frontend backend works without docker
This commit is contained in:
parent
ba3fb012b5
commit
829093e64a
13 changed files with 135 additions and 76 deletions
|
|
@ -4,6 +4,8 @@ TWITCH_CLIENT_SECRET=
|
||||||
TWITCH_LOGIN=rythondev
|
TWITCH_LOGIN=rythondev
|
||||||
|
|
||||||
# CMS authentication
|
# CMS authentication
|
||||||
|
# Values containing `$` or other special characters must be single-quoted,
|
||||||
|
# e.g. ADMIN_PASSWORD='p@$$w0rd!'
|
||||||
ADMIN_USER=admin
|
ADMIN_USER=admin
|
||||||
ADMIN_PASSWORD=change-me
|
ADMIN_PASSWORD=change-me
|
||||||
|
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -31,3 +31,5 @@ dist-ssr
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
scripts/
|
||||||
|
|
|
||||||
53
README.md
53
README.md
|
|
@ -28,6 +28,10 @@ Copy `.env.example` to `.env` and fill in your values:
|
||||||
cp .env.example .env
|
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 |
|
| Variable | Description | Default |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `TWITCH_CLIENT_ID` | Twitch Helix API client ID | empty |
|
| `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` |
|
| `DATABASE_PATH` | SQLite database file path | `data/media_kit.db` |
|
||||||
| `STATIC_DIR` | Directory containing the built frontend | `frontend/dist` |
|
| `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
|
```bash
|
||||||
cd backend
|
|
||||||
cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Frontend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
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`.
|
The Vite dev server proxies `/api` requests to `http://localhost:3000`.
|
||||||
|
|
||||||
## Production build
|
If you prefer two terminals, run them separately:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
# terminal 1
|
||||||
npm install
|
npm run dev:backend
|
||||||
npm run build
|
|
||||||
|
|
||||||
cd ../backend
|
# terminal 2
|
||||||
cargo build --release
|
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
|
```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
|
## Docker
|
||||||
|
|
@ -81,7 +96,7 @@ STATIC_DIR=../frontend/dist ./backend/target/release/backend
|
||||||
docker compose up --build
|
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.
|
in the `media-kit-data` Docker volume.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||||
("tagline", "Game developer & live streamer"),
|
("tagline", "Game developer & live streamer"),
|
||||||
("about", "I build games and stream the process on Twitch."),
|
("about", "I build games and stream the process on Twitch."),
|
||||||
("email", "hello@rython.dev"),
|
("email", "hello@rython.dev"),
|
||||||
|
("profile_image_url", "/images/pfp_new_medium.webp"),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (key, value) in content_defaults {
|
for (key, value) in content_defaults {
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,26 @@ fn env_or(key: &str, default: &str) -> String {
|
||||||
std::env::var(key).unwrap_or_else(|_| default.to_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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
dotenvy::dotenv().ok();
|
load_env_file();
|
||||||
|
|
||||||
let db_path = env_or("DATABASE_PATH", "data/media_kit.db");
|
let db_path = env_or("DATABASE_PATH", "data/media_kit.db");
|
||||||
if let Some(parent) = std::path::Path::new(&db_path).parent() {
|
if let Some(parent) = std::path::Path::new(&db_path).parent() {
|
||||||
|
|
|
||||||
BIN
frontend/public/fonts/Antonio-Bold.ttf
Normal file
BIN
frontend/public/fonts/Antonio-Bold.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/Fredoka-Regular.ttf
Normal file
BIN
frontend/public/fonts/Fredoka-Regular.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/Montserrat-Bold.ttf
Normal file
BIN
frontend/public/fonts/Montserrat-Bold.ttf
Normal file
Binary file not shown.
BIN
frontend/public/fonts/Montserrat-Regular.ttf
Normal file
BIN
frontend/public/fonts/Montserrat-Regular.ttf
Normal file
Binary file not shown.
|
|
@ -1 +1,38 @@
|
||||||
@import "tailwindcss";
|
@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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,20 @@
|
||||||
rows="4"
|
rows="4"
|
||||||
bind:value={content[key]}
|
bind:value={content[key]}
|
||||||
></textarea>
|
></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}
|
{:else}
|
||||||
<input
|
<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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -34,32 +34,38 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if error}
|
{#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">
|
<div class="mt-6 rounded-[10px] bg-red-600/10 px-4 py-3 text-sm text-red-600 dark:text-red-400">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{:else if !data}
|
{: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>
|
<div class="py-8 text-gray-500 dark:text-gray-400">Loading…</div>
|
||||||
</main>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
<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">
|
||||||
<header class="pt-6 pb-2">
|
<header class="flex flex-col items-start gap-6 pt-6 pb-2 sm:flex-row sm:items-center">
|
||||||
<p class="mb-3 text-xs font-semibold tracking-[0.14em] uppercase text-gray-500 dark:text-gray-400">
|
{#if content.profile_image_url}
|
||||||
Media kit
|
<img
|
||||||
</p>
|
class="h-24 w-24 shrink-0 rounded-md object-cover"
|
||||||
<h1 class="text-[clamp(36px,8vw,52px)] leading-[1.05] font-semibold tracking-tight">
|
src={content.profile_image_url}
|
||||||
{content.brand_name ?? 'RythonDev'}
|
alt={content.brand_name ?? 'Profile image'}
|
||||||
</h1>
|
/>
|
||||||
{#if content.tagline}
|
|
||||||
<p class="mt-4 text-xl text-gray-500 dark:text-gray-400">{content.tagline}</p>
|
|
||||||
{/if}
|
{/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>
|
</header>
|
||||||
|
|
||||||
<section class="mt-14 border-t border-gray-200 pt-8 dark:border-zinc-800">
|
<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>
|
<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 ?? ''}
|
{content.about ?? ''}
|
||||||
</p>
|
</p>
|
||||||
{#if content.email}
|
{#if content.email}
|
||||||
|
|
@ -80,45 +86,7 @@
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">Followers</span>
|
<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>
|
<span class="text-3xl font-semibold tracking-tight">{formatCount(twitch?.followers)}</span>
|
||||||
</div>
|
</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>
|
</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}
|
{:else}
|
||||||
<p class="text-gray-500 dark:text-gray-400">{twitch?.message ?? 'Twitch stats are unavailable.'}</p>
|
<p class="text-gray-500 dark:text-gray-400">{twitch?.message ?? 'Twitch stats are unavailable.'}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -145,9 +113,5 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.4",
|
"@tailwindcss/vite": "^4.3.4",
|
||||||
"tailwindcss": "^4.3.4"
|
"tailwindcss": "^4.3.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue