Discord presence overhaul: game profiles, Spotify bar, Lanyard WS
Build & Push / Lint & Build (push) Successful in 1m19s
Build & Push / Build & Push Image (push) Successful in 31s

- 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
This commit is contained in:
fristy committed 2026-09-10 14:36:14 +03:00
1 parent ad943fff8c
commit bcc5662a44
16 files changed
+1888 -43

No files matched your search

+326 -27
View File
@@ -2,11 +2,18 @@
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,
@@ -19,12 +26,68 @@ const STATUS_DOT: Record<DiscordStatus, string> = {
offline: "bg-zinc-500",
};
const POLL_MS = 15_000;
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 formatElapsed(start: number): string {
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`;
}
@@ -34,9 +97,58 @@ function formatElapsed(start: number): string {
return "<1m";
}
function ActivityRow({ activity }: { activity: LanyardActivity }) {
const large = activityImageUrl(activity, false);
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 ? (
@@ -58,8 +170,15 @@ function ActivityRow({ activity }: { activity: LanyardActivity }) {
/>
) : null}
</div>
) : null}
<div className="min-w-0">
) : (
<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>
@@ -67,12 +186,38 @@ function ActivityRow({ activity }: { activity: LanyardActivity }) {
{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 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>
);
}
@@ -80,28 +225,165 @@ function ActivityRow({ activity }: { activity: LanyardActivity }) {
export function DiscordCard({
initial,
dict,
locale,
}: {
initial: LanyardData | null;
dict: Dictionary["discord"];
locale: string;
}) {
const [data, setData] = useState<LanyardData | null>(initial);
useEffect(() => {
async function poll(): Promise<void> {
const response = await fetch(lanyardUrl(), { cache: "no-store" });
if (!response.ok) {
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;
}
const json = (await response.json()) as { success: boolean; data: LanyardData };
if (json.success) {
setData(json.data);
}
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 tick(): void {
void poll().catch(() => undefined);
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;
}
const timer = window.setInterval(tick, POLL_MS);
return () => window.clearInterval(timer);
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) {
@@ -166,7 +448,7 @@ export function DiscordCard({
{dict.playing}
</p>
{games.map((game) => (
<ActivityRow key={game.id} activity={game} />
<ActivityRow key={game.id} activity={game} dict={dict} locale={locale} />
))}
</div>
) : null}
@@ -176,20 +458,37 @@ export function DiscordCard({
{data.spotify.album_art_url ? (
<Image
src={data.spotify.album_art_url}
alt={data.spotify.album}
alt={formatSpotifyList(data.spotify.song)}
width={48}
height={48}
className="rounded-lg"
/>
) : null}
<div className="min-w-0">
<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">{data.spotify.song}</p>
<p className="truncate text-sm text-zinc-400">
{data.spotify.artist} — {data.spotify.album}
<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}
+594
View File
@@ -0,0 +1,594 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GamePricePoint, GameProfile } from "@/lib/game-profile";
import { steamdbRating } from "@/lib/game-profile";
import type { Dictionary } from "@/lib/i18n/get-dictionary";
type ModalDict = Dictionary["discord"]["gameModal"];
const STEAM_SCORE_KEYS: Record<string, keyof ModalDict["reviewScores"]> = {
"Overwhelmingly Positive": "overwhelminglyPositive",
"Very Positive": "veryPositive",
Positive: "positive",
"Mostly Positive": "mostlyPositive",
Mixed: "mixed",
"Mostly Negative": "mostlyNegative",
Negative: "negative",
"Very Negative": "veryNegative",
"Overwhelmingly Negative": "overwhelminglyNegative",
};
const LOCALE_TAGS: Record<string, string> = {
tr: "tr-TR",
en: "en-US",
de: "de-DE",
};
const COUNTRIES: Record<string, string> = {
tr: "TR",
en: "US",
de: "DE",
};
function localizeScore(label: string, dict: ModalDict): string {
const key = STEAM_SCORE_KEYS[label];
return key ? dict.reviewScores[key] : label;
}
function formatRatingPercent(value: number, tag: string): string {
try {
return new Intl.NumberFormat(tag, {
style: "percent",
maximumFractionDigits: 2,
}).format(value);
} catch {
return `%${(value * 100).toFixed(2)}`;
}
}
function formatPrice(point: GamePricePoint, tag: string): string {
try {
return new Intl.NumberFormat(tag, {
style: "currency",
currency: point.currency,
}).format(point.price);
} catch {
return `${point.price} ${point.currency}`;
}
}
function formatReleaseDate(value: string, tag: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const parsed = new Date(`${value}T00:00:00`);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toLocaleDateString(tag, {
day: "numeric",
month: "long",
year: "numeric",
});
}
}
return value;
}
function formatCount(value: number | null | undefined, tag: string, unit: string): string {
if (value == null) {
return "—";
}
return `${value.toLocaleString(tag)} ${unit}`;
}
function discountPercent(point: GamePricePoint): number | null {
if (point.regular > point.price && point.regular > 0) {
return Math.round((1 - point.price / point.regular) * 100);
}
return null;
}
export function GameProfileModal({
applicationId,
heroSeed,
lang,
iconUrl,
fallbackName,
fallbackGameUrl,
dict,
onClose,
}: {
applicationId: string;
heroSeed: number;
lang: string;
iconUrl: string | null;
fallbackName: string;
fallbackGameUrl: string;
dict: ModalDict;
onClose: () => void;
}) {
const [profile, setProfile] = useState<GameProfile | null>(null);
const [failed, setFailed] = useState(false);
const [bannerPick, setBannerPick] = useState<string | null>(null);
const tag = LOCALE_TAGS[lang] ?? "en-US";
const country = COUNTRIES[lang] ?? "US";
useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
try {
const response = await fetch(
`/api/game/${applicationId}?lang=${lang}&country=${country}`,
);
if (!response.ok) {
throw new Error(`game api: ${response.status}`);
}
const json = (await response.json()) as GameProfile;
if (!cancelled) {
setProfile(json);
}
} catch {
if (!cancelled) {
setFailed(true);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [applicationId, lang, country]);
useEffect(() => {
function onKey(event: KeyboardEvent): void {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("keydown", onKey);
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = previous;
};
}, [onClose]);
const gameUrl = profile?.gameUrl ?? fallbackGameUrl;
const name = profile?.name ?? fallbackName;
const best = profile?.itad?.prices ?? null;
const low = profile?.itad?.historyLow ?? null;
const hero = useMemo(() => {
if (bannerPick) {
return bannerPick;
}
const shots = profile?.screenshots ?? [];
if (shots.length > 0) {
return shots[heroSeed % shots.length] ?? null;
}
return profile?.artworkUrl ?? null;
}, [profile, heroSeed, bannerPick]);
const dbRating =
profile?.steamReviews?.positive != null && profile?.steamReviews?.total != null
? steamdbRating(profile.steamReviews.positive, profile.steamReviews.total)
: null;
const itadReviews = profile?.itad?.reviews ?? [];
const hasDetails = !!(
profile &&
(profile.developers.length > 0 ||
profile.publishers.length > 0 ||
profile.releaseDate ||
profile.platforms.length > 0 ||
profile.links.length > 0)
);
const metascore = itadReviews.find((r) => r.source === "Metascore") ?? null;
const opencritic = itadReviews.find((r) => r.source === "OpenCritic") ?? null;
const hasReviews = !!(
profile?.steamReviews ||
(dbRating != null && profile?.steamId) ||
metascore ||
opencritic
);
const stripRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const strip = stripRef.current;
if (!strip) {
return;
}
function onWheel(event: WheelEvent): void {
const el = stripRef.current;
if (!el) {
return;
}
if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
return;
}
if (el.scrollWidth <= el.clientWidth + 1) {
return;
}
event.preventDefault();
el.scrollLeft += event.deltaY;
}
strip.addEventListener("wheel", onWheel, { passive: false });
return () => strip.removeEventListener("wheel", onWheel);
}, [profile]);
function scrollStrip(by: number): void {
stripRef.current?.scrollBy({ left: by, behavior: "smooth" });
}
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-4 backdrop-blur-sm sm:items-center"
onClick={onClose}
role="presentation"
>
<div
role="dialog"
aria-modal="true"
aria-label={name}
className="modal-scroll max-h-[85vh] w-full max-w-4xl overflow-y-auto rounded-3xl border border-white/10 bg-panel shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
{hero ? (
<div className="relative h-48 w-full shrink-0 sm:h-64">
<Image
src={hero}
alt=""
fill
sizes="(max-width: 896px) 100vw, 896px"
className="rounded-t-3xl object-cover"
priority
/>
<div className="absolute inset-0 rounded-t-3xl bg-gradient-to-t from-panel via-panel/30 to-transparent" />
</div>
) : null}
<div className="p-5 sm:p-6">
<div className="flex items-center gap-4">
{iconUrl ? (
<Image
src={iconUrl}
alt=""
width={64}
height={64}
className="shrink-0 rounded-2xl border border-white/10"
/>
) : (
<div
aria-hidden
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-blurple/20 text-2xl font-bold text-blurple-soft"
>
{name.charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="truncate text-xl font-bold">{name}</h2>
{profile && profile.genres.length > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{profile.genres.map((genre) => (
<span
key={genre}
className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-zinc-300"
>
{genre}
</span>
))}
</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
>
{dict.close}
</button>
</div>
{!profile && !failed ? (
<p className="mt-4 animate-pulse text-sm text-zinc-500">{dict.loading}</p>
) : null}
{failed && !profile ? (
<p className="mt-4 text-sm text-zinc-500">{dict.loadError}</p>
) : null}
{profile?.description ? (
<p className="mt-4 text-sm leading-relaxed text-zinc-300">
{profile.description}
</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
{profile?.steamUrl ? (
<a
href={profile.steamUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-blurple px-4 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-blurple-soft hover:text-void"
>
{dict.openOnSteam} ↗
</a>
) : null}
{profile?.steamId ? (
<a
href={`https://steamdb.info/app/${profile.steamId}/`}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
>
{dict.openOnSteamDb} ↗
</a>
) : null}
<a
href={gameUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
>
{dict.openOnDiscord} ↗
</a>
{profile?.itad?.pageUrl ? (
<a
href={profile.itad.pageUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
>
{dict.allPrices} ↗
</a>
) : null}
</div>
{hasDetails ? (
<dl className="mt-5 grid gap-x-6 gap-y-2 rounded-2xl border border-white/5 bg-white/[0.03] p-4 text-sm md:grid-cols-2">
{profile.developers.length > 0 ? (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
<dt className="shrink-0 text-zinc-500 sm:w-36">
{dict.developers}
</dt>
<dd className="text-zinc-200">
{profile.developers.join(", ")}
</dd>
</div>
) : null}
{profile.publishers.length > 0 ? (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
<dt className="shrink-0 text-zinc-500 sm:w-36">
{dict.publishers}
</dt>
<dd className="text-zinc-200">
{profile.publishers.join(", ")}
</dd>
</div>
) : null}
{profile.releaseDate ? (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
<dt className="shrink-0 text-zinc-500 sm:w-36">
{dict.releaseDate}
</dt>
<dd className="text-zinc-200">
{formatReleaseDate(profile.releaseDate, tag)}
</dd>
</div>
) : null}
{profile.platforms.length > 0 ? (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
<dt className="shrink-0 text-zinc-500 sm:w-36">
{dict.platforms}
</dt>
<dd className="text-zinc-200">
{profile.platforms.join(" · ")}
</dd>
</div>
) : null}
{profile.links.length > 0 ? (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3 md:col-span-2">
<dt className="shrink-0 text-zinc-500 sm:w-36">{dict.links}</dt>
<dd className="flex flex-wrap gap-x-3 gap-y-1">
{profile.links.slice(0, 8).map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blurple-soft transition-colors hover:text-white"
>
{link.label} ↗
</a>
))}
</dd>
</div>
) : null}
</dl>
) : null}
{profile && hasReviews ? (
<div className="mt-4">
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
{dict.reviews}
</p>
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
{profile.steamReviews ? (
<a
href={profile.steamReviews.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
>
<p className="font-mono text-xs text-zinc-500">
{dict.totalReviews}
</p>
<p className="mt-1 text-sm font-semibold">
{localizeScore(profile.steamReviews.label, dict)}
</p>
<p className="font-mono text-xs text-zinc-500">
{formatCount(
profile.steamReviews.total,
tag,
dict.reviewsUnit,
)}
</p>
</a>
) : null}
{dbRating != null && profile.steamId ? (
<a
href={`https://steamdb.info/app/${profile.steamId}/`}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
>
<p className="font-mono text-xs text-zinc-500">
{dict.steamdbRating}
</p>
<p className="mt-1 text-sm font-semibold">
{formatRatingPercent(dbRating, tag)}
</p>
<p className="font-mono text-xs text-zinc-500">
steamdb.info ↗
</p>
</a>
) : null}
{metascore ? (
<a
href={metascore.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
>
<p className="font-mono text-xs text-zinc-500">
Metacritic
</p>
<p className="mt-1 text-sm font-semibold">
{metascore.score ?? "—"}
<span className="font-normal text-zinc-500">/100</span>
</p>
<p className="font-mono text-xs text-zinc-500">
{formatCount(metascore.count, tag, dict.reviewsUnit)}
</p>
</a>
) : null}
{opencritic ? (
<a
href={opencritic.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
>
<p className="font-mono text-xs text-zinc-500">
OpenCritic
</p>
<p className="mt-1 text-sm font-semibold">
{opencritic.score ?? "—"}
<span className="font-normal text-zinc-500">/100</span>
</p>
<p className="font-mono text-xs text-zinc-500">
{formatCount(opencritic.count, tag, dict.reviewsUnit)}
</p>
</a>
) : null}
</div>
</div>
) : null}
{profile?.itad && (best || low) ? (
<div className="mt-4">
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
{dict.prices}
</p>
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
{best ? (
<a
href={best.url || undefined}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-mint/20 bg-mint/5 p-3 transition-colors hover:border-mint/40"
>
<p className="font-mono text-xs text-mint">
{dict.bestPrice} · {best.shop}
</p>
<p className="mt-1 text-lg font-bold">
{formatPrice(best, tag)}{" "}
{discountPercent(best) ? (
<span className="ml-1 rounded bg-mint/15 px-1.5 py-0.5 align-middle font-mono text-xs text-mint">
-%{discountPercent(best)}
</span>
) : null}
</p>
</a>
) : null}
{low ? (
<div className="rounded-xl border border-white/5 bg-white/[0.03] p-3">
<p className="font-mono text-xs text-zinc-500">
{dict.historyLow} · {low.shop}
</p>
<p className="mt-1 text-lg font-bold text-zinc-200">
{formatPrice(low, tag)}
</p>
</div>
) : null}
</div>
</div>
) : null}
{profile && profile.screenshots.length > 0 ? (
<div className="mt-4">
<div className="flex items-center justify-between">
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
{dict.screenshots}
</p>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => scrollStrip(-360)}
aria-label={dict.scrollBack}
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
>
‹
</button>
<button
type="button"
onClick={() => scrollStrip(360)}
aria-label={dict.scrollForward}
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
>
›
</button>
</div>
</div>
<div
ref={stripRef}
className="no-scrollbar mt-1.5 flex gap-2 overflow-x-auto p-1"
>
{profile.screenshots.map((shot) => (
<button
key={shot}
type="button"
onClick={() => setBannerPick(shot)}
aria-label={dict.screenshots}
className={`shrink-0 overflow-hidden rounded-xl transition-all ${
hero === shot
? "ring-2 ring-blurple"
: "opacity-70 hover:opacity-100"
}`}
>
<Image
src={shot}
alt=""
width={320}
height={180}
loading="lazy"
className="h-auto w-56"
/>
</button>
))}
</div>
</div>
) : null}
</div>
</div>
</div>
);
}
+10 -10
View File
@@ -1,18 +1,18 @@
"use client";
import { useRouter } from "next/navigation";
import { LOCALES, LOCALE_LABELS, type Locale } from "@/lib/i18n/locales";
import { setLocaleCookie } from "@/lib/i18n/set-locale-cookie";
import { LOCALES, LOCALE_LABELS, LOCALE_COOKIE, isLocale, type Locale } from "@/lib/i18n/locales";
function writeLocaleCookie(locale: Locale): void {
document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=31536000; SameSite=Lax`;
}
export function LanguageSwitcher({ current }: { current: Locale }) {
const router = useRouter();
async function switchTo(locale: Locale) {
if (locale === current) {
function switchTo(locale: Locale) {
if (locale === current || !isLocale(locale)) {
return;
}
await setLocaleCookie(locale);
router.refresh();
writeLocaleCookie(locale);
window.location.reload();
}
return (
@@ -27,7 +27,7 @@ export function LanguageSwitcher({ current }: { current: Locale }) {
type="button"
onClick={() => switchTo(locale)}
aria-pressed={locale === current}
className={`rounded-full px-2.5 py-1 font-mono text-xs transition-colors ${
className={`cursor-pointer rounded-full px-2.5 py-1 font-mono text-xs transition-colors ${
locale === current
? "bg-blurple text-white"
: "text-zinc-400 hover:text-white"