);
@@ -34,8 +35,8 @@ export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) {
if (lastSyncedAt) {
return (
-
-
+
+
{t('LIBRARY.LAST_SYNCED', {when: formatRelative(lastSyncedAt, i18n.language)})}
);
diff --git a/src/components/ui/alert.jsx b/src/components/ui/alert.jsx
new file mode 100644
index 0000000..607c06c
--- /dev/null
+++ b/src/components/ui/alert.jsx
@@ -0,0 +1,68 @@
+import * as React from "react"
+import { cva } from "class-variance-authority";
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function AlertTitle({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/src/components/ui/badge.jsx b/src/components/ui/badge.jsx
new file mode 100644
index 0000000..878ca5a
--- /dev/null
+++ b/src/components/ui/badge.jsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import { cva } from "class-variance-authority";
+import { cn } from "@/lib/utils"
+import { Slot } from "radix-ui"
+
+const badgeVariants = cva(
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
+ outline:
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ asChild = false,
+ ...props
+}) {
+ const Comp = asChild ? Slot.Root : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/button.jsx b/src/components/ui/button.jsx
new file mode 100644
index 0000000..e744b1c
--- /dev/null
+++ b/src/components/ui/button.jsx
@@ -0,0 +1,60 @@
+import * as React from "react"
+import { cva } from "class-variance-authority";
+import { cn } from "@/lib/utils"
+import { Slot } from "radix-ui"
+
+const buttonVariants = cva(
+ "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/src/components/ui/card.jsx b/src/components/ui/card.jsx
new file mode 100644
index 0000000..526c1b5
--- /dev/null
+++ b/src/components/ui/card.jsx
@@ -0,0 +1,112 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+function Card({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function CardHeader({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function CardTitle({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function CardDescription({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function CardAction({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function CardContent({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function CardFooter({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/components/ui/dropdown-menu.jsx b/src/components/ui/dropdown-menu.jsx
new file mode 100644
index 0000000..daacdf6
--- /dev/null
+++ b/src/components/ui/dropdown-menu.jsx
@@ -0,0 +1,249 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
+
+function DropdownMenu({
+ ...props
+}) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/src/components/ui/input.jsx b/src/components/ui/input.jsx
new file mode 100644
index 0000000..292ed7d
--- /dev/null
+++ b/src/components/ui/input.jsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+function Input({
+ className,
+ type,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/components/ui/label.jsx b/src/components/ui/label.jsx
new file mode 100644
index 0000000..e531796
--- /dev/null
+++ b/src/components/ui/label.jsx
@@ -0,0 +1,21 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { Label as LabelPrimitive } from "radix-ui"
+
+function Label({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/src/components/ui/select.jsx b/src/components/ui/select.jsx
new file mode 100644
index 0000000..e0a5d56
--- /dev/null
+++ b/src/components/ui/select.jsx
@@ -0,0 +1,187 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+import { Select as SelectPrimitive } from "radix-ui"
+
+function Select({
+ ...props
+}) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}) {
+ return
+}
+
+function SelectValue({
+ ...props
+}) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/src/components/ui/separator.jsx b/src/components/ui/separator.jsx
new file mode 100644
index 0000000..7effbfc
--- /dev/null
+++ b/src/components/ui/separator.jsx
@@ -0,0 +1,25 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/skeleton.jsx b/src/components/ui/skeleton.jsx
new file mode 100644
index 0000000..144a97a
--- /dev/null
+++ b/src/components/ui/skeleton.jsx
@@ -0,0 +1,16 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({
+ className,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/src/components/ui/sonner.jsx b/src/components/ui/sonner.jsx
new file mode 100644
index 0000000..aaa89c6
--- /dev/null
+++ b/src/components/ui/sonner.jsx
@@ -0,0 +1,43 @@
+"use client"
+
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+} from "lucide-react"
+import { usePreferences } from "@/context/PreferencesContext.jsx"
+import { Toaster as Sonner } from "sonner";
+
+const Toaster = ({
+ ...props
+}) => {
+ const { isDarkMode } = usePreferences()
+ const theme = isDarkMode ? "dark" : "light"
+
+ return (
+
,
+ info:
,
+ warning:
,
+ error:
,
+ loading:
,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)"
+ }
+ }
+ {...props}
+ />
+ );
+}
+
+export { Toaster }
diff --git a/src/components/ui/switch.jsx b/src/components/ui/switch.jsx
new file mode 100644
index 0000000..67f4af2
--- /dev/null
+++ b/src/components/ui/switch.jsx
@@ -0,0 +1,30 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { Switch as SwitchPrimitive } from "radix-ui"
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}) {
+ return (
+
+
+
+ )
+}
+
+export { Switch }
diff --git a/src/components/ui/tooltip.jsx b/src/components/ui/tooltip.jsx
new file mode 100644
index 0000000..5d82fd5
--- /dev/null
+++ b/src/components/ui/tooltip.jsx
@@ -0,0 +1,54 @@
+import * as React from "react"
+import { cn } from "@/lib/utils"
+import { Tooltip as TooltipPrimitive } from "radix-ui"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}) {
+ return
+}
+
+function TooltipTrigger({
+ ...props
+}) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/src/hooks/useForceSync.js b/src/hooks/useForceSync.js
index 8b7fdd5..6add492 100644
--- a/src/hooks/useForceSync.js
+++ b/src/hooks/useForceSync.js
@@ -1,18 +1,18 @@
import {useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
+import {toast} from 'sonner';
const RELOAD_DELAY_MS = 4000;
/**
- * Starts a library sync (POST /api/library/sync) and calls onStarted after a short delay,
- * so the caller can reload once Steam data has arrived.
+ * Starts a library sync (POST /api/library/sync), reports the result as a toast and calls
+ * onStarted after a short delay, so the caller can reload once Steam data has arrived.
*/
export default function useForceSync(onStarted) {
const {t} = useTranslation();
const navigate = useNavigate();
const [busy, setBusy] = useState(false);
- const [message, setMessage] = useState(null);
const trigger = async () => {
const token = localStorage.getItem('tbp_jwt');
@@ -21,7 +21,6 @@ export default function useForceSync(onStarted) {
return;
}
setBusy(true);
- setMessage(null);
try {
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
method: 'POST',
@@ -29,22 +28,22 @@ export default function useForceSync(onStarted) {
});
if (response.status === 429) {
const json = await response.json().catch(() => ({}));
- setMessage(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60}));
+ toast.warning(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60}));
return;
}
if (!response.ok) {
- setMessage(t('LIBRARY.SYNC_FAILED'));
+ toast.error(t('LIBRARY.SYNC_FAILED'));
return;
}
- setMessage(t('LIBRARY.SYNC_STARTED'));
+ toast.success(t('LIBRARY.SYNC_STARTED'));
setTimeout(onStarted, RELOAD_DELAY_MS);
} catch (err) {
console.error('sync trigger error:', err);
- setMessage(t('INFO.ERROR.NETWORK_FAILURE'));
+ toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
- return {trigger, busy, message};
+ return {trigger, busy};
}
diff --git a/src/lib/utils.js b/src/lib/utils.js
new file mode 100644
index 0000000..c3c6060
--- /dev/null
+++ b/src/lib/utils.js
@@ -0,0 +1,6 @@
+import {clsx} from 'clsx';
+import {twMerge} from 'tailwind-merge';
+
+export function cn(...inputs) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/main.jsx b/src/main.jsx
index eb86d6b..27c38a3 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -5,7 +5,6 @@ import App from './App.jsx';
import {PreferencesProvider} from './context/PreferencesContext.jsx';
import './i18n';
import './style.css';
-import '@mdi/font/css/materialdesignicons.css';
createRoot(document.getElementById('root')).render(
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx
index 3bb37ea..1274b26 100644
--- a/src/pages/Dashboard.jsx
+++ b/src/pages/Dashboard.jsx
@@ -1,18 +1,23 @@
import React, {useCallback, useEffect, useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
+import {ArrowRight, Hash, Link2, RefreshCw, Trophy} from 'lucide-react';
+
+import {Badge} from '@/components/ui/badge';
+import {Button} from '@/components/ui/button';
+import {Card} from '@/components/ui/card';
+import {Skeleton} from '@/components/ui/skeleton';
+import GameCover from '../components/GameCover.jsx';
import SteamGameCount from '../components/SteamGameCount';
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
import useLibrarySync from '../hooks/useLibrarySync.js';
import useForceSync from '../hooks/useForceSync.js';
-import {formatPlaytime, iconUrl} from '../utils/games.js';
+import {formatPlaytime} from '../utils/games.js';
const TOP_GAMES = 5;
// The games endpoint accepts only the page sizes 20, 40 and 80.
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
-const cardClass = 'bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700';
-
const authHeaders = () => ({
'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`,
'Content-Type': 'application/json',
@@ -101,120 +106,123 @@ const Dashboard = () => {
const forceSync = useForceSync(reloadLibrary);
if (isLoading) {
- return (
-
- {t('DASHBOARD.LOADING')}
-
- );
+ return ;
}
if (error) {
return (
-
+
{error}
);
}
const syncBusy = forceSync.busy || sync.isRunning;
+ const maxMinutes = topGames[0]?.playtimeMinutes || 1;
return (
-
-
-
+
+
+
+
{t('DASHBOARD.WELCOME_BACK')}
+
{t('DASHBOARD.HELLO')} {userData?.email?.split('@')[0]} 👋
{hasSteamId ? (
-
-
- {t('SETTINGS.STEAM_ID')}: {userData.steamId}
-
+
+
+ {userData.steamId}
+
) : (
-
-
- {t('DASHBOARD.NO_STEAM_ID')}
-
+
)}
{hasSteamId && (
-
+
-
)}
-
+
{hasSteamId && (
-
-
-
-
+
+
+
+
{t('DASHBOARD.MOST_PLAYED')}
-
- {t('DASHBOARD.SHOW_ALL')}
-
+
+ {t('DASHBOARD.SHOW_ALL')}
+
{topGames.length === 0 ? (
-
+
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
) : (
-
- {topGames.map((g, index) => {
- const icon = iconUrl(g.appid, g.imgIconUrl);
- return (
- -
-
-
- {index + 1}.
-
- {icon ? (
-
- ) : (
-
- )}
-
+
+ {topGames.map((g, index) => (
+ -
+
+
+ {index + 1}
+
+
+
+
{g.name ?? `App ${g.appid}`}
-
-
- {formatPlaytime(g.playtimeMinutes)}
-
-
-
- );
- })}
+
+
+
+
+ {formatPlaytime(g.playtimeMinutes)}
+
+
+
+ ))}
)}
-
+
)}
);
};
+function DashboardSkeleton() {
+ return (
+
+ );
+}
+
export default Dashboard;
diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx
index 8fb6601..2463d36 100644
--- a/src/pages/GameDetail.jsx
+++ b/src/pages/GameDetail.jsx
@@ -1,7 +1,15 @@
import {useEffect, useMemo, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
-import {formatPlaytime, iconUrl} from '../utils/games.js';
+import {ArrowLeft, Clock, ExternalLink, ShieldOff, Trophy} from 'lucide-react';
+
+import {Badge} from '@/components/ui/badge';
+import {Button} from '@/components/ui/button';
+import {Card} from '@/components/ui/card';
+import {Skeleton} from '@/components/ui/skeleton';
+import {cn} from '@/lib/utils';
+import GameCover from '../components/GameCover.jsx';
+import {formatPlaytime} from '../utils/games.js';
const formatUnlockedAt = (instant, locale) => {
if (!instant) return '';
@@ -80,169 +88,129 @@ export default function GameDetail() {
});
}, [data]);
+ const stats = data ? [
+ [t('GAME_DETAIL.UNLOCKED'), data.achievementsUnlocked],
+ [t('GAME_DETAIL.REMAINING'), data.achievementsRemaining],
+ [t('GAME_DETAIL.COMPLETION'), `${data.completionPercent}%`],
+ ] : [];
+
return (
-
-
-
navigate(-1)}
- className="inline-flex items-center gap-2 text-sm font-bold text-slate-600 dark:text-slate-300 hover:text-blue-500 transition-colors"
- >
- {t('GAME_DETAIL.BACK')}
-
+
+
navigate(-1)}>
+ {t('GAME_DETAIL.BACK')}
+
- {isLoading && (
-
- {t('GAME_DETAIL.LOADING')}
-
- )}
+ {isLoading && (
+
+
+
+
+ )}
- {error && !isLoading && (
-
- {error}
-
-
- {t('GAME_DETAIL.BACK_TO_LIBRARY')}
-
-
-
- )}
+ {error && !isLoading && (
+
+
{error}
+
{t('GAME_DETAIL.BACK_TO_LIBRARY')}
+
+ )}
- {!isLoading && !error && data && (
- <>
-
- {iconUrl(data.appid, data.imgIconUrl) ? (
-
- ) : (
-
- )}
-
-
+ {!isLoading && !error && data && (
+ <>
+
+
+
+
- {!data.hasAchievements && (
-
- {t('GAME_DETAIL.NO_ACHIEVEMENTS')}
-
- )}
+ {!data.hasAchievements && (
+
+
+ {t('GAME_DETAIL.NO_ACHIEVEMENTS')}
+
+ )}
- {data.hasAchievements && !data.statsVisible && (
-
-
- {t('GAME_DETAIL.STATS_HIDDEN')}
-
- )}
+ {data.hasAchievements && !data.statsVisible && (
+
+
+ {t('GAME_DETAIL.STATS_HIDDEN')}
+
+ )}
- {data.hasAchievements && data.statsVisible && (
- <>
-
-
-
- {t('GAME_DETAIL.ACHIEVEMENTS')}
-
-
- {data.achievementsUnlocked} / {data.achievementsTotal}
- {' · '}
- {data.completionPercent}%
-
-
-
-
-
-
- {data.achievementsUnlocked}
-
-
- {t('GAME_DETAIL.UNLOCKED')}
-
+ {data.hasAchievements && data.statsVisible && (
+ <>
+
+
+
+
+ {t('GAME_DETAIL.ACHIEVEMENTS')}
+
+
+ {data.achievementsUnlocked} / {data.achievementsTotal}
+
+
+
+
+ {stats.map(([label, value]) => (
+
-
-
- {data.achievementsRemaining}
-
-
- {t('GAME_DETAIL.REMAINING')}
-
-
-
-
- {data.completionPercent}%
-
-
- {t('GAME_DETAIL.COMPLETION')}
-
-
-
-
+ ))}
+
+
-
-
- {sortedAchievements.map((a) => (
- -
- {a.unlocked ? (
- a.iconUrl
- ?
- :
- ) : (
- a.iconGrayUrl
- ?
- :
+
+ {sortedAchievements.map((a) => {
+ const icon = a.unlocked ? a.iconUrl : a.iconGrayUrl;
+ return (
+ -
+ {icon
+ ?
+ : }
+
+
{a.displayName ?? a.apiName}
+ {a.description && (
+
{a.description}
)}
-
-
- {a.displayName ?? a.apiName}
+ {a.unlocked && a.unlockedAt && (
+
+ {formatUnlockedAt(a.unlockedAt, i18n.language)}
- {a.description && (
-
- {a.description}
-
- )}
- {a.unlocked && a.unlockedAt && (
-
- {formatUnlockedAt(a.unlockedAt, i18n.language)}
-
- )}
-
-
- ))}
-
-
- >
- )}
- >
- )}
-
+ )}
+
+
+ );
+ })}
+
+ >
+ )}
+ >
+ )}
);
}
diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx
index 6002354..5bb7e37 100644
--- a/src/pages/GameLibrary.jsx
+++ b/src/pages/GameLibrary.jsx
@@ -4,7 +4,15 @@ import {useTranslation} from 'react-i18next';
import useLibrarySync from '../hooks/useLibrarySync.js';
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
import useForceSync from '../hooks/useForceSync.js';
-import {formatPlaytime, iconUrl} from '../utils/games.js';
+import {formatPlaytime} from '../utils/games.js';
+import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw} from 'lucide-react';
+
+import {Badge} from '@/components/ui/badge';
+import {Button} from '@/components/ui/button';
+import {Card} from '@/components/ui/card';
+import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
+import {Skeleton} from '@/components/ui/skeleton';
+import GameCover from '../components/GameCover.jsx';
const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20;
@@ -87,12 +95,12 @@ export default function GameLibrary() {
setSearchParams(params);
};
- const onSizeChange = (event) => {
- updateParams({size: event.target.value, page: 0});
+ const onSizeChange = (value) => {
+ updateParams({size: value, page: 0});
};
- const onSortChange = (event) => {
- updateParams({sort: event.target.value, page: 0});
+ const onSortChange = (value) => {
+ updateParams({sort: value, page: 0});
};
const goToPage = (target) => {
@@ -100,142 +108,117 @@ export default function GameLibrary() {
updateParams({page: target});
};
- const {trigger: triggerSync, busy: syncBusy, message: syncMessage} = useForceSync(load);
+ const {trigger: triggerSync, busy: syncBusy} = useForceSync(load);
+
+ const syncing = syncBusy || sync.isRunning;
+ const ready = !isLoading && !error;
return (
-
-
-
-
-
- {t('LIBRARY.TITLE')}
-
-
-
-
-
- {t('LIBRARY.RESYNC')}
-
- {syncMessage && (
- {syncMessage}
- )}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
{t('LIBRARY.TITLE')}
+
+
+
+
+ {t('LIBRARY.RESYNC')}
+
-
- {isLoading && (
-
- {t('LIBRARY.LOADING')}
-
- )}
-
- {error && (
-
{error}
- )}
-
- {!isLoading && !error && data.content.length === 0 && (
-
- {sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
-
- )}
-
- {!isLoading && !error && data.content.length > 0 && (
-
- {data.content.map((g) => {
- const icon = iconUrl(g.appid, g.imgIconUrl);
- return (
- -
-
- {icon ? (
-
{ e.currentTarget.style.visibility = 'hidden'; }}
- />
- ) : (
-
- )}
-
- {g.name ?? `App ${g.appid}`}
-
-
- {formatPlaytime(g.playtimeMinutes)}
-
-
-
-
- );
- })}
-
- )}
-
- {!isLoading && !error && data.totalPages > 1 && (
-
- goToPage(page - 1)}
- disabled={page <= 0}
- className="px-4 py-2 rounded-lg bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 disabled:opacity-40 hover:border-blue-500 disabled:hover:border-slate-300 dark:disabled:hover:border-slate-600 transition-colors"
- >
- {t('LIBRARY.PREV')}
-
-
- {t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})}
- {' · '}
- {data.totalElements} {t('LIBRARY.GAMES')}
-
- goToPage(page + 1)}
- disabled={page >= data.totalPages - 1}
- className="px-4 py-2 rounded-lg bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 disabled:opacity-40 hover:border-blue-500 disabled:hover:border-slate-300 dark:disabled:hover:border-slate-600 transition-colors"
- >
- {t('LIBRARY.NEXT')}
-
-
- )}
+
+
+
+
+
+ {isLoading && (
+
+ {Array.from({length: 8}, (_, i) => (
+
+
+
+
+
+ ))}
+
+ )}
+
+ {error &&
{error}
}
+
+ {ready && data.content.length === 0 && (
+
+
+
+ {sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
+
+
+ )}
+
+ {ready && data.content.length > 0 && (
+
+
+ {data.content.map((g) => (
+ -
+
+
+
+ {g.name ?? `App ${g.appid}`}
+
+
+
+ {formatPlaytime(g.playtimeMinutes)}
+
+
+
+
+ ))}
+
+
+ )}
+
+ {ready && data.totalPages > 1 && (
+
+ goToPage(page - 1)} disabled={page <= 0}>
+ {t('LIBRARY.PREV')}
+
+
+ {t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})}
+ {' · '}
+ {data.totalElements} {t('LIBRARY.GAMES')}
+
+ goToPage(page + 1)} disabled={page >= data.totalPages - 1}>
+ {t('LIBRARY.NEXT')}
+
+
+ )}
);
}
diff --git a/src/pages/ImprintPage.jsx b/src/pages/ImprintPage.jsx
index 5d232fd..c52e773 100644
--- a/src/pages/ImprintPage.jsx
+++ b/src/pages/ImprintPage.jsx
@@ -1,28 +1,30 @@
-import {useTranslation} from 'react-i18next';
+import {useTranslation} from 'react-i18next';
+
+import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
export default function ImprintPage() {
const {t} = useTranslation();
return (
-
-
-
- {t('IMPRINT.TITLE')}
-
-
+
+
+
+ {t('IMPRINT.TITLE')}
+
+
{t('IMPRINT.LEGAL')}
- Andreas Dahm
+ Andreas Dahm
Hauptstraße 37A
85614 Kirchseeon
Deutschland
-
+
{t('IMPRINT.CONTACT')}
-
Email: andreas.dahm@gmail.com
-
-
+
Email: andreas.dahm@gmail.com
+
+
);
}
\ No newline at end of file
diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx
index c124551..2819907 100644
--- a/src/pages/LandingPage.jsx
+++ b/src/pages/LandingPage.jsx
@@ -1,146 +1,105 @@
import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom';
-import {getValidToken} from '../utils/auth.js';
+import {
+ Archive, ArrowRight, Blocks, Clock, Gamepad2, Library, Link2, Sparkles, Trophy, Users,
+} from 'lucide-react';
+
+import {Badge} from '@/components/ui/badge';
+import {Button} from '@/components/ui/button';
+import {Card} from '@/components/ui/card';
+import {Skeleton} from '@/components/ui/skeleton';
+import GameCover from '../components/GameCover.jsx';
import StatCard from '../components/StatCard.jsx';
+import {getValidToken} from '../utils/auth.js';
import usePublicStats from '../hooks/usePublicStats.js';
-const iconUrl = (appid, hash) =>
- hash
- ? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`
- : null;
+function RankedGames({title, games, formatValue}) {
+ return (
+
+
+
+ {title}
+
+
+ {games.map((g, idx) => (
+ -
+ {idx + 1}
+
+ {g.name ?? `App ${g.appid}`}
+ {formatValue(g.value ?? 0)}
+
+ ))}
+
+
+ );
+}
export default function LandingPage() {
- const {t} = useTranslation();
+ const {t, i18n} = useTranslation();
const {data, isLoading} = usePublicStats();
const auth = data.auth;
const core = data.core;
+ const stats = [
+ [t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users],
+ [t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2],
+ [t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2],
+ [t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles],
+ [t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks],
+ [t('LANDING.TOTAL_PLAYTIME_DAYS'), core?.totalPlaytimeDays, Clock, ` ${t('LANDING.DAYS_SUFFIX')}`],
+ [t('LANDING.BIGGEST_LIBRARY'), core?.biggestLibrary, Library],
+ [t('LANDING.BIGGEST_BACKLOG'), core?.biggestBacklog, Archive],
+ ];
+
return (
-
-
-
- {t('MAIN.WELCOME')}
-
-
- {t('MAIN.DESCRIPTION')}
-
- {getValidToken() ? (
-
- {t('NAV.TO_DASHBOARD')}
-
- ) : (
-
- {t('MAIN.GET_STARTED')}
-
- )}
+
+
+
+
+
+ Game Log
+
+
{t('MAIN.WELCOME')}
+
{t('MAIN.DESCRIPTION')}
+
+ {getValidToken() ? (
+ {t('NAV.TO_DASHBOARD')}
+ ) : (
+ {t('MAIN.GET_STARTED')}
+ )}
+
+
-
-
- {t('LANDING.STATS_TITLE')}
-
- {isLoading ? (
-
- {t('LANDING.STATS_LOADING')}
-
- ) : (
-
-
-
-
-
-
-
-
-
-
- )}
+
+ {t('LANDING.STATS_TITLE')}
- {core?.topPlayedGames?.length > 0 && (
-
-
-
- {t('LANDING.TOP_PLAYED')}
-
-
- {core.topPlayedGames.map((g, idx) => (
- -
- {idx + 1}
- {iconUrl(g.appid, g.imgIconUrl) ? (
-
- ) : (
-
- )}
-
- {g.name ?? `App ${g.appid}`}
-
-
- {Math.round(g.value / 60).toLocaleString()}h
-
-
- ))}
-
-
-
-
- {t('LANDING.TOP_OWNED')}
-
-
- {core.topOwnedGames.map((g, idx) => (
- -
- {idx + 1}
- {iconUrl(g.appid, g.imgIconUrl) ? (
-
- ) : (
-
- )}
-
- {g.name ?? `App ${g.appid}`}
-
-
- {g.value.toLocaleString()}
-
-
- ))}
-
-
-
- )}
+
+ {isLoading
+ ? stats.map(([label]) => )
+ : stats.map(([label, value, icon, suffix]) => (
+
+ ))}
+
+
+
+ {core?.topPlayedGames?.length > 0 && (
+ `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
+ />
+ )}
+ {core?.topOwnedGames?.length > 0 && (
+ count.toLocaleString(i18n.language)}
+ />
+ )}
+
);
diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx
index 4368e1c..89b8773 100644
--- a/src/pages/LoginPage.jsx
+++ b/src/pages/LoginPage.jsx
@@ -1,6 +1,13 @@
import {useTranslation} from 'react-i18next';
import {useState} from 'react';
import {Link, Navigate, useNavigate} from 'react-router-dom';
+import {Loader2} from 'lucide-react';
+
+import {Button} from '@/components/ui/button';
+import {Input} from '@/components/ui/input';
+import {Label} from '@/components/ui/label';
+import AuthLayout from '../components/AuthLayout.jsx';
+import FeedbackAlert from '../components/FeedbackAlert.jsx';
import {getValidToken} from '../utils/auth.js';
export default function LoginPage() {
@@ -64,83 +71,47 @@ export default function LoginPage() {
}
return (
-
+
+
);
}
\ No newline at end of file
diff --git a/src/pages/RegistrationPage.jsx b/src/pages/RegistrationPage.jsx
index 611f738..f0acf58 100644
--- a/src/pages/RegistrationPage.jsx
+++ b/src/pages/RegistrationPage.jsx
@@ -4,6 +4,13 @@ import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
+import {Loader2} from 'lucide-react';
+
+import {Button} from '@/components/ui/button';
+import {Input} from '@/components/ui/input';
+import AuthLayout from '../components/AuthLayout.jsx';
+import FeedbackAlert from '../components/FeedbackAlert.jsx';
+import FormField from '../components/FormField.jsx';
import {registrationSchema} from '../utils/validation.js';
const BACKEND_ERROR_KEY = {
@@ -12,11 +19,6 @@ const BACKEND_ERROR_KEY = {
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
};
-const inputClass =
- 'w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:outline-none';
-const inputErrorClass =
- 'w-full px-4 py-2 border border-red-500 dark:border-red-500 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:outline-none';
-
export default function RegistrationPage() {
const {t} = useTranslation();
const navigate = useNavigate();
@@ -70,116 +72,73 @@ export default function RegistrationPage() {
}
};
- const renderError = (field) =>
- errors[field] && (
- {t(errors[field].message)}
- );
-
return (
-
-
-
- {t('REGISTRATION.TITLE')}
-
+
+ {t('REGISTRATION.HAVE_ACCOUNT')}{' '}
+
+ {t('MAIN.LOGIN')}
+
+ >}
+ >
+
-
- {isSubmitting ? '...' : t('REGISTRATION.SUBMIT')}
-
-
-
- {feedback.message && (
-
- {feedback.message}
-
- )}
-
-
- {t('REGISTRATION.HAVE_ACCOUNT')}{' '}
-
- {t('MAIN.LOGIN')}
-
-
-
-
+
+
);
}
diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx
index 352b452..1994111 100644
--- a/src/pages/SettingsPage.jsx
+++ b/src/pages/SettingsPage.jsx
@@ -4,6 +4,17 @@ import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
+import {Gamepad2, Loader2, Save, SlidersHorizontal, UserRound} from 'lucide-react';
+import {toast} from 'sonner';
+
+import {Button} from '@/components/ui/button';
+import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
+import {Input} from '@/components/ui/input';
+import {Label} from '@/components/ui/label';
+import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
+import {Skeleton} from '@/components/ui/skeleton';
+import {Switch} from '@/components/ui/switch';
+import FormField from '../components/FormField.jsx';
import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
@@ -13,13 +24,6 @@ const BACKEND_ERROR_KEY = {
const SUPPORTED_LANGUAGES = ['en', 'de'];
-const inputClass =
- 'w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:outline-none';
-const inputErrorClass =
- 'w-full px-4 py-2 border border-red-500 dark:border-red-500 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:outline-none';
-const readOnlyClass =
- 'w-full px-4 py-2 border border-slate-200 dark:border-slate-700 rounded-lg bg-slate-100 dark:bg-slate-900 text-slate-500 dark:text-slate-400 cursor-not-allowed';
-
const formatDate = (value, locale) => {
if (!value) return '—';
try {
@@ -37,7 +41,6 @@ export default function SettingsPage() {
const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
- const [feedback, setFeedback] = useState({message: '', isError: false});
const {
register,
@@ -121,20 +124,17 @@ export default function SettingsPage() {
if (!response.ok) {
const data = await response.json().catch(() => ({}));
const mappedKey = BACKEND_ERROR_KEY[data.code];
- setFeedback({
- message: mappedKey ? t(mappedKey) : data.message || t('SETTINGS.ERROR_SAVE'),
- isError: true,
- });
+ toast.error(mappedKey ? t(mappedKey) : data.message || t('SETTINGS.ERROR_SAVE'));
return {ok: false};
}
const data = await response.json();
localStorage.setItem('tbp_jwt', data.token); // Core reads the steamId from the token
- setFeedback({message: t('SETTINGS.SAVED'), isError: false});
+ toast.success(t('SETTINGS.SAVED'));
return {ok: true};
} catch (err) {
console.error('Settings save error:', err);
- setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
+ toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
return {ok: false};
}
};
@@ -149,8 +149,7 @@ export default function SettingsPage() {
});
};
- const handleLanguageChange = async (event) => {
- const next = event.target.value;
+ const handleLanguageChange = async (next) => {
setLanguage(next); // apply on the fly
await persistSettings({
useDarkTheme: isDarkMode,
@@ -170,167 +169,98 @@ export default function SettingsPage() {
}
};
- const renderError = (field) =>
- errors[field] && (
- {t(errors[field].message)}
- );
-
if (isLoading) {
return (
-
- {t('SETTINGS.LOADING')}
+
+
+
+
+
);
}
if (loadError) {
- return (
-
- {loadError}
-
- );
+ return
{loadError}
;
}
+ const accountRows = [
+ [t('SETTINGS.EMAIL'), profile?.email],
+ [t('SETTINGS.ROLE'), profile?.role],
+ [t('SETTINGS.CREATED_DATE'), formatDate(profile?.createdDate, i18n.language)],
+ [t('SETTINGS.LAST_LOGIN'), formatDate(profile?.lastLogin, i18n.language)],
+ ];
+
return (
-
-
-
-
- {t('SETTINGS.TITLE')}
-
+
+
{t('SETTINGS.TITLE')}
- {/* Read-only account info */}
-
-
- {t('SETTINGS.ACCOUNT')}
-
-
-
-
-
-
-
-
-
-
-
-
+ ))}
+
+
+
- {/* Preferences — live */}
-
-
- {t('SETTINGS.PREFERENCES')}
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
- {/* Steam ID — explicit save */}
-
-
- {t('SETTINGS.STEAM')}
-
-
-
-
- {feedback.message && (
-
- {feedback.message}
-
- )}
-
-
+
+
+ {t('SETTINGS.STEAM')}
+
+
+
+
+
);
}
diff --git a/src/style.css b/src/style.css
index ffc5adb..35360e3 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,3 +1,85 @@
@import "tailwindcss";
+@import "tw-animate-css";
+@import "@fontsource-variable/inter";
-@custom-variant dark (&:is(.dark *));
\ No newline at end of file
+@custom-variant dark (&:is(.dark *));
+
+/* shadcn/ui theme tokens: zinc surfaces, violet accent. */
+:root {
+ --radius: 0.75rem;
+ --background: oklch(0.985 0 0);
+ --foreground: oklch(0.141 0.005 285.823);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.005 285.823);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.005 285.823);
+ --primary: oklch(0.541 0.281 293.009);
+ --primary-foreground: oklch(0.969 0.016 293.756);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.967 0.001 286.375);
+ --muted-foreground: oklch(0.552 0.016 285.938);
+ --accent: oklch(0.943 0.029 294.588);
+ --accent-foreground: oklch(0.38 0.189 293.745);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.92 0.004 286.32);
+ --input: oklch(0.92 0.004 286.32);
+ --ring: oklch(0.606 0.25 292.717);
+}
+
+.dark {
+ --background: oklch(0.141 0.005 285.823);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.21 0.006 285.885);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.21 0.006 285.885);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.606 0.25 292.717);
+ --primary-foreground: oklch(0.969 0.016 293.756);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --accent: oklch(0.283 0.141 291.089);
+ --accent-foreground: oklch(0.894 0.057 293.283);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.541 0.281 293.009);
+}
+
+@theme inline {
+ --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+
+ body {
+ @apply bg-background text-foreground antialiased;
+ }
+}
diff --git a/src/utils/games.js b/src/utils/games.js
index 954564a..34cd972 100644
--- a/src/utils/games.js
+++ b/src/utils/games.js
@@ -9,3 +9,6 @@ export const formatPlaytime = (minutes) => {
const hours = Math.round(minutes / 6) / 10;
return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`;
};
+
+// 460x215 store header. Not every app has one; callers need an onError fallback.
+export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
diff --git a/vite.config.js b/vite.config.js
index 9af89b0..0cf1da5 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,3 +1,4 @@
+import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
@@ -7,4 +8,9 @@ export default defineConfig({
react(),
tailwindcss(),
],
-});
\ No newline at end of file
+ resolve: {
+ alias: {
+ '@': path.resolve(import.meta.dirname, './src'),
+ },
+ },
+});