Files
fristydev/components/game-profile-modal.tsx
fristy ed149ded52
CI / Code Health (pull_request) Successful in 24s
CI / Lint, Test & Build (pull_request) Successful in 59s
fix: prettier formatting and downgrade upload-artifact to v3 for Gitea
2026-09-12 00:47:38 +03:00

315 lines
15 KiB
TypeScript

"use client";
import Image from "next/image";
import { useEffect } from "react";
import { ModalPrices, ModalReviews, ModalScreenshots } from "@/components/game-profile-sections";
import { formatReleaseDate, selectGameView, type ModalDict } from "@/lib/game/game-format";
import { steamdbAppUrl } from "@/lib/game/discord-app";
import type { Locale } from "@/lib/i18n/locales";
import { useGameProfile } from "@/hooks/use-game-profile";
export function GameProfileModal({
applicationId,
heroSeed,
lang,
iconUrl,
fallbackName,
fallbackGameUrl,
dict,
onClose,
}: {
applicationId: string;
heroSeed: number;
lang: Locale;
iconUrl: string | null;
fallbackName: string;
fallbackGameUrl: string;
dict: ModalDict;
onClose: () => void;
}) {
const {
profile,
failed,
localeTag,
hero,
hasDetails,
partialFail,
isDeterministicMiss,
canRetry,
retry,
reset,
setBannerPick,
} = useGameProfile(applicationId, lang, heroSeed);
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, name, bestPrice, historyLow, isFree } = selectGameView(
profile,
fallbackName,
fallbackGameUrl,
);
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 ? (
<div className="mt-4 flex items-center gap-3 text-sm text-zinc-500">
<p>{dict.loadError}</p>
<button
type="button"
onClick={retry}
disabled={!canRetry}
className="shrink-0 cursor-pointer rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300 transition-colors hover:border-white/25 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
{dict.retry}
</button>
</div>
) : null}
{isDeterministicMiss ? (
<div className="mt-4 flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3 text-sm text-zinc-400">
<p className="flex-1">{dict.noGameData}</p>
</div>
) : partialFail ? (
<div className="mt-4 flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3 text-sm text-zinc-400">
<p className="flex-1">{dict.partialFail}</p>
<button
type="button"
onClick={reset}
disabled={!canRetry}
className="shrink-0 cursor-pointer rounded-full border border-white/10 px-3 py-1 text-xs text-zinc-300 transition-colors hover:border-white/25 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
{dict.retry}
</button>
</div>
) : 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={steamdbAppUrl(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}
{profile?.igdbUrl ? (
<a
href={profile.igdbUrl}
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.openOnIgdb} ↗
</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>
{profile && 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, localeTag)}
</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 ? (
<ModalReviews profile={profile} dict={dict} localeTag={localeTag} />
) : null}
{profile?.itad && (bestPrice || historyLow) ? (
<ModalPrices
dict={dict}
localeTag={localeTag}
bestPrice={bestPrice}
historyLow={historyLow}
isFree={isFree}
/>
) : null}
{profile && profile.screenshots.length > 0 ? (
<ModalScreenshots
profile={profile}
dict={dict}
hero={hero}
onPick={setBannerPick}
/>
) : null}
</div>
</div>
</div>
);
}