first commit

This commit is contained in:
liyunze 2026-11-05 14:48:25 +08:00
commit ba3fb012b5
38 changed files with 3561 additions and 0 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
.git
.gitignore
backend/target
frontend/node_modules
frontend/dist
**/.DS_Store
**/*.log
.env

11
.env.example Normal file
View file

@ -0,0 +1,11 @@
# Twitch Helix API (https://dev.twitch.tv/console/apps)
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
TWITCH_LOGIN=rythondev
# CMS authentication
ADMIN_USER=admin
ADMIN_PASSWORD=change-me
# Server
PORT=3000

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
.env
data/
backend/target/
frontend/node_modules/
frontend/dist/
.zed/
.vscode/
./backend/target/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

41
Dockerfile Normal file
View file

@ -0,0 +1,41 @@
# ---- Frontend build ----
FROM node:24-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/bun.lock ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# ---- Backend build ----
FROM rust:1.98-alpine AS backend
WORKDIR /app/backend
COPY backend/Cargo.toml backend/Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
COPY backend/src ./src
RUN touch src/main.rs && cargo build --release
# ---- Runtime ----
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=backend /app/backend/target/release/backend ./backend
COPY --from=frontend /app/frontend/dist ./frontend/dist
ENV STATIC_DIR=/app/frontend/dist \
DATABASE_PATH=/app/data/media_kit.db
RUN addgroup -S app \
&& adduser -S app -G app \
&& mkdir -p /app/data \
&& chown -R app:app /app
USER app
EXPOSE 3000
CMD ["./backend"]

96
README.md Normal file
View file

@ -0,0 +1,96 @@
# RythonDev Media Kit
A minimal media kit for **RythonDev** with live Twitch analytics and a small CMS for
text content and YouTube / TikTok / Instagram follower counts.
- **Backend:** Rust (Axum) + SQLite (`rusqlite`)
- **Frontend:** Svelte 5 + Vite
- **Caching:** in-memory cache with a 5 minute TTL
- **Deployment:** Docker + Docker Compose
## Features
- Public media kit page at `/`
- Live Twitch stats (followers, total views, live status, current viewers)
- CMS at `/#/admin` for:
- Text content (`brand_name`, `tagline`, `about`, `email`)
- YouTube, TikTok, and Instagram handles, URLs, and follower counts
- Twitch data is fetched from the Twitch Helix API; other platform counts are managed in the CMS
- SQLite persistence
- 5 minute cache TTL for the public payload and Twitch data
- HTTP Basic Auth for CMS API routes
## Configuration
Copy `.env.example` to `.env` and fill in your values:
```bash
cp .env.example .env
```
| Variable | Description | Default |
| --- | --- | --- |
| `TWITCH_CLIENT_ID` | Twitch Helix API client ID | empty |
| `TWITCH_CLIENT_SECRET` | Twitch Helix API client secret | empty |
| `TWITCH_LOGIN` | Twitch channel login | `rythondev` |
| `ADMIN_USER` | CMS username | `admin` |
| `ADMIN_PASSWORD` | CMS password | `change-me` |
| `PORT` | HTTP port | `3000` |
| `DATABASE_PATH` | SQLite database file path | `data/media_kit.db` |
| `STATIC_DIR` | Directory containing the built frontend | `frontend/dist` |
## Local development
### 1. Backend
```bash
cd backend
cargo run
```
### 2. Frontend
```bash
cd frontend
npm install
npm run dev
```
The Vite dev server proxies `/api` requests to `http://localhost:3000`.
## Production build
```bash
cd frontend
npm install
npm run build
cd ../backend
cargo build --release
```
Then run the backend with the frontend build directory:
```bash
STATIC_DIR=../frontend/dist ./backend/target/release/backend
```
## Docker
```bash
docker compose up --build
```
The app is then available at <http://localhost:3001>. The SQLite database is persisted
in the `media-kit-data` Docker volume.
## API
- `GET /api/media-kit` — public media kit payload (cached for 5 minutes)
- `GET /api/admin/verify` — verify Basic Auth credentials
- `GET /api/admin/state` — current content and platform values (admin)
- `PUT /api/admin/content` — update text content (admin)
- `PUT /api/admin/platforms` — update platform follower counts (admin)
Admin routes use HTTP Basic Auth. The CMS login page stores the Base64-encoded
credentials in `localStorage` and sends them with each admin request.

1
backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

1600
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
backend/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tower-http = { version = "0.6", features = ["fs"] }
base64 = "0.22"
dotenvy = "0.15"

143
backend/src/api.rs Normal file
View file

@ -0,0 +1,143 @@
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
};
use serde_json::{Value, json};
use std::collections::HashMap;
use crate::auth::is_authorized;
use crate::db::{self, PlatformInput};
use crate::state::AppState;
use crate::twitch;
const MEDIA_KIT_CACHE_KEY: &str = "media_kit";
const TTL_SECS: u64 = 300;
type ApiError = (StatusCode, Json<Value>);
fn internal_error(message: impl ToString) -> ApiError {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": message.to_string() })),
)
}
fn unauthorized() -> ApiError {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "unauthorized" })),
)
}
fn require_admin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> {
if is_authorized(state, headers) {
Ok(())
} else {
Err(unauthorized())
}
}
pub async fn get_media_kit(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
if let Some(cached) = state.cache.get(MEDIA_KIT_CACHE_KEY) {
return Ok(Json(cached));
}
let content = {
let conn = state.db.lock().unwrap();
db::get_content(&conn).map_err(internal_error)?
};
let platforms = {
let conn = state.db.lock().unwrap();
db::get_platforms(&conn).map_err(internal_error)?
};
let twitch = match twitch::get_twitch(&state).await {
Ok(value) => value,
Err(message) => json!({
"configured": true,
"found": false,
"login": state.twitch_login,
"message": message,
}),
};
let payload = json!({
"content": content,
"platforms": platforms,
"twitch": twitch,
});
state
.cache
.set(MEDIA_KIT_CACHE_KEY, payload.clone(), TTL_SECS);
Ok(Json(payload))
}
pub async fn admin_verify(State(state): State<AppState>, headers: HeaderMap) -> StatusCode {
if is_authorized(&state, &headers) {
StatusCode::OK
} else {
StatusCode::UNAUTHORIZED
}
}
pub async fn admin_get_state(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers)?;
let conn = state.db.lock().unwrap();
let content = db::get_content(&conn).map_err(internal_error)?;
let platforms = db::get_platforms(&conn).map_err(internal_error)?;
Ok(Json(json!({
"content": content,
"platforms": platforms,
})))
}
pub async fn admin_update_content(
State(state): State<AppState>,
headers: HeaderMap,
Json(entries): Json<HashMap<String, String>>,
) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers)?;
let entries: Vec<(String, String)> = entries.into_iter().collect();
{
let conn = state.db.lock().unwrap();
db::upsert_content(&conn, &entries).map_err(internal_error)?;
}
state.cache.invalidate(MEDIA_KIT_CACHE_KEY);
let conn = state.db.lock().unwrap();
let content = db::get_content(&conn).map_err(internal_error)?;
Ok(Json(json!({ "content": content })))
}
pub async fn admin_update_platforms(
State(state): State<AppState>,
headers: HeaderMap,
Json(platforms): Json<Vec<PlatformInput>>,
) -> Result<Json<Value>, ApiError> {
require_admin(&state, &headers)?;
{
let conn = state.db.lock().unwrap();
db::upsert_platforms(&conn, &platforms).map_err(internal_error)?;
}
state.cache.invalidate(MEDIA_KIT_CACHE_KEY);
let conn = state.db.lock().unwrap();
let saved = db::get_platforms(&conn).map_err(internal_error)?;
Ok(Json(json!({ "platforms": saved })))
}

31
backend/src/auth.rs Normal file
View file

@ -0,0 +1,31 @@
use crate::state::AppState;
use axum::http::{HeaderMap, header};
use base64::Engine;
pub fn is_authorized(state: &AppState, headers: &HeaderMap) -> bool {
let Some(value) = headers.get(header::AUTHORIZATION) else {
return false;
};
let Ok(value) = value.to_str() else {
return false;
};
let Some(encoded) = value.strip_prefix("Basic ") else {
return false;
};
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else {
return false;
};
let Ok(decoded) = String::from_utf8(bytes) else {
return false;
};
let mut parts = decoded.splitn(2, ':');
let user = parts.next().unwrap_or("");
let pass = parts.next().unwrap_or("");
user == state.admin_user && pass == state.admin_password
}

47
backend/src/cache.rs Normal file
View file

@ -0,0 +1,47 @@
use serde_json::Value;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Clone)]
pub struct Cache {
inner: Arc<Mutex<HashMap<String, CacheEntry>>>,
}
struct CacheEntry {
expires_at: Instant,
value: Value,
}
impl Cache {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn get(&self, key: &str) -> Option<Value> {
let guard = self.inner.lock().unwrap();
let entry = guard.get(key)?;
if entry.expires_at <= Instant::now() {
return None;
}
Some(entry.value.clone())
}
pub fn set(&self, key: &str, value: Value, ttl_secs: u64) {
let mut guard = self.inner.lock().unwrap();
guard.insert(
key.to_string(),
CacheEntry {
expires_at: Instant::now() + Duration::from_secs(ttl_secs),
value,
},
);
}
pub fn invalidate(&self, key: &str) {
let mut guard = self.inner.lock().unwrap();
guard.remove(key);
}
}

139
backend/src/db.rs Normal file
View file

@ -0,0 +1,139 @@
use crate::util::now_secs;
use rusqlite::{Connection, params};
use serde::Deserialize;
use serde_json::{Value, json};
#[derive(Debug, Deserialize)]
pub struct PlatformInput {
pub name: String,
pub handle: String,
pub url: String,
pub followers: i64,
}
pub fn init(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS content (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS platforms (
name TEXT PRIMARY KEY,
handle TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
followers INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
"#,
)?;
seed(conn)
}
fn seed(conn: &Connection) -> rusqlite::Result<()> {
let now = now_secs();
let content_defaults = [
("brand_name", "RythonDev"),
("tagline", "Game developer & live streamer"),
("about", "I build games and stream the process on Twitch."),
("email", "hello@rython.dev"),
];
for (key, value) in content_defaults {
conn.execute(
"INSERT OR IGNORE INTO content (key, value, updated_at) VALUES (?1, ?2, ?3)",
params![key, value, now],
)?;
}
let platform_defaults = [
("youtube", "@rythondev", "https://youtube.com/@rythondev", 0),
("tiktok", "@rythondev", "https://tiktok.com/@rythondev", 0),
(
"instagram",
"@rythondev",
"https://instagram.com/rythondev",
0,
),
];
for (name, handle, url, followers) in platform_defaults {
conn.execute(
"INSERT OR IGNORE INTO platforms (name, handle, url, followers, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![name, handle, url, followers, now],
)?;
}
Ok(())
}
pub fn get_content(conn: &Connection) -> rusqlite::Result<Value> {
let mut stmt = conn.prepare("SELECT key, value FROM content ORDER BY key")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut map = serde_json::Map::new();
for row in rows {
let (key, value) = row?;
map.insert(key, Value::String(value));
}
Ok(Value::Object(map))
}
pub fn get_platforms(conn: &Connection) -> rusqlite::Result<Vec<Value>> {
let mut stmt =
conn.prepare("SELECT name, handle, url, followers FROM platforms ORDER BY name")?;
let rows = stmt.query_map([], |row| {
Ok(json!({
"name": row.get::<_, String>(0)?,
"handle": row.get::<_, String>(1)?,
"url": row.get::<_, String>(2)?,
"followers": row.get::<_, i64>(3)?,
}))
})?;
rows.collect()
}
pub fn upsert_content(conn: &Connection, entries: &[(String, String)]) -> rusqlite::Result<()> {
let now = now_secs();
let mut stmt = conn.prepare(
"INSERT INTO content (key, value, updated_at) VALUES (?1, ?2, ?3)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
)?;
for (key, value) in entries {
stmt.execute(params![key, value, now])?;
}
Ok(())
}
pub fn upsert_platforms(conn: &Connection, platforms: &[PlatformInput]) -> rusqlite::Result<()> {
let now = now_secs();
let mut stmt = conn.prepare(
"INSERT INTO platforms (name, handle, url, followers, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(name) DO UPDATE SET
handle = excluded.handle,
url = excluded.url,
followers = excluded.followers,
updated_at = excluded.updated_at",
)?;
for platform in platforms {
stmt.execute(params![
platform.name,
platform.handle,
platform.url,
platform.followers,
now
])?;
}
Ok(())
}

69
backend/src/main.rs Normal file
View file

@ -0,0 +1,69 @@
mod api;
mod auth;
mod cache;
mod db;
mod state;
mod twitch;
mod util;
use axum::{
Router,
routing::{get, put},
};
use state::AppState;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tower_http::services::ServeDir;
fn env_or(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let db_path = env_or("DATABASE_PATH", "data/media_kit.db");
if let Some(parent) = std::path::Path::new(&db_path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).expect("failed to create database directory");
}
}
let conn = rusqlite::Connection::open(&db_path).expect("failed to open database");
db::init(&conn).expect("failed to initialize database");
let app_state = AppState {
db: Arc::new(Mutex::new(conn)),
http: reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("failed to create http client"),
cache: cache::Cache::new(),
twitch_client_id: env_or("TWITCH_CLIENT_ID", ""),
twitch_client_secret: env_or("TWITCH_CLIENT_SECRET", ""),
twitch_login: env_or("TWITCH_LOGIN", "rythondev"),
admin_user: env_or("ADMIN_USER", "admin"),
admin_password: env_or("ADMIN_PASSWORD", "change-me"),
};
let static_dir = env_or("STATIC_DIR", "frontend/dist");
let app = Router::new()
.route("/api/media-kit", get(api::get_media_kit))
.route("/api/admin/verify", get(api::admin_verify))
.route("/api/admin/state", get(api::admin_get_state))
.route("/api/admin/content", put(api::admin_update_content))
.route("/api/admin/platforms", put(api::admin_update_platforms))
.fallback_service(ServeDir::new(&static_dir).append_index_html_on_directories(true))
.with_state(app_state);
let port = env_or("PORT", "3000");
let addr = format!("0.0.0.0:{port}");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.unwrap_or_else(|_| panic!("failed to bind to {addr}"));
println!("RythonDev media kit listening on http://localhost:{port}");
axum::serve(listener, app).await.expect("server error");
}

16
backend/src/state.rs Normal file
View file

@ -0,0 +1,16 @@
use crate::cache::Cache;
use reqwest::Client;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct AppState {
pub db: Arc<Mutex<Connection>>,
pub http: Client,
pub cache: Cache,
pub twitch_client_id: String,
pub twitch_client_secret: String,
pub twitch_login: String,
pub admin_user: String,
pub admin_password: String,
}

169
backend/src/twitch.rs Normal file
View file

@ -0,0 +1,169 @@
use crate::state::AppState;
use crate::util::now_secs;
use serde_json::{Value, json};
const TWITCH_CACHE_KEY: &str = "twitch";
const TOKEN_CACHE_KEY: &str = "twitch_token";
const TTL_SECS: u64 = 300;
pub async fn get_twitch(state: &AppState) -> Result<Value, String> {
if let Some(cached) = state.cache.get(TWITCH_CACHE_KEY) {
return Ok(cached);
}
let data = fetch_twitch(state).await?;
state.cache.set(TWITCH_CACHE_KEY, data.clone(), TTL_SECS);
Ok(data)
}
async fn fetch_twitch(state: &AppState) -> Result<Value, String> {
if state.twitch_client_id.is_empty() || state.twitch_client_secret.is_empty() {
return Ok(json!({
"configured": false,
"login": state.twitch_login,
"message": "Twitch credentials are not configured",
}));
}
let token = get_token(state).await?;
let login = state.twitch_login.clone();
let users = helix_get(state, &token, "https://api.twitch.tv/helix/users", &[("login", login.as_str())]).await?;
let user = users
.get("data")
.and_then(|data| data.get(0))
.cloned()
.unwrap_or(Value::Null);
if user.is_null() {
return Ok(json!({
"configured": true,
"found": false,
"login": login,
"message": "Twitch channel not found",
}));
}
let user_id = user
.get("id")
.and_then(|id| id.as_str())
.ok_or_else(|| "Twitch user id missing from response".to_string())?
.to_string();
let stream_query = [("user_id", user_id.as_str())];
let (followers, streams) = tokio::join!(
get_follower_count(state, &token, &user_id),
helix_get(
state,
&token,
"https://api.twitch.tv/helix/streams",
&stream_query,
),
);
let stream = streams
.ok()
.and_then(|body| body.get("data").and_then(|data| data.get(0)).cloned())
.unwrap_or(Value::Null);
let live = !stream.is_null();
Ok(json!({
"configured": true,
"found": true,
"login": user.get("login").cloned().unwrap_or(Value::String(login)),
"display_name": user.get("display_name").cloned().unwrap_or(Value::Null),
"profile_image_url": user.get("profile_image_url").cloned().unwrap_or(Value::Null),
"followers": followers,
"live": live,
"title": stream.get("title").cloned().unwrap_or(Value::Null),
"game_name": stream.get("game_name").cloned().unwrap_or(Value::Null),
"viewer_count": stream.get("viewer_count").cloned().unwrap_or(Value::Null),
"updated_at": now_secs(),
}))
}
async fn get_follower_count(state: &AppState, token: &str, user_id: &str) -> i64 {
// Get Users Follows was removed (410 Gone). Channel Followers returns `total`
// for app access tokens; the follower list itself requires a user token.
match helix_get(
state,
token,
"https://api.twitch.tv/helix/channels/followers",
&[("broadcaster_id", user_id), ("first", "1")],
)
.await
{
Ok(body) => body
.get("total")
.and_then(|total| total.as_i64())
.unwrap_or(0),
Err(_) => 0,
}
}
async fn helix_get(
state: &AppState,
token: &str,
url: &str,
query: &[(&str, &str)],
) -> Result<Value, String> {
state
.http
.get(url)
.query(query)
.header("Client-Id", &state.twitch_client_id)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}
async fn get_token(state: &AppState) -> Result<String, String> {
if let Some(cached) = state.cache.get(TOKEN_CACHE_KEY) {
if let Some(token) = cached.get("access_token").and_then(|t| t.as_str()) {
return Ok(token.to_string());
}
}
let response: Value = state
.http
.post("https://id.twitch.tv/oauth2/token")
.query(&[
("client_id", state.twitch_client_id.as_str()),
("client_secret", state.twitch_client_secret.as_str()),
("grant_type", "client_credentials"),
])
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let token = response
.get("access_token")
.and_then(|t| t.as_str())
.ok_or_else(|| "Twitch token missing access_token".to_string())?
.to_string();
let expires_in = response
.get("expires_in")
.and_then(|t| t.as_u64())
.unwrap_or(3600);
let ttl = expires_in.saturating_sub(60).max(60);
state
.cache
.set(TOKEN_CACHE_KEY, json!({ "access_token": token }), ttl);
Ok(token)
}

8
backend/src/util.rs Normal file
View file

@ -0,0 +1,8 @@
use std::time::{SystemTime, UNIX_EPOCH};
pub fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}

157
bun.lock Normal file
View file

@ -0,0 +1,157 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"dependencies": {
"@tailwindcss/vite": "^4.3.4",
"tailwindcss": "^4.3.4",
},
},
},
"packages": {
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxc-project/types": ["@oxc-project/types@0.156.0", "", {}, "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.16", "", { "os": "android", "cpu": "arm" }, "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.16", "", { "os": "android", "cpu": "arm64" }, "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.16", "", { "os": "linux", "cpu": "arm" }, "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.16", "", { "os": "none", "cpu": "arm64" }, "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.5", "jiti": "^2.7.0", "lightningcss": "1.33.0", "magic-string": "^1.1.0", "source-map-js": "^1.2.1", "tailwindcss": "4.3.4" } }, "sha512-b7QK/QG04v3j04NbpG82XLCedRyulnkuwUDwRa9oK/ypnJIzMapz2Wg+0TltVczjUzDaJJHIa1+jj9aMUOFMTQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.4", "@tailwindcss/oxide-darwin-arm64": "4.3.4", "@tailwindcss/oxide-darwin-x64": "4.3.4", "@tailwindcss/oxide-freebsd-x64": "4.3.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.4", "@tailwindcss/oxide-linux-arm64-musl": "4.3.4", "@tailwindcss/oxide-linux-x64-gnu": "4.3.4", "@tailwindcss/oxide-linux-x64-musl": "4.3.4", "@tailwindcss/oxide-wasm32-wasi": "4.3.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.4", "@tailwindcss/oxide-win32-x64-msvc": "4.3.4" } }, "sha512-riqsRw2sLF7B1sNCCkTl3KYXEqvhTiABzZ/4oxxHfZ3c3Td60bCBfOrUuUGOiHagd3E7V+zG46c+XBDWZGFBcg=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.4", "", { "os": "android", "cpu": "arm64" }, "sha512-qkjxt+EFocOi3mtsUS3ndlj6yj4SHubwBdvBynLnS8T4mvSzX0Enn0CpypJKmBEtczUAXOHVZKA2zTGzSNU2Sg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i/awUA/7mJQAbA3AzCW8aQtUh5goMieUdF1zOoclLaJUCxDvsXQ8VJqpQZY3dfcHS6PJjd3tw25nNneD/mEviQ=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-q56V9U6AnoYz/yj/alkDLhSQ9AZoXQSEl93f7WETa0+32CudbqM57BXdYXfMYPKudueXKaA+MG6L/0KRseF7Jg=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-r0W7A+Z9HTHh7bwihJl45E/zZKLjxD50/jToVF/E/RZHSl8+EWO+4yKKJpPMcUooT9hdoip3Zx1uWnbMg4N79g=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-3aih/sRxzeYfi2Bkh+bGYNf2YU9Llamb2mpA7BNF1obYzdgEeWDAMQzK6BSwzLhs/eTdOaSur92qrGfchagUtQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-ktJSrPGQou4OqL88rDEx5CkeM9BjzrwZvYo8u7B7VL1zkBa2XBm351o4MQrek95qikb2M++26Y7w7vbH6Mgl8g=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-rt3hjUxT7utAtcB+df6wHyaXDyxhE7Zd1x7L/nvB5Jb2qBb8dZwFUoWqeRja1BaF5OlSqkKSjBmVXj9OsevmCQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-XE3jO2yOOWjttpeGvpwf3/b274NMi3QapQSAI/EzsgrmYNan1Tw8dXT9JQkN7K0OgvuqiC8mcCyNvGzEkpVJPQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-PaAZ2FjJaZk9SQLYtKIfQWiFm/xtl7pX2MKPgCzn0mS/VU54yu+vP457AJdB2XFLxU3gO2abLCt8N7Lg4EbKNg=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.4", "", { "dependencies": { "@emnapi/core": "^1.11.3", "@emnapi/runtime": "^1.11.3", "@emnapi/wasi-threads": "^1.2.3", "@napi-rs/wasm-runtime": "^1.2.2", "@tybys/wasm-util": "^0.10.3", "tslib": "^2.8.1" } }, "sha512-bjO9WmoM687p8AoCIyw1DUqzpAs4JICLvNmIPxIWDmhw8IOsdubtWQLShyJmISnDKtRy1BBApHVOUl6sNFQQJA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-jCrwCvWVtt7ruMXp6dFPd5KBNU8o1tWtEdZbTL/w/hvD3lUYZA0E2mmfvTzW4QLHIx5wGQU9HeY7997R1QTrEg=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-cge1wcedn1FrRSXL22lF5sNDYr8c9+VImk7zFYLPs12jkDmchSrZNfzWqOKub+5Hb/aYRdDEqERGGDKONBlOzQ=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.4", "", { "dependencies": { "@tailwindcss/node": "4.3.4", "@tailwindcss/oxide": "4.3.4", "tailwindcss": "4.3.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-wGukGWb8SVuN7+FJvSS7M5eSJWDIWwVSvUvj1q41eTIhGPsS2DAgFWjA7YURJNwjHSgty59Hz8iMUiyO+YMHBQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"enhanced-resolve": ["enhanced-resolve@5.26.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"magic-string": ["magic-string@1.4.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w=="],
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="],
"rolldown": ["rolldown@1.2.16", "", { "dependencies": { "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.16", "@rolldown/binding-android-arm64": "1.2.16", "@rolldown/binding-darwin-arm64": "1.2.16", "@rolldown/binding-darwin-x64": "1.2.16", "@rolldown/binding-freebsd-x64": "1.2.16", "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", "@rolldown/binding-linux-arm64-gnu": "1.2.16", "@rolldown/binding-linux-arm64-musl": "1.2.16", "@rolldown/binding-linux-ppc64-gnu": "1.2.16", "@rolldown/binding-linux-s390x-gnu": "1.2.16", "@rolldown/binding-linux-x64-gnu": "1.2.16", "@rolldown/binding-linux-x64-musl": "1.2.16", "@rolldown/binding-openharmony-arm64": "1.2.16", "@rolldown/binding-win32-arm64-msvc": "1.2.16", "@rolldown/binding-win32-x64-msvc": "1.2.16" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw=="],
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"tailwindcss": ["tailwindcss@4.3.4", "", {}, "sha512-PCJZTFFdG+sL3gvnP1Do9o6GbkvH2q0p04xSs3f7cSvmAeK+sttG+/7ncK1fdRwKFfC7n+wDDJaOMBSefkI9ow=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"vite": ["vite@8.3.6", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.32", "rolldown": "~1.2.16", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.7", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || 2.0.0-beta.2 || 2.0.0-beta.3", "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2 || 2.0.0-beta.3" }, "bundled": true }, "sha512-zJccwplOAbgfZju95IUHazLnx9RAPtPN2YGJXI6UbrgeaR9QfPjGrQL6ERkSAtnVCA4Q+83ApGyaX5zdpCaKkQ=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.9.1", "", { "bundled": true }, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="],
}
}

20
docker-compose.yml Normal file
View file

@ -0,0 +1,20 @@
services:
media-kit:
build: .
ports:
- "3000:3000"
environment:
TWITCH_CLIENT_ID: ${TWITCH_CLIENT_ID:-}
TWITCH_CLIENT_SECRET: ${TWITCH_CLIENT_SECRET:-}
TWITCH_LOGIN: ${TWITCH_LOGIN:-rythondev}
ADMIN_USER: ${ADMIN_USER:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me}
PORT: "3000"
DATABASE_PATH: /app/data/media_kit.db
STATIC_DIR: /app/frontend/dist
volumes:
- media-kit-data:/app/data
restart: unless-stopped
volumes:
media-kit-data:

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

47
frontend/README.md Normal file
View file

@ -0,0 +1,47 @@
# Svelte + TS + Vite
This template should help get you started developing with Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```

221
frontend/bun.lock Normal file
View file

@ -0,0 +1,221 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "frontend",
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/vite": "^4.3.4",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^24.19.2",
"svelte": "^5.57.2",
"svelte-check": "^4.7.7",
"tailwindcss": "^4.3.4",
"typescript": "~6.0.3",
"vite": "^8.3.5",
},
},
},
"packages": {
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxc-project/types": ["@oxc-project/types@0.156.0", "", {}, "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.16", "", { "os": "android", "cpu": "arm" }, "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.16", "", { "os": "android", "cpu": "arm64" }, "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.16", "", { "os": "linux", "cpu": "arm" }, "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.16", "", { "os": "none", "cpu": "arm64" }, "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.3.1", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.5", "jiti": "^2.7.0", "lightningcss": "1.33.0", "magic-string": "^1.1.0", "source-map-js": "^1.2.1", "tailwindcss": "4.3.4" } }, "sha512-b7QK/QG04v3j04NbpG82XLCedRyulnkuwUDwRa9oK/ypnJIzMapz2Wg+0TltVczjUzDaJJHIa1+jj9aMUOFMTQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.4", "@tailwindcss/oxide-darwin-arm64": "4.3.4", "@tailwindcss/oxide-darwin-x64": "4.3.4", "@tailwindcss/oxide-freebsd-x64": "4.3.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.4", "@tailwindcss/oxide-linux-arm64-musl": "4.3.4", "@tailwindcss/oxide-linux-x64-gnu": "4.3.4", "@tailwindcss/oxide-linux-x64-musl": "4.3.4", "@tailwindcss/oxide-wasm32-wasi": "4.3.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.4", "@tailwindcss/oxide-win32-x64-msvc": "4.3.4" } }, "sha512-riqsRw2sLF7B1sNCCkTl3KYXEqvhTiABzZ/4oxxHfZ3c3Td60bCBfOrUuUGOiHagd3E7V+zG46c+XBDWZGFBcg=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.4", "", { "os": "android", "cpu": "arm64" }, "sha512-qkjxt+EFocOi3mtsUS3ndlj6yj4SHubwBdvBynLnS8T4mvSzX0Enn0CpypJKmBEtczUAXOHVZKA2zTGzSNU2Sg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i/awUA/7mJQAbA3AzCW8aQtUh5goMieUdF1zOoclLaJUCxDvsXQ8VJqpQZY3dfcHS6PJjd3tw25nNneD/mEviQ=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-q56V9U6AnoYz/yj/alkDLhSQ9AZoXQSEl93f7WETa0+32CudbqM57BXdYXfMYPKudueXKaA+MG6L/0KRseF7Jg=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-r0W7A+Z9HTHh7bwihJl45E/zZKLjxD50/jToVF/E/RZHSl8+EWO+4yKKJpPMcUooT9hdoip3Zx1uWnbMg4N79g=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-3aih/sRxzeYfi2Bkh+bGYNf2YU9Llamb2mpA7BNF1obYzdgEeWDAMQzK6BSwzLhs/eTdOaSur92qrGfchagUtQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-ktJSrPGQou4OqL88rDEx5CkeM9BjzrwZvYo8u7B7VL1zkBa2XBm351o4MQrek95qikb2M++26Y7w7vbH6Mgl8g=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-rt3hjUxT7utAtcB+df6wHyaXDyxhE7Zd1x7L/nvB5Jb2qBb8dZwFUoWqeRja1BaF5OlSqkKSjBmVXj9OsevmCQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-XE3jO2yOOWjttpeGvpwf3/b274NMi3QapQSAI/EzsgrmYNan1Tw8dXT9JQkN7K0OgvuqiC8mcCyNvGzEkpVJPQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-PaAZ2FjJaZk9SQLYtKIfQWiFm/xtl7pX2MKPgCzn0mS/VU54yu+vP457AJdB2XFLxU3gO2abLCt8N7Lg4EbKNg=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.4", "", { "dependencies": { "@emnapi/core": "^1.11.3", "@emnapi/runtime": "^1.11.3", "@emnapi/wasi-threads": "^1.2.3", "@napi-rs/wasm-runtime": "^1.2.2", "@tybys/wasm-util": "^0.10.3", "tslib": "^2.8.1" } }, "sha512-bjO9WmoM687p8AoCIyw1DUqzpAs4JICLvNmIPxIWDmhw8IOsdubtWQLShyJmISnDKtRy1BBApHVOUl6sNFQQJA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-jCrwCvWVtt7ruMXp6dFPd5KBNU8o1tWtEdZbTL/w/hvD3lUYZA0E2mmfvTzW4QLHIx5wGQU9HeY7997R1QTrEg=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-cge1wcedn1FrRSXL22lF5sNDYr8c9+VImk7zFYLPs12jkDmchSrZNfzWqOKub+5Hb/aYRdDEqERGGDKONBlOzQ=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.4", "", { "dependencies": { "@tailwindcss/node": "4.3.4", "@tailwindcss/oxide": "4.3.4", "tailwindcss": "4.3.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-wGukGWb8SVuN7+FJvSS7M5eSJWDIWwVSvUvj1q41eTIhGPsS2DAgFWjA7YURJNwjHSgty59Hz8iMUiyO+YMHBQ=="],
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.8", "", {}, "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ=="],
"@types/estree": ["@types/estree@1.0.10", "", {}, "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g=="],
"@types/node": ["@types/node@24.19.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ=="],
"acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="],
"enhanced-resolve": ["enhanced-resolve@5.26.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.4.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@1.4.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"rolldown": ["rolldown@1.2.16", "", { "dependencies": { "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.16", "@rolldown/binding-android-arm64": "1.2.16", "@rolldown/binding-darwin-arm64": "1.2.16", "@rolldown/binding-darwin-x64": "1.2.16", "@rolldown/binding-freebsd-x64": "1.2.16", "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", "@rolldown/binding-linux-arm64-gnu": "1.2.16", "@rolldown/binding-linux-arm64-musl": "1.2.16", "@rolldown/binding-linux-ppc64-gnu": "1.2.16", "@rolldown/binding-linux-s390x-gnu": "1.2.16", "@rolldown/binding-linux-x64-gnu": "1.2.16", "@rolldown/binding-linux-x64-musl": "1.2.16", "@rolldown/binding-openharmony-arm64": "1.2.16", "@rolldown/binding-win32-arm64-msvc": "1.2.16", "@rolldown/binding-win32-x64-msvc": "1.2.16" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"svelte": ["svelte@5.57.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-5mAftM3SoDV9X0zdF386F9p2pv8iKOwi/KfBS1XnBAkVCKFzVOLHUoa8iGWAt4buoicLmyWVRpuuD5wplXOQsg=="],
"svelte-check": ["svelte-check@4.7.8", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-laZ49GFHZ3b0z2PWinJWL0syehc/PTR8aHaUaTiBkWJ+4Y38nhxjmXsSz7CbGa3VdKopViVbrBH3bGNqIAI0bQ=="],
"tailwindcss": ["tailwindcss@4.3.4", "", {}, "sha512-PCJZTFFdG+sL3gvnP1Do9o6GbkvH2q0p04xSs3f7cSvmAeK+sttG+/7ncK1fdRwKFfC7n+wDDJaOMBSefkI9ow=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.7", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || 2.0.0-beta.2 || 2.0.0-beta.3", "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2 || 2.0.0-beta.3" }, "bundled": true }, "sha512-zJccwplOAbgfZju95IUHazLnx9RAPtPN2YGJXI6UbrgeaR9QfPjGrQL6ERkSAtnVCA4Q+83ApGyaX5zdpCaKkQ=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.9.1", "", { "bundled": true }, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="],
"svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
}
}

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Media kit and audience stats for RythonDev." />
<title>RythonDev — Media Kit</title>
</head>
<body class="bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-50">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

23
frontend/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/vite": "^4.3.4",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^24.19.2",
"svelte": "^5.57.2",
"svelte-check": "^4.7.7",
"tailwindcss": "^4.3.4",
"typescript": "~6.0.3",
"vite": "^8.3.5"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

18
frontend/src/App.svelte Normal file
View file

@ -0,0 +1,18 @@
<script lang="ts">
import Admin from './routes/Admin.svelte'
import MediaKit from './routes/MediaKit.svelte'
let route = $state(window.location.hash || '#/')
function onHashChange() {
route = window.location.hash || '#/'
}
</script>
<svelte:window onhashchange={onHashChange} />
{#if route.startsWith('#/admin')}
<Admin />
{:else}
<MediaKit />
{/if}

1
frontend/src/app.css Normal file
View file

@ -0,0 +1 @@
@import "tailwindcss";

78
frontend/src/lib/api.ts Normal file
View file

@ -0,0 +1,78 @@
import type { AdminState, MediaKit, Platform } from './types'
const TOKEN_KEY = 'rythondev_admin_token'
function authHeaders(): Record<string, string> {
const token = localStorage.getItem(TOKEN_KEY)
return token ? { Authorization: `Basic ${token}` } : {}
}
export function getStoredToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function clearStoredToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
export async function fetchMediaKit(): Promise<MediaKit> {
const res = await fetch('/api/media-kit')
if (!res.ok) throw new Error('Failed to load media kit')
return res.json()
}
export async function verifyAdmin(username: string, password: string): Promise<boolean> {
const token = btoa(`${username}:${password}`)
const res = await fetch('/api/admin/verify', {
headers: { Authorization: `Basic ${token}` },
})
if (!res.ok) return false
localStorage.setItem(TOKEN_KEY, token)
return true
}
export async function fetchAdminState(): Promise<AdminState> {
const res = await fetch('/api/admin/state', { headers: authHeaders() })
if (res.status === 401) {
clearStoredToken()
throw new Error('unauthorized')
}
if (!res.ok) throw new Error('Failed to load admin state')
return res.json()
}
export async function updateContent(content: Record<string, string>): Promise<Record<string, string>> {
const res = await fetch('/api/admin/content', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(content),
})
if (res.status === 401) {
clearStoredToken()
throw new Error('unauthorized')
}
if (!res.ok) throw new Error('Failed to save content')
const data = await res.json()
return data.content
}
export async function updatePlatforms(platforms: Platform[]): Promise<Platform[]> {
const res = await fetch('/api/admin/platforms', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(platforms),
})
if (res.status === 401) {
clearStoredToken()
throw new Error('unauthorized')
}
if (!res.ok) throw new Error('Failed to save platforms')
const data = await res.json()
return data.platforms
}

32
frontend/src/lib/types.ts Normal file
View file

@ -0,0 +1,32 @@
export interface Platform {
name: string
handle: string
url: string
followers: number
}
export interface TwitchData {
configured?: boolean
found?: boolean
login?: string
display_name?: string
profile_image_url?: string
followers?: number
live?: boolean
title?: string
game_name?: string
viewer_count?: number
updated_at?: number
message?: string
}
export interface MediaKit {
content: Record<string, string>
platforms: Platform[]
twitch: TwitchData | null
}
export interface AdminState {
content: Record<string, string>
platforms: Platform[]
}

9
frontend/src/main.ts Normal file
View file

@ -0,0 +1,9 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app

View file

@ -0,0 +1,261 @@
<script lang="ts">
import { onMount } from 'svelte'
import {
clearStoredToken,
fetchAdminState,
getStoredToken,
updateContent,
updatePlatforms,
verifyAdmin,
} from '../lib/api'
import type { Platform } from '../lib/types'
let authed = $state(false)
let loading = $state(true)
let message = $state('')
let error = $state('')
let username = $state('')
let password = $state('')
let content = $state<Record<string, string>>({})
let platforms = $state<Platform[]>([])
onMount(async () => {
if (getStoredToken()) {
await loadState()
} else {
loading = false
}
})
function titleCase(key: string): string {
return key
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
async function loadState() {
loading = true
error = ''
try {
const state = await fetchAdminState()
content = state.content
platforms = state.platforms
authed = true
} catch (err) {
authed = false
error = err instanceof Error ? err.message : 'Unable to load admin state'
} finally {
loading = false
}
}
async function handleLogin(event: SubmitEvent) {
event.preventDefault()
error = ''
message = ''
if (await verifyAdmin(username, password)) {
password = ''
await loadState()
} else {
error = 'Invalid credentials'
}
}
function handleLogout() {
clearStoredToken()
authed = false
content = {}
platforms = []
message = ''
error = ''
}
async function handleSaveContent() {
error = ''
message = ''
try {
content = await updateContent(content)
message = 'Content saved'
} catch (err) {
error = err instanceof Error ? err.message : 'Unable to save content'
}
}
async function handleSavePlatforms() {
error = ''
message = ''
try {
const payload = platforms.map((platform) => ({
...platform,
followers: Number(platform.followers) || 0,
}))
platforms = await updatePlatforms(payload)
message = 'Platforms saved'
} catch (err) {
error = err instanceof Error ? err.message : 'Unable to save platforms'
}
}
</script>
<main class="mx-auto w-full max-w-[920px] px-6 pt-12 pb-[72px]">
<header class="flex items-start justify-between gap-6">
<div>
<p class="mb-3 text-xs font-semibold tracking-[0.14em] uppercase text-gray-500 dark:text-gray-400">CMS</p>
<h1 class="text-[clamp(36px,8vw,52px)] leading-[1.05] font-semibold tracking-tight">
RythonDev media kit
</h1>
</div>
<div class="flex items-center gap-3">
<a class="text-sm text-gray-500 hover:text-neutral-900 dark:text-gray-400 dark:hover:text-neutral-50" href="#/">
View media kit
</a>
{#if authed}
<button
class="cursor-pointer rounded-[10px] border border-gray-200 bg-transparent px-4 py-2.5 text-sm font-medium text-neutral-900 transition-opacity hover:opacity-85 dark:border-zinc-800 dark:text-neutral-50"
onclick={handleLogout}
>
Log out
</button>
{/if}
</div>
</header>
{#if loading}
<div class="py-8 text-gray-500 dark:text-gray-400">Loading…</div>
{:else if !authed}
<form class="mt-10 max-w-[400px]" onsubmit={handleLogin}>
<h2 class="mb-2 text-xl font-semibold tracking-tight">Sign in</h2>
<p class="text-gray-500 dark:text-gray-400">Enter the admin credentials configured for this instance.</p>
<label class="flex flex-col gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Username</span>
<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={username}
autocomplete="username"
required
/>
</label>
<label class="flex flex-col gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Password</span>
<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={password}
type="password"
autocomplete="current-password"
required
/>
</label>
{#if error}
<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>
{/if}
<button
class="cursor-pointer rounded-[10px] border border-transparent bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-85 dark:bg-neutral-50 dark:text-neutral-950"
type="submit"
>
Sign in
</button>
</form>
{:else}
<div class="mt-10 grid grid-cols-1 gap-8 md:grid-cols-2 md:items-start">
<section class="border-t border-gray-200 pt-8 dark:border-zinc-800">
<div class="mb-5 flex items-center justify-between gap-4">
<h2 class="text-xl font-semibold tracking-tight">Text content</h2>
<button
class="cursor-pointer rounded-[10px] border border-transparent bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-85 dark:bg-neutral-50 dark:text-neutral-950"
onclick={handleSaveContent}
>
Save content
</button>
</div>
<div class="grid grid-cols-1 gap-4">
{#each Object.keys(content) as key}
<label class="flex flex-col gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>{titleCase(key)}</span>
{#if key === 'about'}
<textarea
class="w-full resize-y 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"
rows="4"
bind:value={content[key]}
></textarea>
{: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"
bind:value={content[key]}
/>
{/if}
</label>
{/each}
</div>
</section>
<section class="border-t border-gray-200 pt-8 dark:border-zinc-800">
<div class="mb-5 flex items-center justify-between gap-4">
<h2 class="text-xl font-semibold tracking-tight">Follower counts</h2>
<button
class="cursor-pointer rounded-[10px] border border-transparent bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-85 dark:bg-neutral-50 dark:text-neutral-950"
onclick={handleSavePlatforms}
>
Save platforms
</button>
</div>
<div class="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] gap-5">
{#each platforms as platform, index (platform.name)}
<div
class="rounded-2xl border border-gray-200 bg-neutral-50 p-5 dark:border-zinc-800 dark:bg-neutral-900"
>
<div class="mb-4 text-sm font-semibold">{titleCase(platform.name)}</div>
<div class="grid grid-cols-1 gap-4">
<label class="flex flex-col gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Handle</span>
<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={platform.handle}
/>
</label>
<label class="flex flex-col gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>URL</span>
<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={platform.url}
/>
</label>
<label class="flex flex-col gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Followers</span>
<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"
type="number"
min="0"
step="1"
bind:value={platform.followers}
/>
</label>
</div>
</div>
{/each}
</div>
</section>
</div>
{#if message}
<div class="mt-6 rounded-[10px] bg-green-600/10 px-4 py-3 text-sm text-green-600 dark:text-green-400">
{message}
</div>
{/if}
{#if error}
<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>
{/if}
{/if}
</main>

View file

@ -0,0 +1,153 @@
<script lang="ts">
import { onMount } from 'svelte'
import { fetchMediaKit } from '../lib/api'
import type { MediaKit } from '../lib/types'
let data = $state<MediaKit | null>(null)
let error = $state('')
onMount(async () => {
try {
data = await fetchMediaKit()
} catch (err) {
error = err instanceof Error ? err.message : 'Something went wrong'
}
})
const content = $derived(data?.content ?? {})
const platforms = $derived(data?.platforms ?? [])
const twitch = $derived(data?.twitch ?? null)
const twitchLogin = $derived(twitch?.login ?? '')
function formatCount(value: number | undefined | null): string {
if (value == null) return '—'
if (value >= 1_000_000) {
const millions = value / 1_000_000
return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1).replace(/\.0$/, '')}M`
}
if (value >= 1_000) {
const thousands = value / 1_000
return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1).replace(/\.0$/, '')}K`
}
return value.toLocaleString()
}
</script>
{#if error}
<main class="mx-auto w-full max-w-[920px] px-6 pt-12 pb-[72px]">
<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]">
<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>
{/if}
</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">
{content.about ?? ''}
</p>
{#if content.email}
<a
class="mt-4 inline-block text-violet-600 dark:text-violet-400"
href="mailto:{content.email}">{content.email}</a
>
{/if}
</section>
<section class="mt-14 border-t border-gray-200 pt-8 dark:border-zinc-800">
<h2 class="text-xl font-semibold tracking-tight">Twitch</h2>
{#if twitch?.configured && twitch?.found}
<div class="mt-6 grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-4">
<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">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}
</section>
<section class="mt-14 border-t border-gray-200 pt-8 dark:border-zinc-800">
<h2 class="text-xl font-semibold tracking-tight">Platforms</h2>
<div class="mt-6 grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4">
{#each platforms as platform (platform.name)}
<a
class="flex flex-col gap-1 rounded-2xl border border-gray-200 bg-neutral-50 p-5 transition-colors hover:border-violet-600 dark:border-zinc-800 dark:bg-neutral-900 dark:hover:border-violet-400"
href={platform.url}
target="_blank"
rel="noreferrer"
>
<span class="text-sm font-semibold tracking-[0.08em] uppercase text-gray-500 dark:text-gray-400">
{platform.name}
</span>
<span class="text-base">{platform.handle}</span>
<span class="mt-2 text-2xl font-semibold tracking-tight">
{formatCount(platform.followers)} followers
</span>
</a>
{/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

@ -0,0 +1,2 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}

View file

@ -0,0 +1,21 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"module": "esnext",
"types": ["svelte", "vite/client"],
"allowArbitraryExtensions": true,
"noEmit": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,16 @@
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [tailwindcss(), svelte()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})

6
package.json Normal file
View file

@ -0,0 +1,6 @@
{
"dependencies": {
"@tailwindcss/vite": "^4.3.4",
"tailwindcss": "^4.3.4"
}
}