diff --git a/public/i18n/de.json b/public/i18n/de.json
index 73a67a2..4de28fc 100644
--- a/public/i18n/de.json
+++ b/public/i18n/de.json
@@ -109,7 +109,10 @@
"SYNCING": "Synchronisiere mit Steam …",
"SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.",
"SYNC_FAILED": "Synchronisation fehlgeschlagen. Bitte später erneut versuchen.",
- "LAST_SYNCED": "Zuletzt synchronisiert {{when}}"
+ "LAST_SYNCED": "Zuletzt synchronisiert {{when}}",
+ "FILTER_ALL": "Alle",
+ "SEARCH_PLACEHOLDER": "Spiel suchen …",
+ "NO_MATCH": "Keine Spiele für diesen Filter."
},
"GAME_DETAIL": {
"BACK": "Zurück",
@@ -157,5 +160,32 @@
"LANGUAGE": "Sprache",
"DARK_MODE": "Dunkles Design",
"LIGHT_MODE": "Helles Design"
+ },
+ "BACKLOG": {
+ "TITLE": "Mein Backlog",
+ "STATUS_LABEL": "Status",
+ "PRIORITY_LABEL": "Priorität",
+ "RATING_LABEL": "Bewertung",
+ "RATING_STARS_one": "{{count}} Stern",
+ "RATING_STARS_other": "{{count}} Sterne",
+ "NOTES_LABEL": "Notizen",
+ "NOTES_PLACEHOLDER": "Was willst du dir zu diesem Spiel merken?",
+ "SAVE": "Speichern",
+ "RESET": "Verwerfen",
+ "SAVED": "Gespeichert",
+ "SAVE_FAILED": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
+ "STATUS": {
+ "BACKLOG": "Backlog",
+ "PLAYING": "Spiele ich",
+ "FINISHED": "Durchgespielt",
+ "COMPLETED": "100 %",
+ "DROPPED": "Abgebrochen"
+ },
+ "PRIORITY": {
+ "NONE": "Keine",
+ "HIGH": "Hoch",
+ "MEDIUM": "Mittel",
+ "LOW": "Niedrig"
+ }
}
}
diff --git a/public/i18n/en.json b/public/i18n/en.json
index 334a73d..0e5f4ee 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -109,7 +109,10 @@
"SYNCING": "Syncing with Steam …",
"SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.",
"SYNC_FAILED": "Sync failed. Please try again later.",
- "LAST_SYNCED": "Last synced {{when}}"
+ "LAST_SYNCED": "Last synced {{when}}",
+ "FILTER_ALL": "All",
+ "SEARCH_PLACEHOLDER": "Search games …",
+ "NO_MATCH": "No games match this filter."
},
"GAME_DETAIL": {
"BACK": "Back",
@@ -157,5 +160,32 @@
"LANGUAGE": "Language",
"DARK_MODE": "Dark mode",
"LIGHT_MODE": "Light mode"
+ },
+ "BACKLOG": {
+ "TITLE": "My backlog",
+ "STATUS_LABEL": "Status",
+ "PRIORITY_LABEL": "Priority",
+ "RATING_LABEL": "Rating",
+ "RATING_STARS_one": "{{count}} star",
+ "RATING_STARS_other": "{{count}} stars",
+ "NOTES_LABEL": "Notes",
+ "NOTES_PLACEHOLDER": "What do you want to remember about this game?",
+ "SAVE": "Save",
+ "RESET": "Discard",
+ "SAVED": "Saved",
+ "SAVE_FAILED": "Saving failed. Please try again.",
+ "STATUS": {
+ "BACKLOG": "Backlog",
+ "PLAYING": "Playing",
+ "FINISHED": "Finished",
+ "COMPLETED": "100%",
+ "DROPPED": "Dropped"
+ },
+ "PRIORITY": {
+ "NONE": "None",
+ "HIGH": "High",
+ "MEDIUM": "Medium",
+ "LOW": "Low"
+ }
}
}
diff --git a/src/components/BacklogCard.jsx b/src/components/BacklogCard.jsx
new file mode 100644
index 0000000..896896a
--- /dev/null
+++ b/src/components/BacklogCard.jsx
@@ -0,0 +1,156 @@
+import {useState} from 'react';
+import {useNavigate} from 'react-router-dom';
+import {useTranslation} from 'react-i18next';
+import {toast} from 'sonner';
+import {ListTodo, Loader2, Star} from 'lucide-react';
+
+import {Button} from '@/components/ui/button';
+import {Card} from '@/components/ui/card';
+import {Label} from '@/components/ui/label';
+import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
+import {Textarea} from '@/components/ui/textarea';
+import {cn} from '@/lib/utils';
+import {STATUS_META} from './GameStatusBadge.jsx';
+import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, toBacklogRequest} from '../utils/backlog.js';
+
+const NOTES_MAX = 2000;
+
+const toForm = (entry) => ({
+ status: entry.status ?? 'BACKLOG',
+ rating: entry.rating ?? 0,
+ notes: entry.notes ?? '',
+ priority: entry.priority ?? NO_PRIORITY,
+});
+
+export default function BacklogCard({game, onSaved}) {
+ const {t} = useTranslation();
+ const navigate = useNavigate();
+ const [saved, setSaved] = useState(() => toForm(game));
+ const [form, setForm] = useState(saved);
+ const [busy, setBusy] = useState(false);
+
+ const dirty = JSON.stringify(form) !== JSON.stringify(saved);
+ const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
+
+ const save = async () => {
+ const token = localStorage.getItem('tbp_jwt');
+ if (!token) {
+ navigate('/login');
+ return;
+ }
+ setBusy(true);
+ try {
+ const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${game.appid}/backlog`, {
+ method: 'PUT',
+ headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'},
+ body: JSON.stringify(toBacklogRequest(form)),
+ });
+ if (response.status === 401) {
+ localStorage.removeItem('tbp_jwt');
+ navigate('/login');
+ return;
+ }
+ if (!response.ok) {
+ toast.error(t('BACKLOG.SAVE_FAILED'));
+ return;
+ }
+ const entry = await response.json();
+ const next = toForm(entry);
+ setSaved(next);
+ onSaved?.(entry);
+ setForm(next);
+ toast.success(t('BACKLOG.SAVED'));
+ } catch (err) {
+ console.error('backlog save error:', err);
+ toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ {t('BACKLOG.TITLE')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4, 5].map((n) => (
+
+ ))}
+
+
+
+
+
+
+
+ {form.notes.length} / {NOTES_MAX}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/GameStatusBadge.jsx b/src/components/GameStatusBadge.jsx
new file mode 100644
index 0000000..7e53a1b
--- /dev/null
+++ b/src/components/GameStatusBadge.jsx
@@ -0,0 +1,30 @@
+import {useTranslation} from 'react-i18next';
+import {Ban, CircleCheck, Gamepad2, Inbox, Trophy} from 'lucide-react';
+
+import {Badge} from '@/components/ui/badge';
+import {cn} from '@/lib/utils';
+
+export const STATUS_META = {
+ BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground'},
+ PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary'},
+ FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'},
+ COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400'},
+ DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400'},
+};
+
+/**
+ * @param compact shows only the icon below the sm breakpoint
+ */
+export default function GameStatusBadge({status, compact = false, className}) {
+ const {t} = useTranslation();
+ const meta = STATUS_META[status];
+ if (!meta) return null;
+ const Icon = meta.icon;
+ const label = t(`BACKLOG.STATUS.${status}`);
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/src/components/ui/textarea.jsx b/src/components/ui/textarea.jsx
new file mode 100644
index 0000000..18ad4c4
--- /dev/null
+++ b/src/components/ui/textarea.jsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx
index 2463d36..ef21361 100644
--- a/src/pages/GameDetail.jsx
+++ b/src/pages/GameDetail.jsx
@@ -8,7 +8,9 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
+import BacklogCard from '../components/BacklogCard.jsx';
import GameCover from '../components/GameCover.jsx';
+import GameStatusBadge from '../components/GameStatusBadge.jsx';
import {formatPlaytime} from '../utils/games.js';
const formatUnlockedAt = (instant, locale) => {
@@ -123,10 +125,13 @@ export default function GameDetail() {
{data.name ?? `App ${data.appid}`}
-
-
- {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
-
+
+
+
+ {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
+
+
+