Files
fristydev/components/discord-card.tsx
T
fristy ad943fff8c
Build & Push / Lint & Build (push) Successful in 1m15s
Build & Push / Build & Push Image (push) Successful in 52s
Initial commit: fristy.net personal site
2026-09-10 04:59:12 +03:00

203 lines
7.6 KiB
TypeScript

"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import type { Dictionary } from "@/lib/i18n/get-dictionary";
import {
activityImageUrl,
discordAvatarUrl,
lanyardUrl,
type DiscordStatus,
type LanyardActivity,
type LanyardData,
} from "@/lib/lanyard";
const STATUS_DOT: Record<DiscordStatus, string> = {
online: "bg-emerald-400",
idle: "bg-amber-400",
dnd: "bg-rose-500",
offline: "bg-zinc-500",
};
const POLL_MS = 15_000;
function formatElapsed(start: number): string {
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m`;
}
return "<1m";
}
function ActivityRow({ activity }: { activity: LanyardActivity }) {
const large = activityImageUrl(activity, false);
const small = activityImageUrl(activity, true);
return (
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3">
{large ? (
<div className="relative shrink-0">
<Image
src={large}
alt={activity.assets?.large_text ?? activity.name}
width={56}
height={56}
className="rounded-xl"
/>
{small ? (
<Image
src={small}
alt={activity.assets?.small_text ?? ""}
width={24}
height={24}
className="absolute -bottom-1.5 -right-1.5 rounded-full border-2 border-panel"
/>
) : null}
</div>
) : null}
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{activity.name}</p>
{activity.details ? (
<p className="truncate text-sm text-zinc-300">{activity.details}</p>
) : null}
{activity.state ? (
<p className="truncate text-sm text-zinc-500">{activity.state}</p>
) : null}
{activity.timestamps?.start ? (
<p className="mt-0.5 font-mono text-xs text-zinc-500">
{formatElapsed(activity.timestamps.start)}
</p>
) : null}
</div>
</div>
);
}
export function DiscordCard({
initial,
dict,
}: {
initial: LanyardData | null;
dict: Dictionary["discord"];
}) {
const [data, setData] = useState<LanyardData | null>(initial);
useEffect(() => {
async function poll(): Promise<void> {
const response = await fetch(lanyardUrl(), { cache: "no-store" });
if (!response.ok) {
return;
}
const json = (await response.json()) as { success: boolean; data: LanyardData };
if (json.success) {
setData(json.data);
}
}
function tick(): void {
void poll().catch(() => undefined);
}
const timer = window.setInterval(tick, POLL_MS);
return () => window.clearInterval(timer);
}, []);
if (!data) {
return (
<div className="rounded-2xl border border-white/10 bg-panel p-6 text-zinc-400">
{dict.offline}
</div>
);
}
const user = data.discord_user;
const displayName = user.display_name ?? user.global_name ?? user.username;
const customStatus = data.activities.find((a) => a.type === 4);
const games = data.activities.filter((a) => a.type === 0);
return (
<div className="rounded-2xl border border-white/10 bg-panel p-6">
<div className="flex items-center gap-4">
<div className="relative shrink-0">
<Image
src={discordAvatarUrl(user.id, user.avatar, 128)}
alt={displayName}
width={72}
height={72}
className="rounded-2xl"
priority
/>
<span
title={dict[data.discord_status]}
className={`absolute -bottom-1 -right-1 h-5 w-5 rounded-full border-4 border-panel ${STATUS_DOT[data.discord_status]}`}
/>
</div>
<div className="min-w-0">
<p className="truncate text-xl font-bold">{displayName}</p>
<p className="font-mono text-sm text-zinc-500">@{user.username}</p>
<p className="mt-1 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-2.5 py-0.5 text-xs text-zinc-300">
<span
className={`h-2 w-2 rounded-full ${STATUS_DOT[data.discord_status]}`}
/>
{dict[data.discord_status]}
<span className="text-zinc-600">·</span>
<span className="text-mint">{dict.live}</span>
</p>
</div>
</div>
{customStatus?.state ? (
<p className="mt-4 rounded-xl border border-white/5 bg-white/[0.03] p-3 text-sm text-zinc-300">
<span className="mr-2 font-mono text-xs text-zinc-500">
{dict.customStatus}
</span>
{customStatus.emoji?.name ? (
<span className="mr-1">{customStatus.emoji.name}</span>
) : null}
{customStatus.state}
</p>
) : null}
{games.length > 0 ? (
<div className="mt-4 space-y-2">
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
{dict.playing}
</p>
{games.map((game) => (
<ActivityRow key={game.id} activity={game} />
))}
</div>
) : null}
{data.listening_to_spotify && data.spotify ? (
<div className="mt-4 flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3">
{data.spotify.album_art_url ? (
<Image
src={data.spotify.album_art_url}
alt={data.spotify.album}
width={48}
height={48}
className="rounded-lg"
/>
) : null}
<div className="min-w-0">
<p className="font-mono text-xs uppercase tracking-wider text-mint">
{dict.listening} Spotify
</p>
<p className="truncate text-sm font-semibold">{data.spotify.song}</p>
<p className="truncate text-sm text-zinc-400">
{data.spotify.artist} — {data.spotify.album}
</p>
</div>
</div>
) : null}
{games.length === 0 && !data.listening_to_spotify && !customStatus?.state ? (
<p className="mt-4 text-sm text-zinc-500">{dict.noActivity}</p>
) : null}
</div>
);
}