puzzle archive
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user