added preview and collaborations cms

This commit is contained in:
liyunze 2026-11-06 14:15:07 +08:00
parent d71e06ea39
commit beec58ae63
11 changed files with 362 additions and 203 deletions

View file

@ -7,7 +7,7 @@ use serde_json::{Value, json};
use std::collections::HashMap; use std::collections::HashMap;
use crate::auth::is_authorized; use crate::auth::is_authorized;
use crate::db::{self, PlatformInput}; use crate::db::{self, CollabInput, PlatformInput};
use crate::state::AppState; use crate::state::AppState;
use crate::twitch; 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> { fn require_admin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> {
if is_authorized(state, headers) { if is_authorized(state, headers) {
Ok(()) Ok(())
@ -53,6 +57,11 @@ pub async fn get_media_kit(State(state): State<AppState>) -> Result<Json<Value>,
db::get_platforms(&conn).map_err(internal_error)? 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 { let twitch = match twitch::get_twitch(&state).await {
Ok(value) => value, Ok(value) => value,
Err(message) => json!({ Err(message) => json!({
@ -66,6 +75,7 @@ pub async fn get_media_kit(State(state): State<AppState>) -> Result<Json<Value>,
let payload = json!({ let payload = json!({
"content": content, "content": content,
"platforms": platforms, "platforms": platforms,
"collabs": collabs,
"twitch": twitch, "twitch": twitch,
}); });
@ -93,10 +103,12 @@ pub async fn admin_get_state(
let conn = state.db.lock().unwrap(); let conn = state.db.lock().unwrap();
let content = db::get_content(&conn).map_err(internal_error)?; let content = db::get_content(&conn).map_err(internal_error)?;
let platforms = db::get_platforms(&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!({ Ok(Json(json!({
"content": content, "content": content,
"platforms": platforms, "platforms": platforms,
"collabs": collabs,
}))) })))
} }
@ -142,3 +154,39 @@ pub async fn admin_update_platforms(
Ok(Json(json!({ "platforms": saved }))) Ok(Json(json!({ "platforms": saved })))
} }
pub async fn admin_update_collabs(
State(state): State<AppState>,
headers: HeaderMap,
Json(collabs): Json<Vec<CollabInput>>,
) -> Result<Json<Value>, 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 })))
}

View file

@ -11,6 +11,13 @@ pub struct PlatformInput {
pub followers: i64, 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<()> { pub fn init(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch( conn.execute_batch(
r#" r#"
@ -28,6 +35,15 @@ pub fn init(conn: &Connection) -> rusqlite::Result<()> {
updated_at INTEGER NOT NULL 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) 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(()) Ok(())
} }
@ -120,6 +165,47 @@ pub fn upsert_content(conn: &Connection, entries: &[(String, String)]) -> rusqli
Ok(()) Ok(())
} }
pub fn get_collabs(conn: &Connection) -> rusqlite::Result<Vec<Value>> {
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<()> { pub fn upsert_platforms(conn: &Connection, platforms: &[PlatformInput]) -> rusqlite::Result<()> {
let now = now_secs(); let now = now_secs();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(

View file

@ -72,6 +72,7 @@ async fn main() {
.route("/api/admin/state", get(api::admin_get_state)) .route("/api/admin/state", get(api::admin_get_state))
.route("/api/admin/content", put(api::admin_update_content)) .route("/api/admin/content", put(api::admin_update_content))
.route("/api/admin/platforms", put(api::admin_update_platforms)) .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)) .fallback_service(ServeDir::new(&static_dir).append_index_html_on_directories(true))
.with_state(app_state); .with_state(app_state);

View file

@ -8,7 +8,14 @@
name="description" name="description"
content="Media kit and audience stats for RythonDev." content="Media kit and audience stats for RythonDev."
/> />
<title>RythonDev — Media Kit</title> <meta name="viewport" content="width=device-width" />
<meta
name="keywords"
content="Rython, developer, frontend, web developer, streamer, coworking"
/>
<meta name="theme-color" content="#54E9FA" />
<meta name="twitter:image" content="/images/preview.webp" />
<title>RythonDev's Media Kit</title>
</head> </head>
<body <body
class="bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-50" class="bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-50"

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -1,32 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Collab } from "../lib/types";
import AnimatedTooltip from "./ui/AnimatedTooltip/AnimatedTooltip.svelte"; import AnimatedTooltip from "./ui/AnimatedTooltip/AnimatedTooltip.svelte";
const people = [ let { items = [] }: { items?: Collab[] } = $props();
{
id: 1,
name: "ProtonPass",
image: "/images/collaborations/proton-pass.webp",
url: "https://proton.me/pass",
},
{
id: 2,
name: "ProtonVPN",
image: "/images/collaborations/proton-vpn.webp",
url: "https://protonvpn.com/",
},
{
id: 3,
name: "Brick",
image: "/images/collaborations/brick.webp",
url: "https://getbrick.com/",
},
{
id: 4,
name: "Focumon",
image: "/images/collaborations/focumon.webp",
url: "https://www.focumon.com/landing",
},
];
</script> </script>
<AnimatedTooltip items={people} /> <AnimatedTooltip {items} />

View file

@ -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"; const TOKEN_KEY = "rythondev_admin_token";
@ -84,3 +84,27 @@ export async function updatePlatforms(
return data.platforms; return data.platforms;
} }
async function readError(res: Response, fallback: string): Promise<string> {
const data = await res.json().catch(() => null);
return typeof data?.error === "string" ? data.error : fallback;
}
export async function updateCollabs(collabs: Collab[]): Promise<Collab[]> {
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;
}

View file

@ -5,6 +5,13 @@ export interface Platform {
followers: number; followers: number;
} }
export interface Collab {
id: number;
name: string;
image: string;
url: string;
}
export interface TwitchData { export interface TwitchData {
configured?: boolean; configured?: boolean;
found?: boolean; found?: boolean;
@ -23,10 +30,12 @@ export interface TwitchData {
export interface MediaKit { export interface MediaKit {
content: Record<string, string>; content: Record<string, string>;
platforms: Platform[]; platforms: Platform[];
collabs: Collab[];
twitch: TwitchData | null; twitch: TwitchData | null;
} }
export interface AdminState { export interface AdminState {
content: Record<string, string>; content: Record<string, string>;
platforms: Platform[]; platforms: Platform[];
collabs: Collab[];
} }

View file

@ -4,11 +4,12 @@
clearStoredToken, clearStoredToken,
fetchAdminState, fetchAdminState,
getStoredToken, getStoredToken,
updateCollabs,
updateContent, updateContent,
updatePlatforms, updatePlatforms,
verifyAdmin, verifyAdmin,
} from "../lib/api"; } from "../lib/api";
import type { Platform } from "../lib/types"; import type { Collab, Platform } from "../lib/types";
let authed = $state(false); let authed = $state(false);
let loading = $state(true); let loading = $state(true);
@ -19,6 +20,8 @@
let content = $state<Record<string, string>>({}); let content = $state<Record<string, string>>({});
let platforms = $state<Platform[]>([]); let platforms = $state<Platform[]>([]);
let collabs = $state<Collab[]>([]);
let nextCollabId = -1;
onMount(async () => { onMount(async () => {
if (getStoredToken()) { if (getStoredToken()) {
@ -42,6 +45,7 @@
const state = await fetchAdminState(); const state = await fetchAdminState();
content = state.content; content = state.content;
platforms = state.platforms; platforms = state.platforms;
collabs = state.collabs ?? [];
authed = true; authed = true;
} catch (err) { } catch (err) {
authed = false; authed = false;
@ -72,6 +76,7 @@
authed = false; authed = false;
content = {}; content = {};
platforms = []; platforms = [];
collabs = [];
message = ""; message = "";
error = ""; 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() { async function handleSavePlatforms() {
error = ""; error = "";
message = ""; message = "";
@ -296,6 +334,114 @@
{/each} {/each}
</div> </div>
</section> </section>
<section
class="border-t border-gray-200 pt-8 md:col-span-2 dark:border-zinc-800"
>
<div class="mb-5 flex items-center justify-between gap-4">
<h2 class="text-xl font-semibold tracking-tight">
Collaborations
</h2>
<div class="flex items-center gap-3">
<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={addCollab}
>
Add
</button>
<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={handleSaveCollabs}
>
Save collaborations
</button>
</div>
</div>
{#if collabs.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">
No collaborations yet.
</p>
{:else}
<div class="grid grid-cols-1 gap-5">
{#each collabs as collab, index (collab.id)}
<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 flex items-center justify-between gap-3"
>
<div class="text-sm font-semibold">
{collab.name || "New collaboration"}
</div>
<div class="flex items-center gap-2">
<button
class="cursor-pointer rounded-[10px] border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-neutral-900 disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-800 dark:text-neutral-50"
disabled={index === 0}
onclick={() => moveCollab(index, -1)}
>
Up
</button>
<button
class="cursor-pointer rounded-[10px] border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-neutral-900 disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-800 dark:text-neutral-50"
disabled={index === collabs.length - 1}
onclick={() => moveCollab(index, 1)}
>
Down
</button>
<button
class="cursor-pointer rounded-[10px] border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-red-600 dark:border-zinc-800 dark:text-red-400"
onclick={() => removeCollab(index)}
>
Remove
</button>
</div>
</div>
<div
class="grid grid-cols-1 gap-4 md:grid-cols-3"
>
<label
class="flex flex-col gap-2 text-sm text-gray-500 dark:text-gray-400"
>
<span>Name</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={collab.name}
/>
</label>
<label
class="flex flex-col gap-2 text-sm text-gray-500 dark:text-gray-400"
>
<span>Image URL</span>
<div class="flex items-center gap-3">
<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={collab.image}
/>
{#if collab.image}
<img
class="h-10 w-10 shrink-0 rounded-lg border border-gray-200 object-cover dark:border-zinc-800"
src={collab.image}
alt=""
/>
{/if}
</div>
</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={collab.url}
/>
</label>
</div>
</div>
{/each}
</div>
{/if}
</section>
</div> </div>
{#if message} {#if message}

View file

@ -18,6 +18,7 @@
let content = $derived(data?.content ?? {}); let content = $derived(data?.content ?? {});
let platforms = $derived(data?.platforms ?? []); let platforms = $derived(data?.platforms ?? []);
let collabs = $derived(data?.collabs ?? []);
let services = $derived( let services = $derived(
(content.services ?? "") (content.services ?? "")
.split("\n") .split("\n")
@ -185,7 +186,9 @@
</div> </div>
</div> </div>
<!-- 2nd column --> <!-- 2nd column -->
<div class="order-1 flex min-w-0 flex-col items-start justify-start gap-5 lg:order-2"> <div
class="order-1 flex min-w-0 flex-col items-start justify-start gap-5 lg:order-2"
>
<div <div
class="w-full translate-y-2 rounded-lg border-2 border-cyan-300 sm:w-fit" class="w-full translate-y-2 rounded-lg border-2 border-cyan-300 sm:w-fit"
> >
@ -202,23 +205,29 @@
{/if} {/if}
</div> </div>
<div class="mt-5"> <div class="mt-5">
<div class="mb-2 text-3xl font-bold text-white sm:text-4xl">About Me</div> <div class="mb-2 text-3xl font-bold text-white sm:text-4xl">
<p class="break-words"> About Me
</div>
<p class="wrap-break-word">
{content.about ?? ""} {content.about ?? ""}
</p> </p>
</div> </div>
{#if collabs.length}
<div> <div>
<div class="mb-2 text-3xl font-bold text-white sm:text-4xl"> <div class="mb-2 text-3xl font-bold text-white sm:text-4xl">
Collaborations Collaborations
</div> </div>
<Collabs /> <Collabs items={collabs} />
</div> </div>
{/if}
{#if services.length} {#if services.length}
<section class="w-full"> <section class="w-full">
<div class="mb-3 text-3xl font-bold text-white sm:text-4xl"> <div class="mb-3 text-3xl font-bold text-white sm:text-4xl">
Services Services
</div> </div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-x-8"> <div
class="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-x-8"
>
<ul <ul
class="list-disc space-y-2 pl-5 marker:text-cyan-300" class="list-disc space-y-2 pl-5 marker:text-cyan-300"
> >

View file

@ -1,147 +0,0 @@
<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-230 px-6 pt-12 pb-18">
<div
class="mt-6 rounded-[10px] bg-red-600/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
>
{error}
</div>
</main>
{:else if !data}
<main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18">
<div class="py-8 text-gray-500 dark:text-gray-400">Loading…</div>
</main>
{:else}
<main class="grid grid-cols-2">
<div
class="flex flex-col items-start gap-6 pt-6 pb-2 sm:flex-row sm:items-center"
>
{#if content.profile_image_url}
<img
class="h-24 w-24 shrink-0 rounded-md object-cover"
src={content.profile_image_url}
alt={content.brand_name ?? "Profile image"}
/>
{/if}
<div>
<h1
class="text-[clamp(36px,8vw,52px)] leading-[1.05] font-semibold tracking-tight"
>
{content.brand_name ?? "RythonDev"}
</h1>
{#if content.tagline}
<p class="mt-4 text-xl text-gray-500 dark:text-gray-400">
{content.tagline}
</p>
{/if}
</div>
</div>
<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-160 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>
{: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>
</main>
{/if}