From bcc5662a44036f75f5f24a07810b4199fc899b99 Mon Sep 17 00:00:00 2001 From: fristyxd Date: Thu, 10 Sep 2026 14:36:14 +0300 Subject: [PATCH] Discord presence overhaul: game profiles, Spotify bar, Lanyard WS - 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 --- .gitignore | 3 + app/api/discord-app/[id]/route.ts | 37 ++ app/api/game/[id]/route.ts | 44 +++ app/globals.css | 30 +- app/page.tsx | 2 +- components/discord-card.tsx | 353 +++++++++++++++-- components/game-profile-modal.tsx | 594 +++++++++++++++++++++++++++++ components/language-switcher.tsx | 20 +- lib/api-cors.ts | 19 + lib/game-profile.ts | 615 ++++++++++++++++++++++++++++++ lib/gitea.ts | 3 +- lib/i18n/dictionaries/de.json | 39 +- lib/i18n/dictionaries/en.json | 39 +- lib/i18n/dictionaries/tr.json | 39 +- lib/lanyard.ts | 90 +++++ next.config.ts | 4 + 16 files changed, 1888 insertions(+), 43 deletions(-) create mode 100644 app/api/discord-app/[id]/route.ts create mode 100644 app/api/game/[id]/route.ts create mode 100644 components/game-profile-modal.tsx create mode 100644 lib/api-cors.ts create mode 100644 lib/game-profile.ts diff --git a/.gitignore b/.gitignore index 905703a..012d0dd 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# local code intelligence index +.codegraph/ diff --git a/app/api/discord-app/[id]/route.ts b/app/api/discord-app/[id]/route.ts new file mode 100644 index 0000000..e70cace --- /dev/null +++ b/app/api/discord-app/[id]/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { corsHeaders, forbidden, isAllowedOrigin } from "@/lib/api-cors"; +import { getDiscordAppInfo } from "@/lib/lanyard"; + +export async function OPTIONS(request: Request) { + if (!isAllowedOrigin(request)) { + return forbidden(); + } + return new NextResponse(null, { + status: 204, + headers: { + ...corsHeaders(), + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Max-Age": "86400", + }, + }); +} + +export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { + if (!isAllowedOrigin(request)) { + return forbidden(); + } + const { id } = await context.params; + if (!/^\d+$/.test(id)) { + return NextResponse.json({ error: "Invalid application id" }, { status: 400 }); + } + const info = await getDiscordAppInfo(id); + if (!info) { + return NextResponse.json({ error: "Application not found" }, { status: 404 }); + } + return NextResponse.json(info, { + headers: { + "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=86400", + ...corsHeaders(), + }, + }); +} diff --git a/app/api/game/[id]/route.ts b/app/api/game/[id]/route.ts new file mode 100644 index 0000000..0d94c8c --- /dev/null +++ b/app/api/game/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { corsHeaders, forbidden, isAllowedOrigin } from "@/lib/api-cors"; +import { getGameProfile } from "@/lib/game-profile"; + +export async function OPTIONS(request: Request) { + if (!isAllowedOrigin(request)) { + return forbidden(); + } + return new NextResponse(null, { + status: 204, + headers: { + ...corsHeaders(), + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Max-Age": "86400", + }, + }); +} + +export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { + if (!isAllowedOrigin(request)) { + return forbidden(); + } + const { id } = await context.params; + if (!/^\d+$/.test(id)) { + return NextResponse.json({ error: "Invalid application id" }, { status: 400 }); + } + const { searchParams } = new URL(request.url); + const country = /^[A-Z]{2}$/.test(searchParams.get("country") ?? "") + ? (searchParams.get("country") as string) + : "TR"; + const lang = ["tr", "en", "de"].includes(searchParams.get("lang") ?? "") + ? (searchParams.get("lang") as string) + : "en"; + const profile = await getGameProfile(id, country, lang); + if (!profile) { + return NextResponse.json({ error: "Game not found" }, { status: 404 }); + } + return NextResponse.json(profile, { + headers: { + "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400", + ...corsHeaders(), + }, + }); +} diff --git a/app/globals.css b/app/globals.css index c73b912..4d6ca7e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -12,6 +12,8 @@ html { scroll-behavior: smooth; + scrollbar-width: thin; + scrollbar-color: #232c47 var(--color-void); } body { @@ -29,7 +31,7 @@ body { } ::-webkit-scrollbar { - width: 10px; + width: 8px; } ::-webkit-scrollbar-track { background: var(--color-void); @@ -41,3 +43,29 @@ body { ::-webkit-scrollbar-thumb:hover { background: var(--color-blurple); } + +.modal-scroll { + scrollbar-width: thin; + scrollbar-color: #2b3554 transparent; +} +.modal-scroll::-webkit-scrollbar { + width: 6px; +} +.modal-scroll::-webkit-scrollbar-track { + background: transparent; +} +.modal-scroll::-webkit-scrollbar-thumb { + background: #2b3554; + border-radius: 999px; +} +.modal-scroll::-webkit-scrollbar-thumb:hover { + background: var(--color-blurple); +} + +.no-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; +} +.no-scrollbar::-webkit-scrollbar { + display: none; +} diff --git a/app/page.tsx b/app/page.tsx index 34761f8..a82c3e9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -55,7 +55,7 @@ export default async function Home() { } /> - + diff --git a/components/discord-card.tsx b/components/discord-card.tsx index 36c9b32..5a1754d 100644 --- a/components/discord-card.tsx +++ b/components/discord-card.tsx @@ -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 = { 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 ( +
+ {formatTrackTime(now - start)} +
+
+
+ {formatTrackTime(total)} +
+ ); +} + +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(); + +function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo | null { + const [info, setInfo] = useState(() => + applicationId ? (discordAppCache.get(applicationId) ?? null) : null, + ); + + useEffect(() => { + if (!applicationId || discordAppCache.has(applicationId)) { + return; + } + const id = applicationId; + let cancelled = false; + async function load(): Promise { + 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 (
{large ? ( @@ -58,8 +170,15 @@ function ActivityRow({ activity }: { activity: LanyardActivity }) { /> ) : null}
- ) : null} -
+ ) : ( +
+ {activity.name.charAt(0).toUpperCase()} +
+ )} +

{activity.name}

{activity.details ? (

{activity.details}

@@ -67,12 +186,38 @@ function ActivityRow({ activity }: { activity: LanyardActivity }) { {activity.state ? (

{activity.state}

) : null} - {activity.timestamps?.start ? ( -

- {formatElapsed(activity.timestamps.start)} -

- ) : null} +
+ {activity.timestamps?.start ? ( + + {formatElapsed(activity.timestamps.start, locale)} + + ) : null} + {gameUrl ? ( + + ) : null} +
+ {profileOpen && activity.application_id ? ( + setProfileOpen(false)} + /> + ) : null}
); } @@ -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(initial); useEffect(() => { - async function poll(): Promise { - 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 { + 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}

{games.map((game) => ( - + ))}
) : null} @@ -176,20 +458,37 @@ export function DiscordCard({ {data.spotify.album_art_url ? ( {data.spotify.album} ) : null} -
+

{dict.listening} Spotify

-

{data.spotify.song}

-

- {data.spotify.artist} — {data.spotify.album} +

+ {formatSpotifyList(data.spotify.song)}

+

+ {formatSpotifyList(data.spotify.artist)} +

+ {data.spotify.timestamps ? ( + + ) : null} + + {dict.openOnSpotify} ↗ +
) : null} diff --git a/components/game-profile-modal.tsx b/components/game-profile-modal.tsx new file mode 100644 index 0000000..defd306 --- /dev/null +++ b/components/game-profile-modal.tsx @@ -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 = { + "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 = { + tr: "tr-TR", + en: "en-US", + de: "de-DE", +}; + +const COUNTRIES: Record = { + 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(null); + const [failed, setFailed] = useState(false); + const [bannerPick, setBannerPick] = useState(null); + const tag = LOCALE_TAGS[lang] ?? "en-US"; + const country = COUNTRIES[lang] ?? "US"; + + useEffect(() => { + let cancelled = false; + async function load(): Promise { + 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(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 ( +
+
event.stopPropagation()} + > + {hero ? ( +
+ +
+
+ ) : null} + +
+
+ {iconUrl ? ( + + ) : ( +
+ {name.charAt(0).toUpperCase()} +
+ )} +
+

{name}

+ {profile && profile.genres.length > 0 ? ( +
+ {profile.genres.map((genre) => ( + + {genre} + + ))} +
+ ) : null} +
+ +
+ + {!profile && !failed ? ( +

{dict.loading}

+ ) : null} + {failed && !profile ? ( +

{dict.loadError}

+ ) : null} + + {profile?.description ? ( +

+ {profile.description} +

+ ) : null} + +
+ {profile?.steamUrl ? ( + + {dict.openOnSteam} ↗ + + ) : null} + {profile?.steamId ? ( + + {dict.openOnSteamDb} ↗ + + ) : null} + + {dict.openOnDiscord} ↗ + + {profile?.itad?.pageUrl ? ( + + {dict.allPrices} ↗ + + ) : null} +
+ + {hasDetails ? ( +
+ {profile.developers.length > 0 ? ( +
+
+ {dict.developers} +
+
+ {profile.developers.join(", ")} +
+
+ ) : null} + {profile.publishers.length > 0 ? ( +
+
+ {dict.publishers} +
+
+ {profile.publishers.join(", ")} +
+
+ ) : null} + {profile.releaseDate ? ( +
+
+ {dict.releaseDate} +
+
+ {formatReleaseDate(profile.releaseDate, tag)} +
+
+ ) : null} + {profile.platforms.length > 0 ? ( +
+
+ {dict.platforms} +
+
+ {profile.platforms.join(" · ")} +
+
+ ) : null} + {profile.links.length > 0 ? ( +
+
{dict.links}
+
+ {profile.links.slice(0, 8).map((link) => ( + + {link.label} ↗ + + ))} +
+
+ ) : null} +
+ ) : null} + + {profile && hasReviews ? ( + + ) : null} + + {profile?.itad && (best || low) ? ( +
+

+ {dict.prices} +

+
+ {best ? ( + +

+ {dict.bestPrice} · {best.shop} +

+

+ {formatPrice(best, tag)}{" "} + {discountPercent(best) ? ( + + -%{discountPercent(best)} + + ) : null} +

+
+ ) : null} + {low ? ( +
+

+ {dict.historyLow} · {low.shop} +

+

+ {formatPrice(low, tag)} +

+
+ ) : null} +
+
+ ) : null} + + {profile && profile.screenshots.length > 0 ? ( +
+
+

+ {dict.screenshots} +

+
+ + +
+
+
+ {profile.screenshots.map((shot) => ( + + ))} +
+
+ ) : null} +
+
+
+ ); +} diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx index accaf6b..f888b90 100644 --- a/components/language-switcher.tsx +++ b/components/language-switcher.tsx @@ -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" diff --git a/lib/api-cors.ts b/lib/api-cors.ts new file mode 100644 index 0000000..f088571 --- /dev/null +++ b/lib/api-cors.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; + +const ALLOWED_ORIGIN = "https://fristy.net"; + +export function isAllowedOrigin(request: Request): boolean { + const origin = request.headers.get("origin"); + return origin === null || origin === ALLOWED_ORIGIN; +} + +export function corsHeaders(): HeadersInit { + return { + "Access-Control-Allow-Origin": ALLOWED_ORIGIN, + Vary: "Origin", + }; +} + +export function forbidden(): NextResponse { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); +} diff --git a/lib/game-profile.ts b/lib/game-profile.ts new file mode 100644 index 0000000..8cc1836 --- /dev/null +++ b/lib/game-profile.ts @@ -0,0 +1,615 @@ +import { getDiscordAppInfo } from "@/lib/lanyard"; + +export interface GameExternalLink { + label: string; + url: string; +} + +export interface GameReviewSummary { + label: string; + total: number | null; + positive: number | null; + negative: number | null; + url?: string; +} + +export function steamdbRating(positive: number, total: number): number | null { + if ( + !Number.isFinite(positive) || + !Number.isFinite(total) || + total <= 0 || + positive < 0 || + positive > total + ) { + return null; + } + const score = positive / total; + return score - (score - 0.5) * 2 ** -Math.log10(total + 1); +} + +export interface GamePricePoint { + price: number; + regular: number; + currency: string; + shop: string; + url: string; +} + +export interface ItadReview { + score: number | null; + source: string; + count: number | null; + url: string; +} + +export interface GameProfile { + applicationId: string; + gameId: string; + gameUrl: string; + name: string; + genres: string[]; + description: string | null; + developers: string[]; + publishers: string[]; + releaseDate: string | null; + platforms: string[]; + links: GameExternalLink[]; + coverUrl: string | null; + artworkUrl: string | null; + screenshots: string[]; + steamId: string | null; + steamUrl: string | null; + steamReviews: GameReviewSummary | null; + itad: { + prices: GamePricePoint | null; + historyLow: GamePricePoint | null; + pageUrl: string | null; + playerCount: number | null; + reviews: ItadReview[]; + } | null; + sources: { + igdb: boolean; + steam: boolean; + itad: boolean; + }; +} + +interface DetectableEntry { + id: string; + name: string; + third_party_skus?: Array<{ distributor: string; id: string }>; +} + +let detectablesCache: { data: DetectableEntry[]; expires: number } | null = null; + +async function getDetectables(): Promise { + if (detectablesCache && Date.now() < detectablesCache.expires) { + return detectablesCache.data; + } + try { + const response = await fetch("https://cdn.discordapp.com/detectables/games-v1.json", { + next: { revalidate: 86_400 }, + }); + if (!response.ok) { + return detectablesCache?.data ?? []; + } + const data = (await response.json()) as DetectableEntry[]; + detectablesCache = { data, expires: Date.now() + 86_400_000 }; + return data; + } catch { + return detectablesCache?.data ?? []; + } +} + +const DISTRIBUTOR_PLATFORM: Record = { + steam: "PC", + battlenet: "PC", + gog: "PC", + epic: "PC", + xbox: "Xbox", + xbox_title: "Xbox", + xbox_game_pass: "Xbox", + playstation: "PlayStation", + nintendo: "Switch", +}; + +function steamIdFromEntry(entry: DetectableEntry | undefined): string | null { + const sku = entry?.third_party_skus?.find( + (s) => s.distributor === "steam" && /^\d+$/.test(s.id), + ); + return sku?.id ?? null; +} + +interface SteamAppDetails { + success: boolean; + data?: { + name?: string; + short_description?: string; + header_image?: string; + genres?: Array<{ description?: string }>; + developers?: string[]; + publishers?: string[]; + platforms?: { windows?: boolean; mac?: boolean; linux?: boolean }; + release_date?: { date?: string }; + screenshots?: Array<{ path_full?: string }>; + }; +} + +const STEAM_LANG: Record = { + tr: "turkish", + en: "english", + de: "german", +}; + +async function getSteamDetails( + steamId: string, + lang = "en", +): Promise { + try { + const steamLang = STEAM_LANG[lang] ?? "english"; + const response = await fetch( + `https://store.steampowered.com/api/appdetails?appids=${steamId}&l=${steamLang}&cc=US`, + { next: { revalidate: 86_400 } }, + ); + if (!response.ok) { + return null; + } + const json = (await response.json()) as Record; + const entry = json[steamId]; + return entry?.success ? (entry.data ?? null) : null; + } catch { + return null; + } +} + +interface SteamReviewQuery { + review_score_desc?: string; + total_positive?: number; + total_negative?: number; + total_reviews?: number; +} + +async function getSteamReviewSummary(steamId: string): Promise { + try { + const url = + `https://store.steampowered.com/appreviews/${steamId}` + + "?json=1&language=all&purchase_type=all&num_per_page=0"; + const response = await fetch(url, { next: { revalidate: 3600 } }); + if (!response.ok) { + return null; + } + const json = (await response.json()) as { query_summary?: SteamReviewQuery }; + const summary = json.query_summary; + if (!summary || typeof summary.total_reviews !== "number") { + return null; + } + return { + label: summary.review_score_desc ?? "", + total: summary.total_reviews, + positive: summary.total_positive ?? null, + negative: summary.total_negative ?? null, + url: `https://store.steampowered.com/app/${steamId}/#app_reviews_hash`, + }; + } catch { + return null; + } +} + +interface IgdbGame { + name?: string; + genres?: Array<{ name?: string }>; + summary?: string; + storyline?: string; + cover?: { url?: string }; + artworks?: Array<{ url?: string }>; + screenshots?: Array<{ url?: string }>; + websites?: Array<{ url?: string; category?: number }>; + involved_companies?: Array<{ + developer?: boolean; + publisher?: boolean; + company?: { name?: string }; + }>; + platforms?: Array<{ abbreviation?: string; name?: string }>; + first_release_date?: number; +} + +let igdbTokenCache: { token: string; expires: number } | null = null; + +async function getIgdbToken(): Promise { + const clientId = process.env.TWITCH_CLIENT_ID; + const clientSecret = process.env.TWITCH_CLIENT_SECRET; + if (!clientId || !clientSecret) { + return null; + } + if (igdbTokenCache && Date.now() < igdbTokenCache.expires) { + return igdbTokenCache.token; + } + try { + const response = await fetch("https://id.twitch.tv/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "client_credentials", + }), + }); + if (!response.ok) { + console.error(`igdb token failed: ${response.status}`); + return null; + } + const json = (await response.json()) as { access_token?: string; expires_in?: number }; + if (!json.access_token) { + return null; + } + igdbTokenCache = { + token: json.access_token, + expires: Date.now() + (json.expires_in ?? 3600) * 1000 - 60_000, + }; + return json.access_token; + } catch { + return null; + } +} + +async function igdbQuery(token: string, endpoint: string, body: string): Promise { + try { + const response = await fetch(`https://api.igdb.com/v4/${endpoint}`, { + method: "POST", + headers: { + "Client-ID": process.env.TWITCH_CLIENT_ID ?? "", + Authorization: `Bearer ${token}`, + "Content-Type": "text/plain", + }, + body, + next: { revalidate: 86_400 }, + }); + if (!response.ok) { + console.error(`igdb query failed: ${endpoint} ${response.status}`); + return null; + } + return (await response.json()) as T; + } catch { + return null; + } +} + +const IGDB_GAME_FIELDS = + "name,genres.name,summary,storyline,cover.url,artworks.url,screenshots.url," + + "websites.url,websites.category,involved_companies.developer," + + "involved_companies.publisher,involved_companies.company.name," + + "platforms.abbreviation,platforms.name,first_release_date;"; + +function igdbImage(url: string | undefined, size: string): string | null { + if (!url) { + return null; + } + const full = url.startsWith("//") ? `https:${url}` : url; + return full.replace(/t_thumb|t_cover_small|t_logo_med/, size); +} + +async function getIgdbGame(name: string, steamId: string | null): Promise { + const token = await getIgdbToken(); + if (!token) { + return null; + } + if (steamId) { + const external = await igdbQuery>( + token, + "external_games", + `fields game; where uid = "${steamId}"; limit 1;`, + ); + const gameId = external?.[0]?.game; + if (gameId) { + const games = await igdbQuery( + token, + "games", + `fields ${IGDB_GAME_FIELDS} where id = ${gameId}; limit 1;`, + ); + if (games?.[0]) { + return games[0]; + } + } + } + const cleaned = name + .replace(/[®™©]/g, "") + .replace(/\s{2,}/g, " ") + .trim(); + const norm = (v: string): string => + v + .toLowerCase() + .replace(/[®™©]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + const escaped = cleaned.replace(/"/g, '\\"'); + const games = await igdbQuery( + token, + "games", + `fields ${IGDB_GAME_FIELDS} search "${escaped}"; limit 5;`, + ); + if (!games || games.length === 0) { + return null; + } + const wanted = norm(name); + return games.find((g) => g.name && norm(g.name) === wanted) ?? games[0] ?? null; +} + +const IGDB_WEBSITE_LABELS: Record = { + 1: "Website", + 4: "Facebook", + 5: "X", + 6: "Twitch", + 8: "Instagram", + 9: "YouTube", + 13: "Steam", + 14: "Reddit", + 16: "Epic", + 17: "GOG", + 18: "Discord", +}; + +interface ItadGameInfo { + title?: string; + assets?: { boxart?: string; banner600?: string }; + tags?: string[]; + releaseDate?: string | null; + developers?: Array<{ name?: string }>; + publishers?: Array<{ name?: string }>; + reviews?: Array<{ + score?: number | null; + source?: string; + count?: number | null; + url?: string; + }>; + players?: { recent?: number } | null; + urls?: { game?: string }; +} + +interface ItadOverviewPrice { + current?: { + shop?: { name?: string }; + price?: { amount?: number; currency?: string }; + regular?: { amount?: number }; + cut?: number; + url?: string; + } | null; + lowest?: { + shop?: { name?: string }; + price?: { amount?: number; currency?: string }; + regular?: { amount?: number }; + cut?: number; + } | null; +} + +async function itadFetch(path: string, init?: RequestInit): Promise { + const key = process.env.ITAD_API_KEY; + if (!key) { + return null; + } + try { + const separator = path.includes("?") ? "&" : "?"; + const response = await fetch( + `https://api.isthereanydeal.com${path}${separator}key=${key}`, + { + ...init, + next: { revalidate: 3600 }, + }, + ); + if (!response.ok) { + return null; + } + return (await response.json()) as T; + } catch { + return null; + } +} + +async function getItadId(title: string): Promise { + const results = await itadFetch>( + `/games/search/v1?title=${encodeURIComponent(title)}&results=5`, + ); + if (!results || results.length === 0) { + return null; + } + const norm = (value: string): string => + value + .toLowerCase() + .replace(/[®™©]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + const wanted = norm(title); + return results.find((r) => r.title && norm(r.title) === wanted)?.id ?? null; +} + +function toIsoDate(unixSeconds: number | undefined): string | null { + if (!unixSeconds) { + return null; + } + return new Date(unixSeconds * 1000).toISOString().slice(0, 10); +} + +export async function getGameProfile( + applicationId: string, + country = "TR", + lang = "en", +): Promise { + if (!/^\d+$/.test(applicationId)) { + return null; + } + const appInfo = await getDiscordAppInfo(applicationId); + if (!appInfo) { + return null; + } + + const detectables = await getDetectables(); + const entry = detectables.find((e) => e.id === appInfo.gameId || e.id === applicationId); + const steamId = steamIdFromEntry(entry); + + const [steam, steamReviews, igdb] = await Promise.all([ + steamId ? getSteamDetails(steamId, lang) : Promise.resolve(null), + steamId ? getSteamReviewSummary(steamId) : Promise.resolve(null), + getIgdbGame(appInfo.name || entry?.name || "", steamId), + ]); + + const itadId = process.env.ITAD_API_KEY + ? await getItadId(igdb?.name ?? steam?.name ?? appInfo.name) + : null; + const [itadInfo, itadOverview] = itadId + ? await Promise.all([ + itadFetch(`/games/info/v2?id=${itadId}`), + itadFetch<{ prices?: ItadOverviewPrice[] }>(`/games/overview/v2?country=${country}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify([itadId]), + }), + ]) + : [null, null]; + + const companies = igdb?.involved_companies ?? []; + const devList: string[] = + companies.length > 0 + ? [ + ...new Set( + companies + .filter((c) => c.developer) + .map((c) => c.company?.name) + .filter((n): n is string => !!n), + ), + ] + : (steam?.developers ?? + (itadInfo?.developers?.map((d) => d.name).filter((n): n is string => !!n) || [])); + const pubList: string[] = + companies.length > 0 + ? [ + ...new Set( + companies + .filter((c) => c.publisher) + .map((c) => c.company?.name) + .filter((n): n is string => !!n), + ), + ] + : (steam?.publishers ?? + (itadInfo?.publishers?.map((d) => d.name).filter((n): n is string => !!n) || [])); + + const platforms = new Set(); + for (const p of igdb?.platforms ?? []) { + const label = `${p.abbreviation ?? ""} ${p.name ?? ""}`.toUpperCase(); + if (/PS|PLAYSTATION/.test(label)) { + platforms.add("PlayStation"); + } else if (/XBOX|XONE|XSX/.test(label)) { + platforms.add("Xbox"); + } else if (/PC|WIN|MAC|LIN/.test(label)) { + platforms.add("PC"); + } else if (/SWITCH/.test(label)) { + platforms.add("Switch"); + } + } + if ( + steam?.platforms && + (steam.platforms.windows || steam.platforms.mac || steam.platforms.linux) + ) { + platforms.add("PC"); + } + for (const sku of entry?.third_party_skus ?? []) { + const mapped = DISTRIBUTOR_PLATFORM[sku.distributor]; + if (mapped) { + platforms.add(mapped); + } + } + + const links: GameExternalLink[] = []; + for (const site of igdb?.websites ?? []) { + if (!site.url) { + continue; + } + const url = site.url.startsWith("http") ? site.url : `https://${site.url}`; + let label = site.category ? (IGDB_WEBSITE_LABELS[site.category] ?? null) : null; + if (!label) { + try { + label = new URL(url).hostname.replace(/^www\./, ""); + } catch { + continue; + } + } + if (!links.some((l) => l.url === url)) { + links.push({ label, url }); + } + } + + const screenshots: string[] = [ + ...(igdb?.screenshots ?? []) + .map((s) => igdbImage(s.url, "t_screenshot_big")) + .filter((u): u is string => !!u), + ...(steam?.screenshots ?? []).map((s) => s.path_full).filter((u): u is string => !!u), + ].slice(0, 8); + + const priceEntry = itadOverview?.prices?.[0]; + const toPoint = (p: ItadOverviewPrice["current"]): GamePricePoint | null => { + if (!p?.price || typeof p.price.amount !== "number") { + return null; + } + return { + price: p.price.amount, + regular: p.regular?.amount ?? p.price.amount, + currency: p.price.currency ?? "", + shop: p.shop?.name ?? "", + url: p.url ?? "", + }; + }; + + return { + applicationId, + gameId: appInfo.gameId, + gameUrl: appInfo.gameUrl, + name: igdb?.name ?? steam?.name ?? appInfo.name, + genres: [ + ...(igdb?.genres ?? []).map((g) => g.name).filter((n): n is string => !!n), + ...(igdb?.genres?.length ? [] : (itadInfo?.tags ?? []).slice(0, 5)), + ].slice(0, 6), + description: + lang === "en" + ? (igdb?.summary ?? steam?.short_description ?? null) + : (steam?.short_description ?? igdb?.summary ?? null), + developers: devList, + publishers: pubList, + releaseDate: + toIsoDate(igdb?.first_release_date) ?? + itadInfo?.releaseDate ?? + steam?.release_date?.date ?? + null, + platforms: [...platforms], + links, + coverUrl: igdbImage(igdb?.cover?.url, "t_cover_big"), + artworkUrl: + igdbImage(igdb?.artworks?.[0]?.url, "t_1080p") ?? + steam?.header_image ?? + itadInfo?.assets?.banner600 ?? + null, + screenshots, + steamId, + steamUrl: steamId ? `https://store.steampowered.com/app/${steamId}/` : null, + steamReviews, + itad: + process.env.ITAD_API_KEY && (itadInfo || priceEntry) + ? { + prices: toPoint(priceEntry?.current), + historyLow: toPoint(priceEntry?.lowest), + pageUrl: itadInfo?.urls?.game ?? null, + playerCount: itadInfo?.players?.recent ?? null, + reviews: (itadInfo?.reviews ?? []) + .filter((r) => r.source && r.url) + .map((r) => ({ + score: r.score ?? null, + source: r.source as string, + count: r.count ?? null, + url: r.url as string, + })), + } + : null, + sources: { + igdb: !!igdb, + steam: !!steam, + itad: !!itadInfo, + }, + }; +} diff --git a/lib/gitea.ts b/lib/gitea.ts index 3d8433b..5b14cfa 100644 --- a/lib/gitea.ts +++ b/lib/gitea.ts @@ -53,7 +53,8 @@ export async function getRepos(): Promise { stars: repo.stars_count, forks: repo.forks_count, updatedAt: repo.updated_at, - })); + })) + .sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt)); } catch (error) { console.error("cannot fetch gitea repos:", error); return []; diff --git a/lib/i18n/dictionaries/de.json b/lib/i18n/dictionaries/de.json index b72e276..83821df 100644 --- a/lib/i18n/dictionaries/de.json +++ b/lib/i18n/dictionaries/de.json @@ -25,7 +25,44 @@ "listening": "Hört", "customStatus": "Status", "noActivity": "Gerade still... Hier läuft wohl nichts.", - "profile": "Profil Ansehen" + "profile": "Profil Ansehen", + "openOnSpotify": "Auf Spotify öffnen", + "gameProfile": "Spielprofil", + "gameModal": { + "close": "Schließen", + "developers": "Entwickler", + "publishers": "Publisher", + "releaseDate": "Erscheinungsdatum", + "platforms": "Plattformen", + "links": "Links", + "reviews": "Bewertungen", + "prices": "Preise", + "bestPrice": "Bester Preis", + "historyLow": "Historischer Tiefstpreis", + "allPrices": "ITAD", + "openOnDiscord": "Discord-Profil", + "openOnSteam": "Steam", + "openOnSteamDb": "SteamDB", + "loading": "Spieldaten werden geladen...", + "loadError": "Spieldaten konnten nicht geladen werden.", + "screenshots": "Screenshots", + "reviewsUnit": "Bewertungen", + "scrollBack": "Zurück", + "scrollForward": "Weiter", + "totalReviews": "Bewertungen insgesamt", + "steamdbRating": "SteamDB-Wertung", + "reviewScores": { + "overwhelminglyPositive": "Äußerst positiv", + "veryPositive": "Sehr positiv", + "positive": "Positiv", + "mostlyPositive": "Größtenteils positiv", + "mixed": "Ausgeglichen", + "mostlyNegative": "Größtenteils negativ", + "negative": "Negativ", + "veryNegative": "Sehr negativ", + "overwhelminglyNegative": "Äußerst negativ" + } + } }, "projects": { "title": "Projekte", diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index 1f68f94..7ecd278 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -25,7 +25,44 @@ "listening": "Listening To", "customStatus": "Status", "noActivity": "Quiet right now... Nothing seems to be going on.", - "profile": "View Profile" + "profile": "View Profile", + "openOnSpotify": "Open on Spotify", + "gameProfile": "Game Profile", + "gameModal": { + "close": "Close", + "developers": "Developers", + "publishers": "Publishers", + "releaseDate": "Release Date", + "platforms": "Platforms", + "links": "Links", + "reviews": "Reviews", + "prices": "Prices", + "bestPrice": "Best Price", + "historyLow": "All-Time Low", + "allPrices": "ITAD", + "openOnDiscord": "Discord Profile", + "openOnSteam": "Steam", + "openOnSteamDb": "SteamDB", + "loading": "Loading game info...", + "loadError": "Couldn't load game info.", + "screenshots": "Screenshots", + "reviewsUnit": "reviews", + "scrollBack": "Back", + "scrollForward": "Forward", + "totalReviews": "Total Reviews", + "steamdbRating": "SteamDB Rating", + "reviewScores": { + "overwhelminglyPositive": "Overwhelmingly Positive", + "veryPositive": "Very Positive", + "positive": "Positive", + "mostlyPositive": "Mostly Positive", + "mixed": "Mixed", + "mostlyNegative": "Mostly Negative", + "negative": "Negative", + "veryNegative": "Very Negative", + "overwhelminglyNegative": "Overwhelmingly Negative" + } + } }, "projects": { "title": "Projects", diff --git a/lib/i18n/dictionaries/tr.json b/lib/i18n/dictionaries/tr.json index 9f81201..a38c23a 100644 --- a/lib/i18n/dictionaries/tr.json +++ b/lib/i18n/dictionaries/tr.json @@ -25,7 +25,44 @@ "listening": "Dinliyor", "customStatus": "Durum", "noActivity": "Şu an sessizlik... Ortalıkta bir şey yok gibi.", - "profile": "Profile Git" + "profile": "Profile Git", + "openOnSpotify": "Spotify'da Aç", + "gameProfile": "Oyun Profili", + "gameModal": { + "close": "Kapat", + "developers": "Geliştiriciler", + "publishers": "Yayıncılar", + "releaseDate": "Çıkış Tarihi", + "platforms": "Platformlar", + "links": "Bağlantılar", + "reviews": "İncelemeler", + "prices": "Fiyatlar", + "bestPrice": "En İyi Fiyat", + "historyLow": "Tarihi En Düşük", + "allPrices": "ITAD", + "openOnDiscord": "Discord Profili", + "openOnSteam": "Steam", + "openOnSteamDb": "SteamDB", + "loading": "Oyun bilgileri yükleniyor...", + "loadError": "Oyun bilgileri yüklenemedi.", + "screenshots": "Görseller", + "reviewsUnit": "değerlendirme", + "scrollBack": "Geri", + "scrollForward": "İleri", + "totalReviews": "Toplam Değerlendirme", + "steamdbRating": "SteamDB Puanı", + "reviewScores": { + "overwhelminglyPositive": "Son Derece Olumlu", + "veryPositive": "Çok Olumlu", + "positive": "Olumlu", + "mostlyPositive": "Çoğunlukla Olumlu", + "mixed": "Karışık", + "mostlyNegative": "Çoğunlukla Olumsuz", + "negative": "Olumsuz", + "veryNegative": "Çok Olumsuz", + "overwhelminglyNegative": "Son Derece Olumsuz" + } + } }, "projects": { "title": "Projeler", diff --git a/lib/lanyard.ts b/lib/lanyard.ts index 9d54b40..c637d85 100644 --- a/lib/lanyard.ts +++ b/lib/lanyard.ts @@ -2,6 +2,8 @@ export const DISCORD_ID = "1014549805608992901"; const LANYARD_API = `https://api.lanyard.rest/v1/users/${DISCORD_ID}`; +export const LANYARD_WS = "wss://api.lanyard.rest/socket"; + export type DiscordStatus = "online" | "idle" | "dnd" | "offline"; export interface LanyardEmoji { @@ -48,12 +50,100 @@ export function activityImageUrl(activity: LanyardActivity, small: boolean): str export interface LanyardSpotify { track_id: string; + timestamps: { + start: number; + end: number; + }; song: string; artist: string; album: string; album_art_url: string | null; } +export function formatSpotifyList(value: string | null | undefined): string { + if (!value) { + return ""; + } + return value + .replace(/;/g, ",") + .replace(/,[ \t]+,/g, ",") + .replace(/\s{2,}/g, " ") + .trim(); +} + +export function spotifyTrackUrl(trackId: string): string { + return `https://open.spotify.com/track/${trackId}`; +} + +export function discordAppIconUrl( + applicationId: string, + iconHash: string | null, + size = 128, +): string | null { + if (!iconHash) { + return null; + } + const extension = iconHash.startsWith("a_") ? "gif" : "png"; + return `https://cdn.discordapp.com/app-icons/${applicationId}/${iconHash}.${extension}?size=${size}`; +} + +export function discordGameUrl(gameId: string): string { + return `https://discord.com/games/${gameId}`; +} + +export interface DiscordAppInfo { + id: string; + name: string; + icon: string | null; + iconUrl: string | null; + coverImage: string | null; + coverUrl: string | null; + gameId: string; + gameUrl: string; +} + +interface DiscordRpcResponse { + id: string; + name: string; + icon?: string | null; + cover_image?: string | null; + linked_games?: Array<{ id: string }> | null; +} + +export async function getDiscordAppInfo(applicationId: string): Promise { + if (!/^\d+$/.test(applicationId)) { + return null; + } + try { + const response = await fetch( + `https://discord.com/api/v10/applications/${applicationId}/rpc`, + { + headers: { "User-Agent": "fristy.net (personal site)" }, + next: { revalidate: 86_400 }, + }, + ); + if (!response.ok) { + return null; + } + const json = (await response.json()) as DiscordRpcResponse; + const gameId = json.linked_games?.[0]?.id ?? json.id ?? applicationId; + return { + id: json.id ?? applicationId, + name: json.name ?? "", + icon: json.icon ?? null, + iconUrl: discordAppIconUrl(applicationId, json.icon ?? null), + coverImage: json.cover_image ?? null, + coverUrl: json.cover_image + ? `https://cdn.discordapp.com/app-icons/${applicationId}/${json.cover_image}.png?size=512` + : null, + gameId, + gameUrl: discordGameUrl(gameId), + }; + } catch { + return null; + } +} + export interface LanyardData { discord_user: { id: string; diff --git a/next.config.ts b/next.config.ts index c245b97..ccf3f40 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,10 @@ const nextConfig: NextConfig = { { protocol: "https", hostname: "media.discordapp.net" }, { protocol: "https", hostname: "git.fristy.net" }, { protocol: "https", hostname: "i.scdn.co" }, + { protocol: "https", hostname: "images.igdb.com" }, + { protocol: "https", hostname: "cdn.akamai.steamstatic.com" }, + { protocol: "https", hostname: "shared.akamai.steamstatic.com" }, + { protocol: "https", hostname: "assets.isthereanydeal.com" }, ], }, };