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
This commit is contained in:
16 files changed
+1883
-38
No files matched your search
@@ -42,3 +42,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# local code intelligence index
|
||||
.codegraph/
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
+29
-1
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -55,7 +55,7 @@ export default async function Home() {
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<DiscordCard initial={presence} dict={dict.discord} />
|
||||
<DiscordCard initial={presence} dict={dict.discord} locale={locale} />
|
||||
</section>
|
||||
|
||||
<ProjectsSection repos={repos} dict={dict.projects} locale={locale} />
|
||||
|
||||
+321
-22
@@ -2,11 +2,18 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { GameProfileModal } from "@/components/game-profile-modal";
|
||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||
import {
|
||||
activityImageUrl,
|
||||
DISCORD_ID,
|
||||
discordAvatarUrl,
|
||||
discordGameUrl,
|
||||
formatSpotifyList,
|
||||
LANYARD_WS,
|
||||
lanyardUrl,
|
||||
spotifyTrackUrl,
|
||||
type DiscordAppInfo,
|
||||
type DiscordStatus,
|
||||
type LanyardActivity,
|
||||
type LanyardData,
|
||||
@@ -19,12 +26,68 @@ const STATUS_DOT: Record<DiscordStatus, string> = {
|
||||
offline: "bg-zinc-500",
|
||||
};
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
const WS_OPEN_TIMEOUT_MS = 10_000;
|
||||
const RECONNECT_BASE_MS = 2_000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
const SAFETY_POLL_MS = 60_000;
|
||||
|
||||
function formatElapsed(start: number): string {
|
||||
function formatTrackTime(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function SpotifyProgress({ start, end }: { start: number; end: number }) {
|
||||
const [now, setNow] = useState(start);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const total = Math.max(1, end - start);
|
||||
const ratio = Math.min(1, Math.max(0, (now - start) / total));
|
||||
const percent = Math.round(ratio * 100);
|
||||
return (
|
||||
<div className="mt-1.5 flex max-w-60 items-center gap-1.5 font-mono text-[10px] text-zinc-500">
|
||||
<span>{formatTrackTime(now - start)}</span>
|
||||
<div
|
||||
className="h-[3px] flex-1 overflow-hidden rounded-full bg-white/10"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
>
|
||||
<div className="h-full rounded-full bg-zinc-300" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<span>{formatTrackTime(total)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatElapsed(start: number, lang: string): string {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (lang === "tr") {
|
||||
if (hours > 0) {
|
||||
return `${hours}s ${minutes}dk`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}dk`;
|
||||
}
|
||||
return "<1dk";
|
||||
}
|
||||
if (lang === "de") {
|
||||
if (hours > 0) {
|
||||
return `${hours} Std. ${minutes} Min.`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes} Min.`;
|
||||
}
|
||||
return "<1 Min.";
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
@@ -34,9 +97,58 @@ function formatElapsed(start: number): string {
|
||||
return "<1m";
|
||||
}
|
||||
|
||||
function ActivityRow({ activity }: { activity: LanyardActivity }) {
|
||||
const large = activityImageUrl(activity, false);
|
||||
const discordAppCache = new Map<string, DiscordAppInfo | null>();
|
||||
|
||||
function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo | null {
|
||||
const [info, setInfo] = useState<DiscordAppInfo | null>(() =>
|
||||
applicationId ? (discordAppCache.get(applicationId) ?? null) : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!applicationId || discordAppCache.has(applicationId)) {
|
||||
return;
|
||||
}
|
||||
const id = applicationId;
|
||||
let cancelled = false;
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/discord-app/${id}`);
|
||||
const json = (await response.json()) as DiscordAppInfo | { error: string };
|
||||
const resolved = response.ok && "iconUrl" in json ? (json as DiscordAppInfo) : null;
|
||||
discordAppCache.set(id, resolved);
|
||||
if (!cancelled) {
|
||||
setInfo(resolved);
|
||||
}
|
||||
} catch {
|
||||
discordAppCache.set(id, null);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applicationId]);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
function ActivityRow({
|
||||
activity,
|
||||
dict,
|
||||
locale,
|
||||
}: {
|
||||
activity: LanyardActivity;
|
||||
dict: Dictionary["discord"];
|
||||
locale: string;
|
||||
}) {
|
||||
const appInfo = useDiscordApp(activity.application_id);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [heroSeed, setHeroSeed] = useState(0);
|
||||
const large = activityImageUrl(activity, false) ?? appInfo?.iconUrl ?? null;
|
||||
const small = activityImageUrl(activity, true);
|
||||
const gameUrl = activity.application_id
|
||||
? (appInfo?.gameUrl ?? discordGameUrl(activity.application_id))
|
||||
: null;
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3">
|
||||
{large ? (
|
||||
@@ -58,8 +170,15 @@ function ActivityRow({ activity }: { activity: LanyardActivity }) {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
) : (
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-blurple/20 text-xl font-bold text-blurple-soft"
|
||||
>
|
||||
{activity.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{activity.name}</p>
|
||||
{activity.details ? (
|
||||
<p className="truncate text-sm text-zinc-300">{activity.details}</p>
|
||||
@@ -67,41 +186,204 @@ function ActivityRow({ activity }: { activity: LanyardActivity }) {
|
||||
{activity.state ? (
|
||||
<p className="truncate text-sm text-zinc-500">{activity.state}</p>
|
||||
) : null}
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5">
|
||||
{activity.timestamps?.start ? (
|
||||
<p className="mt-0.5 font-mono text-xs text-zinc-500">
|
||||
{formatElapsed(activity.timestamps.start)}
|
||||
</p>
|
||||
<span className="font-mono text-xs text-zinc-500" suppressHydrationWarning>
|
||||
{formatElapsed(activity.timestamps.start, locale)}
|
||||
</span>
|
||||
) : null}
|
||||
{gameUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHeroSeed(Date.now());
|
||||
setProfileOpen(true);
|
||||
}}
|
||||
className="cursor-pointer text-xs font-medium text-blurple-soft transition-colors hover:text-white hover:underline hover:underline-offset-4"
|
||||
>
|
||||
{dict.gameProfile} ↗
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{profileOpen && activity.application_id ? (
|
||||
<GameProfileModal
|
||||
applicationId={activity.application_id}
|
||||
heroSeed={heroSeed}
|
||||
lang={locale}
|
||||
iconUrl={appInfo?.iconUrl ?? large}
|
||||
fallbackName={activity.name}
|
||||
fallbackGameUrl={gameUrl ?? ""}
|
||||
dict={dict.gameModal}
|
||||
onClose={() => setProfileOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscordCard({
|
||||
initial,
|
||||
dict,
|
||||
locale,
|
||||
}: {
|
||||
initial: LanyardData | null;
|
||||
dict: Dictionary["discord"];
|
||||
locale: string;
|
||||
}) {
|
||||
const [data, setData] = useState<LanyardData | null>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
async function poll(): Promise<void> {
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
let heartbeatTimer: number | undefined;
|
||||
let reconnectTimer: number | undefined;
|
||||
let reconnectDelay = RECONNECT_BASE_MS;
|
||||
|
||||
async function pollOnce(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(lanyardUrl(), { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const json = (await response.json()) as { success: boolean; data: LanyardData };
|
||||
if (json.success) {
|
||||
const json = (await response.json()) as {
|
||||
success: boolean;
|
||||
data: LanyardData;
|
||||
};
|
||||
if (json.success && !disposed) {
|
||||
setData(json.data);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
function tick(): void {
|
||||
void poll().catch(() => undefined);
|
||||
}
|
||||
const timer = window.setInterval(tick, POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
function dropSocket(current: WebSocket): void {
|
||||
if (socket === current) {
|
||||
socket = null;
|
||||
}
|
||||
try {
|
||||
current.close();
|
||||
} catch {
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
let current: WebSocket;
|
||||
try {
|
||||
current = new WebSocket(LANYARD_WS);
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
socket = current;
|
||||
let settled = false;
|
||||
|
||||
const openTimeout = window.setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
dropSocket(current);
|
||||
if (!disposed) {
|
||||
void pollOnce();
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}, WS_OPEN_TIMEOUT_MS);
|
||||
|
||||
const handleDrop = (): void => {
|
||||
if (settled && socket !== current) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
window.clearTimeout(openTimeout);
|
||||
window.clearInterval(heartbeatTimer);
|
||||
dropSocket(current);
|
||||
if (!disposed) {
|
||||
void pollOnce();
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
current.onmessage = (event: MessageEvent) => {
|
||||
let message: { op: number; t?: string; d?: unknown };
|
||||
try {
|
||||
message = JSON.parse(event.data as string) as {
|
||||
op: number;
|
||||
t?: string;
|
||||
d?: unknown;
|
||||
};
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.op === 1) {
|
||||
settled = true;
|
||||
window.clearTimeout(openTimeout);
|
||||
reconnectDelay = RECONNECT_BASE_MS;
|
||||
const interval =
|
||||
(message.d as { heartbeat_interval?: number } | undefined)
|
||||
?.heartbeat_interval ?? 30_000;
|
||||
window.clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = window.setInterval(() => {
|
||||
try {
|
||||
current.send(JSON.stringify({ op: 3 }));
|
||||
} catch {
|
||||
handleDrop();
|
||||
}
|
||||
}, interval);
|
||||
current.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_ID } }));
|
||||
} else if (
|
||||
message.op === 0 &&
|
||||
(message.t === "INIT_STATE" || message.t === "PRESENCE_UPDATE")
|
||||
) {
|
||||
if (!disposed) {
|
||||
setData(message.d as LanyardData);
|
||||
}
|
||||
}
|
||||
};
|
||||
current.onerror = handleDrop;
|
||||
current.onclose = handleDrop;
|
||||
}
|
||||
|
||||
connect();
|
||||
const safetyTimer = window.setInterval(() => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
void pollOnce();
|
||||
}
|
||||
}, SAFETY_POLL_MS);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(reconnectTimer);
|
||||
window.clearInterval(safetyTimer);
|
||||
window.clearInterval(heartbeatTimer);
|
||||
if (socket) {
|
||||
const current = socket;
|
||||
socket = null;
|
||||
current.onclose = null;
|
||||
current.onerror = null;
|
||||
current.onmessage = null;
|
||||
try {
|
||||
current.close();
|
||||
} catch {
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!data) {
|
||||
@@ -166,7 +448,7 @@ export function DiscordCard({
|
||||
{dict.playing}
|
||||
</p>
|
||||
{games.map((game) => (
|
||||
<ActivityRow key={game.id} activity={game} />
|
||||
<ActivityRow key={game.id} activity={game} dict={dict} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -176,20 +458,37 @@ export function DiscordCard({
|
||||
{data.spotify.album_art_url ? (
|
||||
<Image
|
||||
src={data.spotify.album_art_url}
|
||||
alt={data.spotify.album}
|
||||
alt={formatSpotifyList(data.spotify.song)}
|
||||
width={48}
|
||||
height={48}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-mint">
|
||||
{dict.listening} Spotify
|
||||
</p>
|
||||
<p className="truncate text-sm font-semibold">{data.spotify.song}</p>
|
||||
<p className="truncate text-sm text-zinc-400">
|
||||
{data.spotify.artist} — {data.spotify.album}
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{formatSpotifyList(data.spotify.song)}
|
||||
</p>
|
||||
<p className="truncate text-sm text-zinc-400">
|
||||
{formatSpotifyList(data.spotify.artist)}
|
||||
</p>
|
||||
{data.spotify.timestamps ? (
|
||||
<SpotifyProgress
|
||||
key={`${data.spotify.track_id}-${data.spotify.timestamps.start}`}
|
||||
start={data.spotify.timestamps.start}
|
||||
end={data.spotify.timestamps.end}
|
||||
/>
|
||||
) : null}
|
||||
<a
|
||||
href={spotifyTrackUrl(data.spotify.track_id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-block text-xs font-medium text-blurple-soft transition-colors hover:text-white"
|
||||
>
|
||||
{dict.openOnSpotify} ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GamePricePoint, GameProfile } from "@/lib/game-profile";
|
||||
import { steamdbRating } from "@/lib/game-profile";
|
||||
import type { Dictionary } from "@/lib/i18n/get-dictionary";
|
||||
|
||||
type ModalDict = Dictionary["discord"]["gameModal"];
|
||||
|
||||
const STEAM_SCORE_KEYS: Record<string, keyof ModalDict["reviewScores"]> = {
|
||||
"Overwhelmingly Positive": "overwhelminglyPositive",
|
||||
"Very Positive": "veryPositive",
|
||||
Positive: "positive",
|
||||
"Mostly Positive": "mostlyPositive",
|
||||
Mixed: "mixed",
|
||||
"Mostly Negative": "mostlyNegative",
|
||||
Negative: "negative",
|
||||
"Very Negative": "veryNegative",
|
||||
"Overwhelmingly Negative": "overwhelminglyNegative",
|
||||
};
|
||||
|
||||
const LOCALE_TAGS: Record<string, string> = {
|
||||
tr: "tr-TR",
|
||||
en: "en-US",
|
||||
de: "de-DE",
|
||||
};
|
||||
|
||||
const COUNTRIES: Record<string, string> = {
|
||||
tr: "TR",
|
||||
en: "US",
|
||||
de: "DE",
|
||||
};
|
||||
|
||||
function localizeScore(label: string, dict: ModalDict): string {
|
||||
const key = STEAM_SCORE_KEYS[label];
|
||||
return key ? dict.reviewScores[key] : label;
|
||||
}
|
||||
|
||||
function formatRatingPercent(value: number, tag: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(tag, {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return `%${(value * 100).toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPrice(point: GamePricePoint, tag: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(tag, {
|
||||
style: "currency",
|
||||
currency: point.currency,
|
||||
}).format(point.price);
|
||||
} catch {
|
||||
return `${point.price} ${point.currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatReleaseDate(value: string, tag: string): string {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed.toLocaleDateString(tag, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatCount(value: number | null | undefined, tag: string, unit: string): string {
|
||||
if (value == null) {
|
||||
return "—";
|
||||
}
|
||||
return `${value.toLocaleString(tag)} ${unit}`;
|
||||
}
|
||||
|
||||
function discountPercent(point: GamePricePoint): number | null {
|
||||
if (point.regular > point.price && point.regular > 0) {
|
||||
return Math.round((1 - point.price / point.regular) * 100);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function GameProfileModal({
|
||||
applicationId,
|
||||
heroSeed,
|
||||
lang,
|
||||
iconUrl,
|
||||
fallbackName,
|
||||
fallbackGameUrl,
|
||||
dict,
|
||||
onClose,
|
||||
}: {
|
||||
applicationId: string;
|
||||
heroSeed: number;
|
||||
lang: string;
|
||||
iconUrl: string | null;
|
||||
fallbackName: string;
|
||||
fallbackGameUrl: string;
|
||||
dict: ModalDict;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [profile, setProfile] = useState<GameProfile | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [bannerPick, setBannerPick] = useState<string | null>(null);
|
||||
const tag = LOCALE_TAGS[lang] ?? "en-US";
|
||||
const country = COUNTRIES[lang] ?? "US";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/game/${applicationId}?lang=${lang}&country=${country}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`game api: ${response.status}`);
|
||||
}
|
||||
const json = (await response.json()) as GameProfile;
|
||||
if (!cancelled) {
|
||||
setProfile(json);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applicationId, lang, country]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(event: KeyboardEvent): void {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
const previous = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = previous;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const gameUrl = profile?.gameUrl ?? fallbackGameUrl;
|
||||
const name = profile?.name ?? fallbackName;
|
||||
const best = profile?.itad?.prices ?? null;
|
||||
const low = profile?.itad?.historyLow ?? null;
|
||||
const hero = useMemo(() => {
|
||||
if (bannerPick) {
|
||||
return bannerPick;
|
||||
}
|
||||
const shots = profile?.screenshots ?? [];
|
||||
if (shots.length > 0) {
|
||||
return shots[heroSeed % shots.length] ?? null;
|
||||
}
|
||||
return profile?.artworkUrl ?? null;
|
||||
}, [profile, heroSeed, bannerPick]);
|
||||
|
||||
const dbRating =
|
||||
profile?.steamReviews?.positive != null && profile?.steamReviews?.total != null
|
||||
? steamdbRating(profile.steamReviews.positive, profile.steamReviews.total)
|
||||
: null;
|
||||
const itadReviews = profile?.itad?.reviews ?? [];
|
||||
const hasDetails = !!(
|
||||
profile &&
|
||||
(profile.developers.length > 0 ||
|
||||
profile.publishers.length > 0 ||
|
||||
profile.releaseDate ||
|
||||
profile.platforms.length > 0 ||
|
||||
profile.links.length > 0)
|
||||
);
|
||||
const metascore = itadReviews.find((r) => r.source === "Metascore") ?? null;
|
||||
const opencritic = itadReviews.find((r) => r.source === "OpenCritic") ?? null;
|
||||
const hasReviews = !!(
|
||||
profile?.steamReviews ||
|
||||
(dbRating != null && profile?.steamId) ||
|
||||
metascore ||
|
||||
opencritic
|
||||
);
|
||||
|
||||
const stripRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) {
|
||||
return;
|
||||
}
|
||||
function onWheel(event: WheelEvent): void {
|
||||
const el = stripRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
|
||||
return;
|
||||
}
|
||||
if (el.scrollWidth <= el.clientWidth + 1) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
el.scrollLeft += event.deltaY;
|
||||
}
|
||||
strip.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => strip.removeEventListener("wheel", onWheel);
|
||||
}, [profile]);
|
||||
|
||||
function scrollStrip(by: number): void {
|
||||
stripRef.current?.scrollBy({ left: by, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-4 backdrop-blur-sm sm:items-center"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={name}
|
||||
className="modal-scroll max-h-[85vh] w-full max-w-4xl overflow-y-auto rounded-3xl border border-white/10 bg-panel shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{hero ? (
|
||||
<div className="relative h-48 w-full shrink-0 sm:h-64">
|
||||
<Image
|
||||
src={hero}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 896px) 100vw, 896px"
|
||||
className="rounded-t-3xl object-cover"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 rounded-t-3xl bg-gradient-to-t from-panel via-panel/30 to-transparent" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{iconUrl ? (
|
||||
<Image
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
className="shrink-0 rounded-2xl border border-white/10"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-blurple/20 text-2xl font-bold text-blurple-soft"
|
||||
>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-xl font-bold">{name}</h2>
|
||||
{profile && profile.genres.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{profile.genres.map((genre) => (
|
||||
<span
|
||||
key={genre}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-zinc-300"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="shrink-0 rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
|
||||
>
|
||||
{dict.close}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!profile && !failed ? (
|
||||
<p className="mt-4 animate-pulse text-sm text-zinc-500">{dict.loading}</p>
|
||||
) : null}
|
||||
{failed && !profile ? (
|
||||
<p className="mt-4 text-sm text-zinc-500">{dict.loadError}</p>
|
||||
) : null}
|
||||
|
||||
{profile?.description ? (
|
||||
<p className="mt-4 text-sm leading-relaxed text-zinc-300">
|
||||
{profile.description}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{profile?.steamUrl ? (
|
||||
<a
|
||||
href={profile.steamUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full bg-blurple px-4 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-blurple-soft hover:text-void"
|
||||
>
|
||||
{dict.openOnSteam} ↗
|
||||
</a>
|
||||
) : null}
|
||||
{profile?.steamId ? (
|
||||
<a
|
||||
href={`https://steamdb.info/app/${profile.steamId}/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
|
||||
>
|
||||
{dict.openOnSteamDb} ↗
|
||||
</a>
|
||||
) : null}
|
||||
<a
|
||||
href={gameUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
|
||||
>
|
||||
{dict.openOnDiscord} ↗
|
||||
</a>
|
||||
{profile?.itad?.pageUrl ? (
|
||||
<a
|
||||
href={profile.itad.pageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full border border-white/15 px-4 py-1.5 text-xs text-zinc-300 transition-colors hover:border-blurple hover:text-white"
|
||||
>
|
||||
{dict.allPrices} ↗
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasDetails ? (
|
||||
<dl className="mt-5 grid gap-x-6 gap-y-2 rounded-2xl border border-white/5 bg-white/[0.03] p-4 text-sm md:grid-cols-2">
|
||||
{profile.developers.length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||||
<dt className="shrink-0 text-zinc-500 sm:w-36">
|
||||
{dict.developers}
|
||||
</dt>
|
||||
<dd className="text-zinc-200">
|
||||
{profile.developers.join(", ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{profile.publishers.length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||||
<dt className="shrink-0 text-zinc-500 sm:w-36">
|
||||
{dict.publishers}
|
||||
</dt>
|
||||
<dd className="text-zinc-200">
|
||||
{profile.publishers.join(", ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{profile.releaseDate ? (
|
||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||||
<dt className="shrink-0 text-zinc-500 sm:w-36">
|
||||
{dict.releaseDate}
|
||||
</dt>
|
||||
<dd className="text-zinc-200">
|
||||
{formatReleaseDate(profile.releaseDate, tag)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{profile.platforms.length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
|
||||
<dt className="shrink-0 text-zinc-500 sm:w-36">
|
||||
{dict.platforms}
|
||||
</dt>
|
||||
<dd className="text-zinc-200">
|
||||
{profile.platforms.join(" · ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{profile.links.length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3 md:col-span-2">
|
||||
<dt className="shrink-0 text-zinc-500 sm:w-36">{dict.links}</dt>
|
||||
<dd className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{profile.links.slice(0, 8).map((link) => (
|
||||
<a
|
||||
key={link.url}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blurple-soft transition-colors hover:text-white"
|
||||
>
|
||||
{link.label} ↗
|
||||
</a>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
) : null}
|
||||
|
||||
{profile && hasReviews ? (
|
||||
<div className="mt-4">
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||||
{dict.reviews}
|
||||
</p>
|
||||
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
|
||||
{profile.steamReviews ? (
|
||||
<a
|
||||
href={profile.steamReviews.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||||
>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{dict.totalReviews}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{localizeScore(profile.steamReviews.label, dict)}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{formatCount(
|
||||
profile.steamReviews.total,
|
||||
tag,
|
||||
dict.reviewsUnit,
|
||||
)}
|
||||
</p>
|
||||
</a>
|
||||
) : null}
|
||||
{dbRating != null && profile.steamId ? (
|
||||
<a
|
||||
href={`https://steamdb.info/app/${profile.steamId}/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||||
>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{dict.steamdbRating}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{formatRatingPercent(dbRating, tag)}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
steamdb.info ↗
|
||||
</p>
|
||||
</a>
|
||||
) : null}
|
||||
{metascore ? (
|
||||
<a
|
||||
href={metascore.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||||
>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
Metacritic
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{metascore.score ?? "—"}
|
||||
<span className="font-normal text-zinc-500">/100</span>
|
||||
</p>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{formatCount(metascore.count, tag, dict.reviewsUnit)}
|
||||
</p>
|
||||
</a>
|
||||
) : null}
|
||||
{opencritic ? (
|
||||
<a
|
||||
href={opencritic.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:border-white/15"
|
||||
>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
OpenCritic
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-semibold">
|
||||
{opencritic.score ?? "—"}
|
||||
<span className="font-normal text-zinc-500">/100</span>
|
||||
</p>
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{formatCount(opencritic.count, tag, dict.reviewsUnit)}
|
||||
</p>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{profile?.itad && (best || low) ? (
|
||||
<div className="mt-4">
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||||
{dict.prices}
|
||||
</p>
|
||||
<div className="mt-1.5 grid gap-2 sm:grid-cols-2">
|
||||
{best ? (
|
||||
<a
|
||||
href={best.url || undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border border-mint/20 bg-mint/5 p-3 transition-colors hover:border-mint/40"
|
||||
>
|
||||
<p className="font-mono text-xs text-mint">
|
||||
{dict.bestPrice} · {best.shop}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-bold">
|
||||
{formatPrice(best, tag)}{" "}
|
||||
{discountPercent(best) ? (
|
||||
<span className="ml-1 rounded bg-mint/15 px-1.5 py-0.5 align-middle font-mono text-xs text-mint">
|
||||
-%{discountPercent(best)}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</a>
|
||||
) : null}
|
||||
{low ? (
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.03] p-3">
|
||||
<p className="font-mono text-xs text-zinc-500">
|
||||
{dict.historyLow} · {low.shop}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-bold text-zinc-200">
|
||||
{formatPrice(low, tag)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{profile && profile.screenshots.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-zinc-500">
|
||||
{dict.screenshots}
|
||||
</p>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollStrip(-360)}
|
||||
aria-label={dict.scrollBack}
|
||||
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollStrip(360)}
|
||||
aria-label={dict.scrollForward}
|
||||
className="rounded-full border border-white/10 px-2.5 py-0.5 text-xs text-zinc-400 transition-colors hover:border-white/25 hover:text-white"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={stripRef}
|
||||
className="no-scrollbar mt-1.5 flex gap-2 overflow-x-auto p-1"
|
||||
>
|
||||
{profile.screenshots.map((shot) => (
|
||||
<button
|
||||
key={shot}
|
||||
type="button"
|
||||
onClick={() => setBannerPick(shot)}
|
||||
aria-label={dict.screenshots}
|
||||
className={`shrink-0 overflow-hidden rounded-xl transition-all ${
|
||||
hero === shot
|
||||
? "ring-2 ring-blurple"
|
||||
: "opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={shot}
|
||||
alt=""
|
||||
width={320}
|
||||
height={180}
|
||||
loading="lazy"
|
||||
className="h-auto w-56"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<DetectableEntry[]> {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
tr: "turkish",
|
||||
en: "english",
|
||||
de: "german",
|
||||
};
|
||||
|
||||
async function getSteamDetails(
|
||||
steamId: string,
|
||||
lang = "en",
|
||||
): Promise<SteamAppDetails["data"] | null> {
|
||||
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<string, SteamAppDetails>;
|
||||
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<GameReviewSummary | null> {
|
||||
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<string | null> {
|
||||
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<T>(token: string, endpoint: string, body: string): Promise<T | null> {
|
||||
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<IgdbGame | null> {
|
||||
const token = await getIgdbToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
if (steamId) {
|
||||
const external = await igdbQuery<Array<{ game?: number }>>(
|
||||
token,
|
||||
"external_games",
|
||||
`fields game; where uid = "${steamId}"; limit 1;`,
|
||||
);
|
||||
const gameId = external?.[0]?.game;
|
||||
if (gameId) {
|
||||
const games = await igdbQuery<IgdbGame[]>(
|
||||
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<IgdbGame[]>(
|
||||
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<number, string> = {
|
||||
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<T>(path: string, init?: RequestInit): Promise<T | null> {
|
||||
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<string | null> {
|
||||
const results = await itadFetch<Array<{ id?: string; title?: string }>>(
|
||||
`/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<GameProfile | null> {
|
||||
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<ItadGameInfo>(`/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<string>();
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
+2
-1
@@ -53,7 +53,8 @@ export async function getRepos(): Promise<GiteaRepo[]> {
|
||||
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 [];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<DiscordAppInfo | null> {
|
||||
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;
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user