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 ? (
) : 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}
+
+
+
+ {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) ? (
+
+ ) : 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" },
],
},
};