updated services

This commit is contained in:
liyunze 2026-11-05 22:52:40 +08:00
parent e51f83aa8c
commit 0f5155811d
5 changed files with 743 additions and 636 deletions

View file

@ -1,106 +1,106 @@
<script lang="ts"> <script lang="ts">
import { import {
Motion, Motion,
useTransform, useTransform,
AnimatePresence, AnimatePresence,
useMotionValue, useMotionValue,
useSpring, useSpring,
} from "svelte-motion"; } from "svelte-motion";
export let items: { export let items: {
id: number; id: number;
name: string; name: string;
image: string; image: string;
url: string; url: string;
}[]; }[];
let hoveredIndex: number | null = null; let hoveredIndex: number | null = null;
const springConfig = { stiffness: 100, damping: 5 }; const springConfig = { stiffness: 100, damping: 5 };
const x = useMotionValue(0); // going to set this value on mouse move const x = useMotionValue(0); // going to set this value on mouse move
// rotate the tooltip // rotate the tooltip
const rotate = useSpring( const rotate = useSpring(
useTransform(x, [-100, 100], [-45, 45]), useTransform(x, [-100, 100], [-45, 45]),
springConfig, springConfig,
); );
// translate the tooltip // translate the tooltip
const translateX = useSpring( const translateX = useSpring(
useTransform(x, [-100, 100], [-50, 50]), useTransform(x, [-100, 100], [-50, 50]),
springConfig, springConfig,
); );
const handleMouseMove = (event: MouseEvent) => { const handleMouseMove = (event: MouseEvent) => {
// @ts-ignore // @ts-ignore
const halfWidth = event.target?.offsetWidth / 2; const halfWidth = event.target?.offsetWidth / 2;
x.set(event.offsetX - halfWidth); // set the x value, which is then used in transform and rotate x.set(event.offsetX - halfWidth); // set the x value, which is then used in transform and rotate
}; };
</script> </script>
<div class="group flex flex-row pl-2"> <div class="group flex flex-row pl-2">
{#each items as item, idx (item.name)} {#each items as item, idx (item.name)}
<div class="relative mr-5"> <div class="relative mr-5">
<AnimatePresence show={true}> <AnimatePresence show={true}>
{#if hoveredIndex === item.id} {#if hoveredIndex === item.id}
<Motion <Motion
let:motion let:motion
initial={{ opacity: 0, y: 20, scale: 0.6 }} initial={{ opacity: 0, y: 20, scale: 0.6 }}
animate={{ animate={{
opacity: 1, opacity: 1,
y: 0, y: 0,
scale: 1, scale: 1,
transition: { transition: {
type: "spring", type: "spring",
stiffness: 260, stiffness: 260,
damping: 10, damping: 10,
}, },
}} }}
exit={{ opacity: 0, y: 20, scale: 0.6 }} exit={{ opacity: 0, y: 20, scale: 0.6 }}
style={{ style={{
translateX: translateX, translateX: translateX,
rotate: rotate, rotate: rotate,
whiteSpace: "nowrap", whiteSpace: "nowrap",
}} }}
> >
<div <div
use:motion use:motion
id="tooltip-{item.id}" id="tooltip-{item.id}"
role="tooltip" role="tooltip"
class="absolute left-1/2 -top-10 z-50 flex -translate-x-1/2 flex-col items-center justify-center rounded-md bg-gray-700 px-4 py-2 text-xs shadow-xl" class="absolute left-1/2 -top-10 z-50 flex -translate-x-1/2 flex-col items-center justify-center rounded-md bg-gray-700 px-4 py-2 text-xs shadow-xl"
> >
<div <div
class="absolute inset-x-10 -bottom-px z-30 h-px w-[20%] bg-linear-to-r from-transparent via-emerald-500 to-transparent" class="absolute inset-x-10 -bottom-px z-30 h-px w-[20%] bg-linear-to-r from-transparent via-emerald-500 to-transparent"
></div> ></div>
<div <div
class="absolute -bottom-px left-10 z-30 h-px w-[40%] bg-linear-to-r from-transparent via-sky-500 to-transparent" class="absolute -bottom-px left-10 z-30 h-px w-[40%] bg-linear-to-r from-transparent via-sky-500 to-transparent"
></div> ></div>
<div <div
class="relative z-30 whitespace-nowrap text-base font-bold text-white" class="relative z-30 whitespace-nowrap text-base font-bold text-white"
> >
{item.name} {item.name}
</div> </div>
</div> </div>
</Motion> </Motion>
{/if} {/if}
</AnimatePresence> </AnimatePresence>
<a <a
href={item.url} href={item.url}
target="_blank" target="_blank"
class="relative m-0 h-14 w-14 cursor-pointer rounded-full border-0 bg-transparent p-0 transition duration-500 group-hover:z-30 group-hover:scale-105" class="relative m-0 h-14 w-14 cursor-pointer rounded-full border-0 bg-transparent p-0 transition duration-500 group-hover:z-30 group-hover:scale-105"
on:mousemove={handleMouseMove} on:mousemove={handleMouseMove}
on:mouseenter={() => (hoveredIndex = item.id)} on:mouseenter={() => (hoveredIndex = item.id)}
on:mouseleave={() => (hoveredIndex = null)} on:mouseleave={() => (hoveredIndex = null)}
on:focus={() => (hoveredIndex = item.id)} on:focus={() => (hoveredIndex = item.id)}
on:blur={() => (hoveredIndex = null)} on:blur={() => (hoveredIndex = null)}
aria-describedby={hoveredIndex === item.id aria-describedby={hoveredIndex === item.id
? `tooltip-${item.id}` ? `tooltip-${item.id}`
: undefined} : undefined}
> >
<img <img
height={100} height={100}
width={100} width={100}
src={item.image} src={item.image}
alt={item.name} alt={item.name}
class="m-0! h-10 w-10 rounded-lg object-cover object-top p-0!" class="m-0! h-10 w-10 rounded-lg object-cover object-top p-0!"
/> />
</a> </a>
</div> </div>
{/each} {/each}
</div> </div>

View file

@ -83,3 +83,4 @@ export async function updatePlatforms(
const data = await res.json(); const data = await res.json();
return data.platforms; return data.platforms;
} }

View file

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

View file

@ -1,196 +1,231 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { fetchMediaKit } from "../lib/api"; import { fetchMediaKit } from "../lib/api";
import type { MediaKit } from "../lib/types"; import type { MediaKit } from "../lib/types";
import Card from "../components/Card.svelte"; import Card from "../components/Card.svelte";
import Collabs from "../components/Collabs.svelte"; import Collabs from "../components/Collabs.svelte";
let data = $state<MediaKit | null>(null); let data = $state<MediaKit | null>(null);
let error = $state(""); let error = $state("");
onMount(async () => { onMount(async () => {
try { try {
data = await fetchMediaKit(); data = await fetchMediaKit();
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : "Something went wrong"; error = err instanceof Error ? err.message : "Something went wrong";
} }
}); });
let content = $derived(data?.content ?? {}); let content = $derived(data?.content ?? {});
let platforms = $derived(data?.platforms ?? []); let platforms = $derived(data?.platforms ?? []);
let twitch = $derived(data?.twitch); let services = $derived(
// let twitchLogin = $derived(twitch?.login ?? ""); (content.services ?? "")
.split("\n")
.map((service) => service.trim())
.filter(Boolean)
.slice(0, 6),
);
let twitch = $derived(data?.twitch);
// let twitchLogin = $derived(twitch?.login ?? "");
type platformDecoType = { type platformDecoType = {
image: string; image: string;
color?: string | null; color?: string | null;
}; };
let platformImageMap: Record<string, platformDecoType> = { let platformImageMap: Record<string, platformDecoType> = {
twitch: { twitch: {
image: "/images/logos/twitch.svg", image: "/images/logos/twitch.svg",
color: "#8E60F5", color: "#8E60F5",
}, },
youtube: { youtube: {
image: "/images/logos/youtube.svg", image: "/images/logos/youtube.svg",
color: "#FF0000", color: "#FF0000",
}, },
instagram: { instagram: {
image: "/images/logos/instagram.svg", image: "/images/logos/instagram.svg",
color: "#E1306C", color: "#E1306C",
}, },
tiktok: { tiktok: {
image: "/images/logos/tiktok.svg", image: "/images/logos/tiktok.svg",
color: "#FFFFFF", color: "#FFFFFF",
}, },
}; };
function formatCount(value: number | undefined | null): string { function formatCount(value: number | undefined | null): string {
if (value == null) return "—"; if (value == null) return "—";
if (value >= 1_000_000) { if (value >= 1_000_000) {
const millions = value / 1_000_000; const millions = value / 1_000_000;
return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1).replace(/\.0$/, "")}M`; return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1).replace(/\.0$/, "")}M`;
} }
if (value >= 1_000) { if (value >= 1_000) {
const thousands = value / 1_000; const thousands = value / 1_000;
return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1).replace(/\.0$/, "")}K`; return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1).replace(/\.0$/, "")}K`;
} }
return value.toLocaleString(); return value.toLocaleString();
} }
</script> </script>
{#if error} {#if error}
<main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18"> <main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18">
<div <div
class="mt-6 rounded-[10px] bg-red-600/10 px-4 py-3 text-sm text-red-600 dark:text-red-400" class="mt-6 rounded-[10px] bg-red-600/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
> >
{error} {error}
</div> </div>
</main> </main>
{:else if !data} {:else if !data}
<main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18"> <main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18">
<div class="py-8 text-gray-500 dark:text-gray-400">Loading…</div> <div class="py-8 text-gray-500 dark:text-gray-400">Loading…</div>
</main> </main>
{:else} {:else}
<main class="grid grid-cols-2 max-w-6xl mx-auto pt-20 pb-10 gap-10"> <main class="grid grid-cols-2 max-w-6xl mx-auto pt-20 pb-10 gap-10">
<div class=""> <div class="">
<div class="px-5"> <div class="px-5">
<div class="flex justify-end items-start"> <div class="flex justify-end items-start">
{#if content.profile_image_url} {#if content.profile_image_url}
<img <img
class="shrink-0 rounded-xl object-cover shadow-cyan-300 shadow-lg" class="shrink-0 rounded-xl object-cover shadow-cyan-300 shadow-lg"
src={content.profile_image_url} src={content.profile_image_url}
alt={content.brand_name ?? "Profile image"} alt={content.brand_name ?? "Profile image"}
width="300" width="300"
/> />
{/if} {/if}
</div> </div>
<div class="mt-6 grid grid-cols-2 gap-4"> <div class="mt-6 grid grid-cols-2 gap-4">
<!-- Twitch --> <!-- Twitch -->
<Card <Card
className="flex flex-col gap-1 rounded-2xl border p-5 transition-colors border-zinc-800 bg-neutral-900" className="flex flex-col gap-1 rounded-2xl border p-5 transition-colors border-zinc-800 bg-neutral-900"
href={`https://twitch.tv/${twitch?.login}`} href={`https://twitch.tv/${twitch?.login}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
color={platformImageMap["twitch"].color} color={platformImageMap["twitch"].color}
> >
<div <div
class="flex flex-row justify-start items-start gap-3" class="flex flex-row justify-start items-start gap-3"
> >
<img <img
src={platformImageMap["twitch"].image} src={platformImageMap["twitch"].image}
alt="Twitch" alt="Twitch"
width="40" width="40"
/> />
<div> <div>
<span <span
class="text-sm font-semibold tracking-[0.08em] uppercase text-white" class="text-sm font-semibold tracking-[0.08em] uppercase text-white"
> >
Twitch Twitch
</span> </span>
<br /> <br />
<span class="text-base" <span class="text-base"
>{twitch?.display_name}</span >{twitch?.display_name}</span
> >
<div <div
class="mt-2 text-2xl font-semibold tracking-tight" class="mt-2 text-2xl font-semibold tracking-tight"
> >
{formatCount(twitch?.followers)} {formatCount(twitch?.followers)}
<span class="text-sm">followers</span> <span class="text-sm">followers</span>
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
{#each platforms as platform (platform.name)} {#each platforms as platform (platform.name)}
<Card <Card
className="flex flex-col gap-1 rounded-2xl border p-5 transition-colors border-zinc-800 bg-neutral-900" className="flex flex-col gap-1 rounded-2xl border p-5 transition-colors border-zinc-800 bg-neutral-900"
href={platform.url} href={platform.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
color={platformImageMap[platform.name]?.color} color={platformImageMap[platform.name]?.color}
> >
<div <div
class="flex flex-row justify-start items-start gap-3" class="flex flex-row justify-start items-start gap-3"
> >
<img <img
src={platformImageMap[platform.name].image} src={platformImageMap[platform.name].image}
alt="Twitch" alt="Twitch"
width="48" width="48"
/> />
<div> <div>
<span <span
class="text-sm font-semibold tracking-[0.08em] uppercase text-white" class="text-sm font-semibold tracking-[0.08em] uppercase text-white"
> >
{platform.name} {platform.name}
</span> </span>
<br /> <br />
<span class="text-base" <span class="text-base"
>{platform.handle}</span >{platform.handle}</span
> >
<div <div
class="mt-2 text-2xl font-semibold tracking-tight" class="mt-2 text-2xl font-semibold tracking-tight"
> >
{formatCount(platform.followers)} {formatCount(platform.followers)}
<span class="text-sm"> <span class="text-sm">
{#if platform.name != "youtube"} {#if platform.name != "youtube"}
followers followers
{:else} {:else}
subscribers subscribers
{/if} {/if}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
{/each} {/each}
</div> </div>
</div> </div>
</div> </div>
<!-- 2nd column --> <!-- 2nd column -->
<div class="flex flex-col justify-start items-start gap-5"> <div class="flex flex-col justify-start items-start gap-5">
<div <div
class="border-cyan-300 border-2 rounded-lg translate-y-2 w-fit" class="border-cyan-300 border-2 rounded-lg translate-y-2 w-fit"
> >
<div <div
class="-translate-x-20 px-5 py-5 text-4xl bg-cyan-300 text-black rounded-xl -translate-y-2" class="-translate-x-20 px-5 py-5 text-4xl bg-cyan-300 text-black rounded-xl -translate-y-2"
> >
Hi there, I'm <br /> Hi there, I'm <br />
<b>RythonDev</b> <b>RythonDev</b>
</div> </div>
</div> {#if content.tagline}
<div class="mt-5"> <p class="text-xl px-5 text-cyan-300 pb-2">
<div class="text-white font-bold text-4xl mb-2">About Me</div> {content.tagline}
<p> </p>
{content.about ?? ""} {/if}
</p> </div>
</div> <div class="mt-5">
<div> <div class="text-white font-bold text-4xl mb-2">About Me</div>
<div class="text-white font-bold text-4xl mb-2"> <p>
Collaborations {content.about ?? ""}
</div> </p>
<Collabs /> </div>
</div> <div>
</div> <div class="text-white font-bold text-4xl mb-2">
</main> Collaborations
</div>
<Collabs />
</div>
{#if services.length}
<section class="w-full">
<div class="mb-3 text-4xl font-bold text-white">
Services
</div>
<div class="grid grid-cols-2 gap-x-8">
<ul
class="list-disc space-y-2 pl-5 marker:text-cyan-300"
>
{#each services.slice(0, 3) as service}
<li>{service}</li>
{/each}
</ul>
<ul
class="list-disc space-y-2 pl-5 marker:text-cyan-300"
>
{#each services.slice(3, 6) as service}
<li>{service}</li>
{/each}
</ul>
</div>
</section>
{/if}
</div>
</main>
{/if} {/if}
<style> <style>

View file

@ -1,117 +1,147 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from "svelte";
import { fetchMediaKit } from '../lib/api' import { fetchMediaKit } from "../lib/api";
import type { MediaKit } from '../lib/types' import type { MediaKit } from "../lib/types";
let data = $state<MediaKit | null>(null) let data = $state<MediaKit | null>(null);
let error = $state('') let error = $state("");
onMount(async () => { onMount(async () => {
try { try {
data = await fetchMediaKit() data = await fetchMediaKit();
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : 'Something went wrong' error = err instanceof Error ? err.message : "Something went wrong";
} }
}) });
const content = $derived(data?.content ?? {}) const content = $derived(data?.content ?? {});
const platforms = $derived(data?.platforms ?? []) const platforms = $derived(data?.platforms ?? []);
const twitch = $derived(data?.twitch ?? null) const twitch = $derived(data?.twitch ?? null);
const twitchLogin = $derived(twitch?.login ?? '') const twitchLogin = $derived(twitch?.login ?? "");
function formatCount(value: number | undefined | null): string { function formatCount(value: number | undefined | null): string {
if (value == null) return '—' if (value == null) return "—";
if (value >= 1_000_000) { if (value >= 1_000_000) {
const millions = value / 1_000_000 const millions = value / 1_000_000;
return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1).replace(/\.0$/, '')}M` return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1).replace(/\.0$/, "")}M`;
} }
if (value >= 1_000) { if (value >= 1_000) {
const thousands = value / 1_000 const thousands = value / 1_000;
return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1).replace(/\.0$/, '')}K` return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1).replace(/\.0$/, "")}K`;
} }
return value.toLocaleString() return value.toLocaleString();
} }
</script> </script>
{#if error} {#if error}
<main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18"> <main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18">
<div class="mt-6 rounded-[10px] bg-red-600/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"> <div
{error} class="mt-6 rounded-[10px] bg-red-600/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
</div> >
</main> {error}
</div>
</main>
{:else if !data} {:else if !data}
<main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18"> <main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18">
<div class="py-8 text-gray-500 dark:text-gray-400">Loading…</div> <div class="py-8 text-gray-500 dark:text-gray-400">Loading…</div>
</main> </main>
{:else} {:else}
<main class="grid grid-cols-2"> <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"> <div
{#if content.profile_image_url} class="flex flex-col items-start gap-6 pt-6 pb-2 sm:flex-row sm:items-center"
<img >
class="h-24 w-24 shrink-0 rounded-md object-cover" {#if content.profile_image_url}
src={content.profile_image_url} <img
alt={content.brand_name ?? 'Profile image'} class="h-24 w-24 shrink-0 rounded-md object-cover"
/> src={content.profile_image_url}
{/if} alt={content.brand_name ?? "Profile image"}
<div> />
<h1 class="text-[clamp(36px,8vw,52px)] leading-[1.05] font-semibold tracking-tight"> {/if}
{content.brand_name ?? 'RythonDev'} <div>
</h1> <h1
{#if content.tagline} class="text-[clamp(36px,8vw,52px)] leading-[1.05] font-semibold tracking-tight"
<p class="mt-4 text-xl text-gray-500 dark:text-gray-400">{content.tagline}</p> >
{/if} {content.brand_name ?? "RythonDev"}
</div> </h1>
</div> {#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"> <section
<h2 class="text-xl font-semibold tracking-tight">About</h2> class="mt-14 border-t border-gray-200 pt-8 dark:border-zinc-800"
<p class="mt-4 max-w-160 text-lg leading-relaxed text-gray-500 dark:text-gray-400"> >
{content.about ?? ''} <h2 class="text-xl font-semibold tracking-tight">About</h2>
</p> <p
{#if content.email} class="mt-4 max-w-160 text-lg leading-relaxed text-gray-500 dark:text-gray-400"
<a >
class="mt-4 inline-block text-violet-600 dark:text-violet-400" {content.about ?? ""}
href="mailto:{content.email}">{content.email}</a </p>
> {#if content.email}
{/if} <a
</section> 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"> <section
<h2 class="text-xl font-semibold tracking-tight">Twitch</h2> class="mt-14 border-t border-gray-200 pt-8 dark:border-zinc-800"
{#if twitch?.configured && twitch?.found} >
<div class="mt-6 grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-4"> <h2 class="text-xl font-semibold tracking-tight">Twitch</h2>
<div {#if twitch?.configured && twitch?.found}
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" <div
> class="mt-6 grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-4"
<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"
</div> >
{:else} <span class="text-sm text-gray-500 dark:text-gray-400"
<p class="text-gray-500 dark:text-gray-400">{twitch?.message ?? 'Twitch stats are unavailable.'}</p> >Followers</span
{/if} >
</section> <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"> <section
<h2 class="text-xl font-semibold tracking-tight">Platforms</h2> class="mt-14 border-t border-gray-200 pt-8 dark:border-zinc-800"
<div class="mt-6 grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4"> >
{#each platforms as platform (platform.name)} <h2 class="text-xl font-semibold tracking-tight">Platforms</h2>
<a <div
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" class="mt-6 grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4"
href={platform.url} >
target="_blank" {#each platforms as platform (platform.name)}
rel="noreferrer" <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"
<span class="text-sm font-semibold tracking-[0.08em] uppercase text-gray-500 dark:text-gray-400"> href={platform.url}
{platform.name} target="_blank"
</span> rel="noreferrer"
<span class="text-base">{platform.handle}</span> >
<span class="mt-2 text-2xl font-semibold tracking-tight"> <span
{formatCount(platform.followers)} followers class="text-sm font-semibold tracking-[0.08em] uppercase text-gray-500 dark:text-gray-400"
</span> >
</a> {platform.name}
{/each} </span>
</div> <span class="text-base">{platform.handle}</span>
</section> <span
</main> class="mt-2 text-2xl font-semibold tracking-tight"
>
{formatCount(platform.followers)} followers
</span>
</a>
{/each}
</div>
</section>
</main>
{/if} {/if}