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