puzzle archive

This commit is contained in:
2026-01-06 20:39:51 +02:00
parent e30bc93a90
commit 0ad2de8132
+110
View File
@@ -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<ArchiveEntry[]>([])
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 (
<motion.div className="settings-overlay" onClick={onClose}
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<motion.div className="settings-panel" onClick={e => 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 }}>
<div className="settings-header">
<h2 className="settings-title">{t('archive.title')}</h2>
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}>
<IconX size={16} aria-hidden="true" />
</button>
</div>
<div className="archive-filters" style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 12 }}>
{PRESETS.map(p => (
<button key={p} className={`btn btn-sm${preset === p ? ' btn-active' : ''}`}
onClick={() => setPreset(p)} style={{ textTransform: 'capitalize' }}>
{p === 'all' ? t('archive.all') : p}
</button>
))}
</div>
<div className="settings-body" style={{ overflowY: 'auto' }}>
{entries.length === 0 && !loading && (
<p style={{ color: 'var(--text-tertiary)', fontSize: '0.8rem' }}>{t('archive.empty')}</p>
)}
<div className="cc-list">
{entries.map((entry, i) => (
<div key={`${entry.date}-${entry.preset}-${i}`} className="cc-clue" style={{ cursor: 'pointer' }} onClick={() => play(entry)}>
<div className="cc-clue-text" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{entry.solved && <IconCircleCheckFilled size={13} style={{ color: 'var(--color-correct)', flexShrink: 0 }} aria-hidden="true" />}
<span>{entry.date}</span>
<span className={`cc-status cc-status-${entry.preset}`} style={{ textTransform: 'capitalize' }}>{entry.preset}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.7rem' }}>{entry.width}x{entry.height}</span>
</div>
</div>
))}
</div>
{hasMore && (
<button className="btn btn-sm" onClick={loadMore} disabled={loading} style={{ width: '100%', marginTop: 8 }}>
{loading ? t('common.loading') : t('archive.loadMore')}
</button>
)}
</div>
</motion.div>
</motion.div>
)
}