diff --git a/frontend/src/components/PuzzleArchive.tsx b/frontend/src/components/PuzzleArchive.tsx new file mode 100644 index 0000000..0a89470 --- /dev/null +++ b/frontend/src/components/PuzzleArchive.tsx @@ -0,0 +1,110 @@ +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { motion } from 'motion/react' +import { IconX, IconCircleCheckFilled } from '@tabler/icons-react' +import * as api from '../api' +import { useStore } from '../store' + +interface ArchiveEntry { + date: string + preset: string + short_id: string + width: number + height: number + solved: boolean +} + +const PRESETS = ['all', 'glyph', 'rune', 'scroll', 'codex', 'grimoire'] + +export default function PuzzleArchive({ onClose }: { onClose: () => void }) { + const { t } = useTranslation() + const [preset, setPreset] = useState('all') + const [entries, setEntries] = useState([]) + const [page, setPage] = useState(0) + const [hasMore, setHasMore] = useState(true) + const [loading, setLoading] = useState(false) + const loadDaily = useStore((s) => s.loadDaily) + + const load = async (p: number, filter: string, append: boolean) => { + setLoading(true) + try { + const res = await api.fetchArchive(filter, p) + const list = res.entries || [] + setEntries(prev => append ? [...prev, ...list] : list) + setHasMore(list.length >= 20) + } catch (_e) { + setHasMore(false) + } + setLoading(false) + } + + useEffect(() => { + queueMicrotask(() => { + setPage(0) + void load(0, preset, false) + }) + }, [preset]) + + const loadMore = () => { + const next = page + 1 + setPage(next) + load(next, preset, true) + } + + const play = (entry: ArchiveEntry) => { + loadDaily(entry.date, entry.preset) + onClose() + } + + return ( + + e.stopPropagation()} + role="dialog" aria-modal="true" aria-label={t('archive.title')} + initial={{ opacity: 0, scale: 0.96, y: 10 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.96, y: 10 }} + transition={{ duration: 0.2 }}> +
+

{t('archive.title')}

+ +
+ +
+ {PRESETS.map(p => ( + + ))} +
+ +
+ {entries.length === 0 && !loading && ( +

{t('archive.empty')}

+ )} +
+ {entries.map((entry, i) => ( +
play(entry)}> +
+ {entry.solved &&
+
+ ))} +
+ {hasMore && ( + + )} +
+
+
+ ) +}