frontend backend works without docker

This commit is contained in:
liyunze 2026-11-05 16:03:21 +08:00
parent ba3fb012b5
commit 829093e64a
13 changed files with 135 additions and 76 deletions

View file

@ -4,6 +4,8 @@ TWITCH_CLIENT_SECRET=
TWITCH_LOGIN=rythondev
# CMS authentication
# Values containing `$` or other special characters must be single-quoted,
# e.g. ADMIN_PASSWORD='p@$$w0rd!'
ADMIN_USER=admin
ADMIN_PASSWORD=change-me

2
.gitignore vendored
View file

@ -31,3 +31,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
scripts/

View file

@ -28,6 +28,10 @@ Copy `.env.example` to `.env` and fill in your values:
cp .env.example .env
```
> **Note:** values containing `$` must be single-quoted, otherwise the backend's
> dotenv loader will try to expand them as variables. For example:
> `ADMIN_PASSWORD='p@$$w0rd!'`
| Variable | Description | Default |
| --- | --- | --- |
| `TWITCH_CLIENT_ID` | Twitch Helix API client ID | empty |
@ -39,40 +43,51 @@ cp .env.example .env
| `DATABASE_PATH` | SQLite database file path | `data/media_kit.db` |
| `STATIC_DIR` | Directory containing the built frontend | `frontend/dist` |
## Local development
## Local development (no Docker)
### 1. Backend
The quickest way is the root `dev` script, which starts the backend and the Vite
dev server together:
```bash
cd backend
cargo run
```
### 2. Frontend
```bash
cd frontend
npm install
npm run dev
```
- Backend (Rust/Axum) → <http://localhost:3000>
- Frontend (Vite) → <http://localhost:5173>
The Vite dev server proxies `/api` requests to `http://localhost:3000`.
## Production build
If you prefer two terminals, run them separately:
```bash
cd frontend
npm install
npm run build
# terminal 1
npm run dev:backend
cd ../backend
cargo build --release
# terminal 2
npm run dev:frontend
```
Then run the backend with the frontend build directory:
> Rust is required for the backend. If `cargo` is not found, add
> `%USERPROFILE%\.cargo\bin` to your `PATH` (Windows) or `~/.cargo/bin`
> (macOS/Linux).
## Production build (no Docker)
```bash
STATIC_DIR=../frontend/dist ./backend/target/release/backend
npm install
npm run build
npm start
```
This builds the frontend to `frontend/dist`, compiles the backend, and serves the
whole app (static site + API) from <http://localhost:3000>.
The SQLite database is written to `data/media_kit.db` and static files are read
from `frontend/dist` by default. Override them if needed:
```bash
DATABASE_PATH=./data/media_kit.db STATIC_DIR=./frontend/dist npm start
```
## Docker
@ -81,7 +96,7 @@ STATIC_DIR=../frontend/dist ./backend/target/release/backend
docker compose up --build
```
The app is then available at <http://localhost:3001>. The SQLite database is persisted
The app is then available at <http://localhost:3000>. The SQLite database is persisted
in the `media-kit-data` Docker volume.
## API

View file

@ -39,6 +39,7 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
("tagline", "Game developer & live streamer"),
("about", "I build games and stream the process on Twitch."),
("email", "hello@rython.dev"),
("profile_image_url", "/images/pfp_new_medium.webp"),
];
for (key, value) in content_defaults {

View file

@ -19,9 +19,26 @@ fn env_or(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
/// Load `.env` from the current directory, then walk up parent directories
/// until one is found. This lets `cargo run` from `backend/` pick up the
/// repository-root `.env` without Docker.
fn load_env_file() {
let mut dir = std::env::current_dir().unwrap_or_default();
loop {
let candidate = dir.join(".env");
if candidate.exists() {
dotenvy::from_path(&candidate).ok();
return;
}
if !dir.pop() {
return;
}
}
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
load_env_file();
let db_path = env_or("DATABASE_PATH", "data/media_kit.db");
if let Some(parent) = std::path::Path::new(&db_path).parent() {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1 +1,38 @@
@import "tailwindcss";
@font-face {
font-family: "Fredoka";
src: url("/fonts/Fredoka-Regular.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Antonio";
src: url("/fonts/Antonio-Bold.ttf") format("truetype");
font-weight: bold;
font-style: normal;
font-display: swap;
}
/* Montserrat Fonts */
@font-face {
font-family: "Montserrat";
src: url("/fonts/Montserrat-Bold.ttf") format("truetype");
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Montserrat";
src: url("/fonts/Montserrat-Regular.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
html, body {
font-family: Montserrat;
}

View file

@ -187,6 +187,20 @@
rows="4"
bind:value={content[key]}
></textarea>
{:else if key === 'profile_image_url'}
<div class="flex items-start gap-4">
<input
class="w-full rounded-[10px] border border-gray-200 bg-white px-3 py-2.5 text-neutral-900 outline-none focus:border-violet-600 dark:border-zinc-800 dark:bg-neutral-950 dark:text-neutral-50 dark:focus:border-violet-400"
bind:value={content[key]}
/>
{#if content[key]}
<img
class="h-16 w-16 shrink-0 rounded-full border border-gray-200 object-cover dark:border-zinc-800"
src={content[key]}
alt="Profile preview"
/>
{/if}
</div>
{:else}
<input
class="w-full rounded-[10px] border border-gray-200 bg-white px-3 py-2.5 text-neutral-900 outline-none focus:border-violet-600 dark:border-zinc-800 dark:bg-neutral-950 dark:text-neutral-50 dark:focus:border-violet-400"

View file

@ -34,32 +34,38 @@
</script>
{#if error}
<main class="mx-auto w-full max-w-[920px] px-6 pt-12 pb-[72px]">
<main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18">
<div class="mt-6 rounded-[10px] bg-red-600/10 px-4 py-3 text-sm text-red-600 dark:text-red-400">
{error}
</div>
</main>
{:else if !data}
<main class="mx-auto w-full max-w-[920px] px-6 pt-12 pb-[72px]">
<main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18">
<div class="py-8 text-gray-500 dark:text-gray-400">Loading…</div>
</main>
{:else}
<main class="mx-auto w-full max-w-[920px] px-6 pt-12 pb-[72px]">
<header class="pt-6 pb-2">
<p class="mb-3 text-xs font-semibold tracking-[0.14em] uppercase text-gray-500 dark:text-gray-400">
Media kit
</p>
<h1 class="text-[clamp(36px,8vw,52px)] leading-[1.05] font-semibold tracking-tight">
{content.brand_name ?? 'RythonDev'}
</h1>
{#if content.tagline}
<p class="mt-4 text-xl text-gray-500 dark:text-gray-400">{content.tagline}</p>
<main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18">
<header class="flex flex-col items-start gap-6 pt-6 pb-2 sm:flex-row sm:items-center">
{#if content.profile_image_url}
<img
class="h-24 w-24 shrink-0 rounded-md object-cover"
src={content.profile_image_url}
alt={content.brand_name ?? 'Profile image'}
/>
{/if}
<div>
<h1 class="text-[clamp(36px,8vw,52px)] leading-[1.05] font-semibold tracking-tight">
{content.brand_name ?? 'RythonDev'}
</h1>
{#if content.tagline}
<p class="mt-4 text-xl text-gray-500 dark:text-gray-400">{content.tagline}</p>
{/if}
</div>
</header>
<section class="mt-14 border-t border-gray-200 pt-8 dark:border-zinc-800">
<h2 class="text-xl font-semibold tracking-tight">About</h2>
<p class="mt-4 max-w-[640px] text-lg leading-relaxed text-gray-500 dark:text-gray-400">
<p class="mt-4 max-w-160 text-lg leading-relaxed text-gray-500 dark:text-gray-400">
{content.about ?? ''}
</p>
{#if content.email}
@ -80,45 +86,7 @@
<span class="text-sm text-gray-500 dark:text-gray-400">Followers</span>
<span class="text-3xl font-semibold tracking-tight">{formatCount(twitch?.followers)}</span>
</div>
<div
class="flex flex-col gap-2 rounded-2xl border border-gray-200 bg-neutral-50 p-5 dark:border-zinc-800 dark:bg-neutral-900"
>
<span class="text-sm text-gray-500 dark:text-gray-400">Status</span>
<span
class="text-3xl font-semibold tracking-tight {twitch?.live
? 'text-green-600 dark:text-green-400'
: ''}"
>
{twitch?.live ? 'LIVE' : 'Offline'}
</span>
</div>
{#if twitch?.live}
<div
class="flex flex-col gap-2 rounded-2xl border border-gray-200 bg-neutral-50 p-5 dark:border-zinc-800 dark:bg-neutral-900"
>
<span class="text-sm text-gray-500 dark:text-gray-400">Viewers</span>
<span class="text-3xl font-semibold tracking-tight">{formatCount(twitch?.viewer_count)}</span>
</div>
{/if}
</div>
{#if twitch?.live && twitch?.title}
<p class="mt-5">
{twitch.title}
{#if twitch.game_name}
<span class="text-gray-500 dark:text-gray-400">— {twitch.game_name}</span>
{/if}
</p>
{/if}
<a
class="mt-6 inline-block rounded-[10px] bg-neutral-900 px-[18px] py-2.5 text-sm font-medium text-white dark:bg-neutral-50 dark:text-neutral-950"
href="https://twitch.tv/{twitchLogin}"
target="_blank"
rel="noreferrer"
>
Watch on Twitch
</a>
{:else}
<p class="text-gray-500 dark:text-gray-400">{twitch?.message ?? 'Twitch stats are unavailable.'}</p>
{/if}
@ -145,9 +113,5 @@
{/each}
</div>
</section>
<footer class="mt-16 border-t border-gray-200 pt-6 text-sm text-gray-500 dark:border-zinc-800 dark:text-gray-400">
<a href="#/admin" class="hover:text-neutral-900 dark:hover:text-neutral-50">Admin</a>
</footer>
</main>
{/if}

View file

@ -1,6 +1,13 @@
{
"scripts": {
"dev": "node scripts/dev.mjs",
"dev:frontend": "npm --prefix frontend run dev",
"dev:backend": "cargo run --manifest-path backend/Cargo.toml",
"build": "npm --prefix frontend run build && cargo build --manifest-path backend/Cargo.toml",
"start": "cargo run --manifest-path backend/Cargo.toml"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.4",
"tailwindcss": "^4.3.4"
}
}
}