- Spotify: drop album line, normalize ";" separators to ",", add open-in-Spotify link and live progress bar - Games: resolve missing artwork via public Discord RPC app icons with placeholder fallback; add in-site game profile modal (IGDB + Steam + ITAD: artwork, details, reviews, prices) - Proxy game data through same-origin /api/game and /api/discord-app routes, CORS-locked to https://fristy.net - Lanyard: SSR REST first paint, then live WebSocket with heartbeat, reconnect backoff and polling fallback - Localize presence UI (tr/en/de): elapsed times, dates, numbers, currency, review labels, game descriptions - Sort projects by last update, descending - Slim dark scrollbars, compact Spotify progress bar
502 lines
18 KiB
TypeScript
502 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import { useEffect, useState } from "react";
|
|
import { GameProfileModal } from "@/components/game-profile-modal";
|
|
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
|
import {
|
|
activityImageUrl,
|
|
DISCORD_ID,
|
|
discordAvatarUrl,
|
|
discordGameUrl,
|
|
formatSpotifyList,
|
|
LANYARD_WS,
|
|
lanyardUrl,
|
|
spotifyTrackUrl,
|
|
type DiscordAppInfo,
|
|
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 WS_OPEN_TIMEOUT_MS = 10_000;
|
|
const RECONNECT_BASE_MS = 2_000;
|
|
const RECONNECT_MAX_MS = 30_000;
|
|
const SAFETY_POLL_MS = 60_000;
|
|
|
|
function formatTrackTime(ms: number): string {
|
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
function SpotifyProgress({ start, end }: { start: number; end: number }) {
|
|
const [now, setNow] = useState(start);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
|
return () => window.clearInterval(timer);
|
|
}, []);
|
|
|
|
const total = Math.max(1, end - start);
|
|
const ratio = Math.min(1, Math.max(0, (now - start) / total));
|
|
const percent = Math.round(ratio * 100);
|
|
return (
|
|
<div className="mt-1.5 flex max-w-60 items-center gap-1.5 font-mono text-[10px] text-zinc-500">
|
|
<span>{formatTrackTime(now - start)}</span>
|
|
<div
|
|
className="h-[3px] flex-1 overflow-hidden rounded-full bg-white/10"
|
|
role="progressbar"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={percent}
|
|
>
|
|
<div className="h-full rounded-full bg-zinc-300" style={{ width: `${percent}%` }} />
|
|
</div>
|
|
<span>{formatTrackTime(total)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatElapsed(start: number, lang: string): 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 (lang === "tr") {
|
|
if (hours > 0) {
|
|
return `${hours}s ${minutes}dk`;
|
|
}
|
|
if (minutes > 0) {
|
|
return `${minutes}dk`;
|
|
}
|
|
return "<1dk";
|
|
}
|
|
if (lang === "de") {
|
|
if (hours > 0) {
|
|
return `${hours} Std. ${minutes} Min.`;
|
|
}
|
|
if (minutes > 0) {
|
|
return `${minutes} Min.`;
|
|
}
|
|
return "<1 Min.";
|
|
}
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes}m`;
|
|
}
|
|
if (minutes > 0) {
|
|
return `${minutes}m`;
|
|
}
|
|
return "<1m";
|
|
}
|
|
|
|
const discordAppCache = new Map<string, DiscordAppInfo | null>();
|
|
|
|
function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo | null {
|
|
const [info, setInfo] = useState<DiscordAppInfo | null>(() =>
|
|
applicationId ? (discordAppCache.get(applicationId) ?? null) : null,
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!applicationId || discordAppCache.has(applicationId)) {
|
|
return;
|
|
}
|
|
const id = applicationId;
|
|
let cancelled = false;
|
|
async function load(): Promise<void> {
|
|
try {
|
|
const response = await fetch(`/api/discord-app/${id}`);
|
|
const json = (await response.json()) as DiscordAppInfo | { error: string };
|
|
const resolved = response.ok && "iconUrl" in json ? (json as DiscordAppInfo) : null;
|
|
discordAppCache.set(id, resolved);
|
|
if (!cancelled) {
|
|
setInfo(resolved);
|
|
}
|
|
} catch {
|
|
discordAppCache.set(id, null);
|
|
}
|
|
}
|
|
void load();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [applicationId]);
|
|
|
|
return info;
|
|
}
|
|
|
|
function ActivityRow({
|
|
activity,
|
|
dict,
|
|
locale,
|
|
}: {
|
|
activity: LanyardActivity;
|
|
dict: Dictionary["discord"];
|
|
locale: string;
|
|
}) {
|
|
const appInfo = useDiscordApp(activity.application_id);
|
|
const [profileOpen, setProfileOpen] = useState(false);
|
|
const [heroSeed, setHeroSeed] = useState(0);
|
|
const large = activityImageUrl(activity, false) ?? appInfo?.iconUrl ?? null;
|
|
const small = activityImageUrl(activity, true);
|
|
const gameUrl = activity.application_id
|
|
? (appInfo?.gameUrl ?? discordGameUrl(activity.application_id))
|
|
: null;
|
|
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>
|
|
) : (
|
|
<div
|
|
aria-hidden
|
|
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-blurple/20 text-xl font-bold text-blurple-soft"
|
|
>
|
|
{activity.name.charAt(0).toUpperCase()}
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<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}
|
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5">
|
|
{activity.timestamps?.start ? (
|
|
<span className="font-mono text-xs text-zinc-500" suppressHydrationWarning>
|
|
{formatElapsed(activity.timestamps.start, locale)}
|
|
</span>
|
|
) : null}
|
|
{gameUrl ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setHeroSeed(Date.now());
|
|
setProfileOpen(true);
|
|
}}
|
|
className="cursor-pointer text-xs font-medium text-blurple-soft transition-colors hover:text-white hover:underline hover:underline-offset-4"
|
|
>
|
|
{dict.gameProfile} ↗
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{profileOpen && activity.application_id ? (
|
|
<GameProfileModal
|
|
applicationId={activity.application_id}
|
|
heroSeed={heroSeed}
|
|
lang={locale}
|
|
iconUrl={appInfo?.iconUrl ?? large}
|
|
fallbackName={activity.name}
|
|
fallbackGameUrl={gameUrl ?? ""}
|
|
dict={dict.gameModal}
|
|
onClose={() => setProfileOpen(false)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DiscordCard({
|
|
initial,
|
|
dict,
|
|
locale,
|
|
}: {
|
|
initial: LanyardData | null;
|
|
dict: Dictionary["discord"];
|
|
locale: string;
|
|
}) {
|
|
const [data, setData] = useState<LanyardData | null>(initial);
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
let socket: WebSocket | null = null;
|
|
let heartbeatTimer: number | undefined;
|
|
let reconnectTimer: number | undefined;
|
|
let reconnectDelay = RECONNECT_BASE_MS;
|
|
|
|
async function pollOnce(): Promise<void> {
|
|
try {
|
|
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 && !disposed) {
|
|
setData(json.data);
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
|
|
function scheduleReconnect(): void {
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
window.clearTimeout(reconnectTimer);
|
|
reconnectTimer = window.setTimeout(() => {
|
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
connect();
|
|
}, reconnectDelay);
|
|
}
|
|
|
|
function dropSocket(current: WebSocket): void {
|
|
if (socket === current) {
|
|
socket = null;
|
|
}
|
|
try {
|
|
current.close();
|
|
} catch {
|
|
socket = null;
|
|
}
|
|
}
|
|
|
|
function connect(): void {
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
let current: WebSocket;
|
|
try {
|
|
current = new WebSocket(LANYARD_WS);
|
|
} catch {
|
|
scheduleReconnect();
|
|
return;
|
|
}
|
|
socket = current;
|
|
let settled = false;
|
|
|
|
const openTimeout = window.setTimeout(() => {
|
|
if (!settled) {
|
|
settled = true;
|
|
dropSocket(current);
|
|
if (!disposed) {
|
|
void pollOnce();
|
|
scheduleReconnect();
|
|
}
|
|
}
|
|
}, WS_OPEN_TIMEOUT_MS);
|
|
|
|
const handleDrop = (): void => {
|
|
if (settled && socket !== current) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
window.clearTimeout(openTimeout);
|
|
window.clearInterval(heartbeatTimer);
|
|
dropSocket(current);
|
|
if (!disposed) {
|
|
void pollOnce();
|
|
scheduleReconnect();
|
|
}
|
|
};
|
|
|
|
current.onmessage = (event: MessageEvent) => {
|
|
let message: { op: number; t?: string; d?: unknown };
|
|
try {
|
|
message = JSON.parse(event.data as string) as {
|
|
op: number;
|
|
t?: string;
|
|
d?: unknown;
|
|
};
|
|
} catch {
|
|
return;
|
|
}
|
|
if (message.op === 1) {
|
|
settled = true;
|
|
window.clearTimeout(openTimeout);
|
|
reconnectDelay = RECONNECT_BASE_MS;
|
|
const interval =
|
|
(message.d as { heartbeat_interval?: number } | undefined)
|
|
?.heartbeat_interval ?? 30_000;
|
|
window.clearInterval(heartbeatTimer);
|
|
heartbeatTimer = window.setInterval(() => {
|
|
try {
|
|
current.send(JSON.stringify({ op: 3 }));
|
|
} catch {
|
|
handleDrop();
|
|
}
|
|
}, interval);
|
|
current.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_ID } }));
|
|
} else if (
|
|
message.op === 0 &&
|
|
(message.t === "INIT_STATE" || message.t === "PRESENCE_UPDATE")
|
|
) {
|
|
if (!disposed) {
|
|
setData(message.d as LanyardData);
|
|
}
|
|
}
|
|
};
|
|
current.onerror = handleDrop;
|
|
current.onclose = handleDrop;
|
|
}
|
|
|
|
connect();
|
|
const safetyTimer = window.setInterval(() => {
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
void pollOnce();
|
|
}
|
|
}, SAFETY_POLL_MS);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
window.clearTimeout(reconnectTimer);
|
|
window.clearInterval(safetyTimer);
|
|
window.clearInterval(heartbeatTimer);
|
|
if (socket) {
|
|
const current = socket;
|
|
socket = null;
|
|
current.onclose = null;
|
|
current.onerror = null;
|
|
current.onmessage = null;
|
|
try {
|
|
current.close();
|
|
} catch {
|
|
socket = null;
|
|
}
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
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} dict={dict} locale={locale} />
|
|
))}
|
|
</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={formatSpotifyList(data.spotify.song)}
|
|
width={48}
|
|
height={48}
|
|
className="rounded-lg"
|
|
/>
|
|
) : null}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-mono text-xs uppercase tracking-wider text-mint">
|
|
{dict.listening} Spotify
|
|
</p>
|
|
<p className="truncate text-sm font-semibold">
|
|
{formatSpotifyList(data.spotify.song)}
|
|
</p>
|
|
<p className="truncate text-sm text-zinc-400">
|
|
{formatSpotifyList(data.spotify.artist)}
|
|
</p>
|
|
{data.spotify.timestamps ? (
|
|
<SpotifyProgress
|
|
key={`${data.spotify.track_id}-${data.spotify.timestamps.start}`}
|
|
start={data.spotify.timestamps.start}
|
|
end={data.spotify.timestamps.end}
|
|
/>
|
|
) : null}
|
|
<a
|
|
href={spotifyTrackUrl(data.spotify.track_id)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="mt-1 inline-block text-xs font-medium text-blurple-soft transition-colors hover:text-white"
|
|
>
|
|
{dict.openOnSpotify} ↗
|
|
</a>
|
|
</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>
|
|
);
|
|
}
|