diff --git a/backend/src/api.rs b/backend/src/api.rs index e05a73b..0438247 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use std::collections::HashMap; use crate::auth::is_authorized; -use crate::db::{self, PlatformInput}; +use crate::db::{self, CollabInput, PlatformInput}; use crate::state::AppState; use crate::twitch; @@ -30,6 +30,10 @@ fn unauthorized() -> ApiError { ) } +fn bad_request(message: &str) -> ApiError { + (StatusCode::BAD_REQUEST, Json(json!({ "error": message }))) +} + fn require_admin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { if is_authorized(state, headers) { Ok(()) @@ -53,6 +57,11 @@ pub async fn get_media_kit(State(state): State) -> Result, db::get_platforms(&conn).map_err(internal_error)? }; + let collabs = { + let conn = state.db.lock().unwrap(); + db::get_collabs(&conn).map_err(internal_error)? + }; + let twitch = match twitch::get_twitch(&state).await { Ok(value) => value, Err(message) => json!({ @@ -66,6 +75,7 @@ pub async fn get_media_kit(State(state): State) -> Result, let payload = json!({ "content": content, "platforms": platforms, + "collabs": collabs, "twitch": twitch, }); @@ -93,10 +103,12 @@ pub async fn admin_get_state( 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)?; + let collabs = db::get_collabs(&conn).map_err(internal_error)?; Ok(Json(json!({ "content": content, "platforms": platforms, + "collabs": collabs, }))) } @@ -142,3 +154,39 @@ pub async fn admin_update_platforms( Ok(Json(json!({ "platforms": saved }))) } +pub async fn admin_update_collabs( + State(state): State, + headers: HeaderMap, + Json(collabs): Json>, +) -> Result, ApiError> { + require_admin(&state, &headers)?; + + let mut cleaned = Vec::with_capacity(collabs.len()); + for collab in collabs { + let name = collab.name.trim().to_string(); + let image = collab.image.trim().to_string(); + let url = collab.url.trim().to_string(); + + if name.is_empty() && image.is_empty() && url.is_empty() { + continue; + } + if name.is_empty() { + return Err(bad_request("Each collaboration needs a name")); + } + + cleaned.push(CollabInput { name, image, url }); + } + + { + let mut conn = state.db.lock().unwrap(); + db::replace_collabs(&mut conn, &cleaned).map_err(internal_error)?; + } + + state.cache.invalidate(MEDIA_KIT_CACHE_KEY); + + let conn = state.db.lock().unwrap(); + let saved = db::get_collabs(&conn).map_err(internal_error)?; + + Ok(Json(json!({ "collabs": saved }))) +} + diff --git a/backend/src/db.rs b/backend/src/db.rs index bba6332..766837c 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -11,6 +11,13 @@ pub struct PlatformInput { pub followers: i64, } +#[derive(Debug, Deserialize)] +pub struct CollabInput { + pub name: String, + pub image: String, + pub url: String, +} + pub fn init(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch( r#" @@ -28,6 +35,15 @@ pub fn init(conn: &Connection) -> rusqlite::Result<()> { updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS collabs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + image TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL + ); + "#, )?; seed(conn) @@ -72,6 +88,35 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> { )?; } + let collab_count: i64 = conn.query_row("SELECT COUNT(*) FROM collabs", [], |row| row.get(0))?; + if collab_count == 0 { + let collab_defaults = [ + ( + "ProtonPass", + "/images/collaborations/proton-pass.webp", + "https://proton.me/pass", + ), + ( + "ProtonVPN", + "/images/collaborations/proton-vpn.webp", + "https://protonvpn.com/", + ), + ("Brick", "/images/collaborations/brick.webp", "https://getbrick.com/"), + ( + "Focumon", + "/images/collaborations/focumon.webp", + "https://www.focumon.com/landing", + ), + ]; + + for (index, (name, image, url)) in collab_defaults.iter().enumerate() { + conn.execute( + "INSERT INTO collabs (name, image, url, sort_order, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![name, image, url, index as i64, now], + )?; + } + } + Ok(()) } @@ -120,6 +165,47 @@ pub fn upsert_content(conn: &Connection, entries: &[(String, String)]) -> rusqli Ok(()) } +pub fn get_collabs(conn: &Connection) -> rusqlite::Result> { + let mut stmt = + conn.prepare("SELECT id, name, image, url FROM collabs ORDER BY sort_order, id")?; + + let rows = stmt.query_map([], |row| { + Ok(json!({ + "id": row.get::<_, i64>(0)?, + "name": row.get::<_, String>(1)?, + "image": row.get::<_, String>(2)?, + "url": row.get::<_, String>(3)?, + })) + })?; + + rows.collect() +} + +pub fn replace_collabs(conn: &mut Connection, collabs: &[CollabInput]) -> rusqlite::Result<()> { + let tx = conn.transaction()?; + tx.execute("DELETE FROM collabs", [])?; + + let now = now_secs(); + { + let mut stmt = tx.prepare( + "INSERT INTO collabs (name, image, url, sort_order, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)", + )?; + + for (index, collab) in collabs.iter().enumerate() { + stmt.execute(params![ + collab.name, + collab.image, + collab.url, + index as i64, + now + ])?; + } + } + + tx.commit()?; + Ok(()) +} + pub fn upsert_platforms(conn: &Connection, platforms: &[PlatformInput]) -> rusqlite::Result<()> { let now = now_secs(); let mut stmt = conn.prepare( diff --git a/backend/src/main.rs b/backend/src/main.rs index dbf4416..c0b16db 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -72,6 +72,7 @@ async fn main() { .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)) + .route("/api/admin/collabs", put(api::admin_update_collabs)) .fallback_service(ServeDir::new(&static_dir).append_index_html_on_directories(true)) .with_state(app_state); diff --git a/frontend/index.html b/frontend/index.html index eda6b2d..7906731 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,19 +1,26 @@ - - - - - - RythonDev — Media Kit - - -
- - + + + + + + + + + + RythonDev's Media Kit + + +
+ + diff --git a/frontend/public/images/preview.webp b/frontend/public/images/preview.webp new file mode 100644 index 0000000..f9ab1c8 Binary files /dev/null and b/frontend/public/images/preview.webp differ diff --git a/frontend/src/components/Collabs.svelte b/frontend/src/components/Collabs.svelte index 729640d..8461705 100644 --- a/frontend/src/components/Collabs.svelte +++ b/frontend/src/components/Collabs.svelte @@ -1,32 +1,8 @@ - + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c89f328..86da148 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { AdminState, MediaKit, Platform } from "./types"; +import type { AdminState, Collab, MediaKit, Platform } from "./types"; const TOKEN_KEY = "rythondev_admin_token"; @@ -84,3 +84,27 @@ export async function updatePlatforms( return data.platforms; } +async function readError(res: Response, fallback: string): Promise { + const data = await res.json().catch(() => null); + return typeof data?.error === "string" ? data.error : fallback; +} + +export async function updateCollabs(collabs: Collab[]): Promise { + const res = await fetch("/api/admin/collabs", { + method: "PUT", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify( + collabs.map(({ name, image, url }) => ({ name, image, url })), + ), + }); + + if (res.status === 401) { + clearStoredToken(); + throw new Error("unauthorized"); + } + if (!res.ok) throw new Error(await readError(res, "Failed to save collaborations")); + + const data = await res.json(); + return data.collabs; +} + diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 8137711..905be9f 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -5,6 +5,13 @@ export interface Platform { followers: number; } +export interface Collab { + id: number; + name: string; + image: string; + url: string; +} + export interface TwitchData { configured?: boolean; found?: boolean; @@ -23,10 +30,12 @@ export interface TwitchData { export interface MediaKit { content: Record; platforms: Platform[]; + collabs: Collab[]; twitch: TwitchData | null; } export interface AdminState { content: Record; platforms: Platform[]; + collabs: Collab[]; } diff --git a/frontend/src/routes/Admin.svelte b/frontend/src/routes/Admin.svelte index 4be3b63..1ba2f11 100644 --- a/frontend/src/routes/Admin.svelte +++ b/frontend/src/routes/Admin.svelte @@ -4,11 +4,12 @@ clearStoredToken, fetchAdminState, getStoredToken, + updateCollabs, updateContent, updatePlatforms, verifyAdmin, } from "../lib/api"; - import type { Platform } from "../lib/types"; + import type { Collab, Platform } from "../lib/types"; let authed = $state(false); let loading = $state(true); @@ -19,6 +20,8 @@ let content = $state>({}); let platforms = $state([]); + let collabs = $state([]); + let nextCollabId = -1; onMount(async () => { if (getStoredToken()) { @@ -42,6 +45,7 @@ const state = await fetchAdminState(); content = state.content; platforms = state.platforms; + collabs = state.collabs ?? []; authed = true; } catch (err) { authed = false; @@ -72,6 +76,7 @@ authed = false; content = {}; platforms = []; + collabs = []; message = ""; error = ""; } @@ -88,6 +93,39 @@ } } + function addCollab() { + collabs = [ + ...collabs, + { id: nextCollabId--, name: "", image: "", url: "" }, + ]; + } + + function removeCollab(index: number) { + collabs = collabs.filter((_, i) => i !== index); + } + + function moveCollab(index: number, direction: -1 | 1) { + const target = index + direction; + if (target < 0 || target >= collabs.length) return; + const next = [...collabs]; + [next[index], next[target]] = [next[target], next[index]]; + collabs = next; + } + + async function handleSaveCollabs() { + error = ""; + message = ""; + try { + collabs = await updateCollabs(collabs); + message = "Collaborations saved"; + } catch (err) { + error = + err instanceof Error + ? err.message + : "Unable to save collaborations"; + } + } + async function handleSavePlatforms() { error = ""; message = ""; @@ -296,6 +334,114 @@ {/each} + +
+
+

+ Collaborations +

+
+ + +
+
+ + {#if collabs.length === 0} +

+ No collaborations yet. +

+ {:else} +
+ {#each collabs as collab, index (collab.id)} +
+
+
+ {collab.name || "New collaboration"} +
+
+ + + +
+
+
+ + + +
+
+ {/each} +
+ {/if} +
{#if message} diff --git a/frontend/src/routes/MediaKit.svelte b/frontend/src/routes/MediaKit.svelte index 9a30b5b..02aefba 100644 --- a/frontend/src/routes/MediaKit.svelte +++ b/frontend/src/routes/MediaKit.svelte @@ -18,6 +18,7 @@ let content = $derived(data?.content ?? {}); let platforms = $derived(data?.platforms ?? []); + let collabs = $derived(data?.collabs ?? []); let services = $derived( (content.services ?? "") .split("\n") @@ -185,7 +186,9 @@ -
+
@@ -202,23 +205,29 @@ {/if}
-
About Me
-

+

+ About Me +
+

{content.about ?? ""}

-
-
- Collaborations + {#if collabs.length} +
+
+ Collaborations +
+
- -
+ {/if} {#if services.length}
Services
-
+
    diff --git a/frontend/src/routes/MediaKit_copy.svelte b/frontend/src/routes/MediaKit_copy.svelte deleted file mode 100644 index 1598ccf..0000000 --- a/frontend/src/routes/MediaKit_copy.svelte +++ /dev/null @@ -1,147 +0,0 @@ - - -{#if error} -
    -
    - {error} -
    -
    -{:else if !data} -
    -
    Loading…
    -
    -{:else} -
    -
    - {#if content.profile_image_url} - {content.brand_name - {/if} -
    -

    - {content.brand_name ?? "RythonDev"} -

    - {#if content.tagline} -

    - {content.tagline} -

    - {/if} -
    -
    - -
    -

    About

    -

    - {content.about ?? ""} -

    - {#if content.email} - {content.email} - {/if} -
    - -
    -

    Twitch

    - {#if twitch?.configured && twitch?.found} -
    -
    - Followers - {formatCount(twitch?.followers)} -
    -
    - {:else} -

    - {twitch?.message ?? "Twitch stats are unavailable."} -

    - {/if} -
    - -
    -

    Platforms

    - -
    -
    -{/if}