203 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|