welcome screen
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Icon as TablerIcon } from '@tabler/icons-react'
|
||||
import { IconStarFilled, IconHeartFilled,
|
||||
IconBoltFilled, IconZoomQuestionFilled, IconPlayerTrackNextFilled,
|
||||
IconChevronDownFilled, IconFileUploadFilled,
|
||||
IconCheckFilled, IconBookFilled, IconFeatherFilled, IconDiamondFilled,
|
||||
IconCompassFilled, IconFlameFilled,
|
||||
IconAutomaticGearboxFilled, IconGlobeFilled,
|
||||
IconBulbFilled, IconHomeFilled, IconTrophyFilled } from '@tabler/icons-react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { useStore } from '../store'
|
||||
import type { GameMode } from '../types'
|
||||
import * as api from '../api'
|
||||
import type { CommunityPuzzleItem, ThemeInfo } from '../api'
|
||||
import { PRESETS, speedrunDescription } from '../presets'
|
||||
import ReportButton from './ui/ReportButton'
|
||||
|
||||
interface RichOption {
|
||||
value: string
|
||||
label: string
|
||||
desc?: string
|
||||
icon?: TablerIcon
|
||||
}
|
||||
|
||||
function RichSelect({ value, options, onChange, label }: {
|
||||
value: string; options: RichOption[]; onChange: (v: string) => void; label: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
|
||||
const selected = options.find(o => o.value === value)
|
||||
|
||||
const updatePos = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const container = triggerRef.current.closest('.welcome')
|
||||
const containerRect = container?.getBoundingClientRect()
|
||||
const maxRight = containerRect ? containerRect.right : window.innerWidth - 16
|
||||
const maxLeft = containerRect ? containerRect.left : 16
|
||||
let left = rect.left
|
||||
const width = Math.max(rect.width, 280)
|
||||
if (left + width > maxRight) left = maxRight - width
|
||||
if (left < maxLeft) left = maxLeft
|
||||
setPos({ top: rect.top, left, width })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
updatePos()
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target
|
||||
if (target instanceof Node && dropRef.current && !dropRef.current.contains(target) &&
|
||||
triggerRef.current && !triggerRef.current.contains(target)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
window.addEventListener('scroll', updatePos, true)
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePos, true)
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open, updatePos])
|
||||
|
||||
return (
|
||||
<div className="ed-rich-select">
|
||||
<label className="ed-select-label">{label}</label>
|
||||
<button ref={triggerRef} type="button" className="ed-rich-trigger" onClick={() => setOpen(!open)}
|
||||
aria-haspopup="listbox" aria-expanded={open}>
|
||||
{selected?.icon && <selected.icon size={13} className="ed-rich-trigger-icon" aria-hidden="true" />}
|
||||
<span className="ed-rich-trigger-label">{selected?.label || ''}</span>
|
||||
<IconChevronDownFilled size={9} className="ed-rich-trigger-chevron" style={{ transform: open ? 'rotate(180deg)' : 'none' }} aria-hidden="true" />
|
||||
</button>
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div ref={dropRef} className="ed-rich-dropdown" role="listbox"
|
||||
style={{ bottom: `calc(100vh - ${pos.top}px + 4px)`, left: pos.left, minWidth: pos.width, top: 'auto' }}
|
||||
initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 6 }} transition={{ duration: 0.12 }}>
|
||||
{options.map(o => (
|
||||
<button key={o.value} type="button" role="option" aria-selected={o.value === value}
|
||||
className={`ed-rich-option ${o.value === value ? 'ed-rich-option-on' : ''}`}
|
||||
onClick={() => { onChange(o.value); setOpen(false) }}>
|
||||
<div className="ed-rich-option-left">
|
||||
{o.icon && <o.icon size={16} className="ed-rich-option-icon" aria-hidden="true" />}
|
||||
<div>
|
||||
<span className="ed-rich-option-name">{o.label}</span>
|
||||
{o.desc && <span className="ed-rich-option-desc">{o.desc}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{o.value === value && <IconCheckFilled size={11} className="ed-rich-option-check" aria-hidden="true" />}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DIFFICULTIES = PRESETS.map(p => ({ preset: p.id, name: p.name, desc: p.desc, tag: p.tag, Icon: p.icon }))
|
||||
|
||||
export default function Welcome() {
|
||||
const generatePuzzle = useStore((s) => s.generatePuzzle)
|
||||
const loadDaily = useStore((s) => s.loadDaily)
|
||||
const loading = useStore((s) => s.loading)
|
||||
const streak = useStore((s) => s.streak)
|
||||
const preferredPreset = useStore((s) => s.preferredPreset)
|
||||
const setGameMode = useStore((s) => s.setGameMode)
|
||||
const gameMode = useStore((s) => s.gameMode)
|
||||
const loadCommunityPuzzle = useStore((s) => s.loadCommunityPuzzle)
|
||||
const importPuzzle = useStore((s) => s.importPuzzle)
|
||||
const { t } = useTranslation()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [communityPuzzles, setCommunityPuzzles] = useState<CommunityPuzzleItem[]>([])
|
||||
const [federatedPuzzles, setFederatedPuzzles] = useState<CommunityPuzzleItem[]>([])
|
||||
const [ratings, setRatings] = useState<Record<string, { average: number; count: number }>>({})
|
||||
const [themes, setThemes] = useState<{ id: string; name: string; word_count: number }[]>([])
|
||||
const [selectedTheme, setSelectedTheme] = useState('')
|
||||
const [packs, setPacks] = useState<ThemeInfo[]>([])
|
||||
const [selectedPack, setSelectedPack] = useState('')
|
||||
const [cryptic, setCryptic] = useState(false)
|
||||
const [clueDiff, setClueDiff] = useState<number | undefined>(undefined)
|
||||
const [showCustomPanel, setShowCustomPanel] = useState(false)
|
||||
const [showCommunity, setShowCommunity] = useState(false)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
const [completedChallenges, setCompletedChallenges] = useState<Record<string, boolean>>({})
|
||||
const [wotd, setWotd] = useState<{ word: string; definition: string; clue: string; length: number } | null>(null)
|
||||
const [countdown, setCountdown] = useState('')
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const now = new Date()
|
||||
const midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
|
||||
const diff = midnight.getTime() - now.getTime()
|
||||
const h = Math.floor(diff / 3600000)
|
||||
const m = Math.floor((diff % 3600000) / 60000)
|
||||
const s = Math.floor((diff % 60000) / 1000)
|
||||
setCountdown(`${h}h ${m}m ${s}s`)
|
||||
}
|
||||
update()
|
||||
const id = setInterval(update, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
api.fetchDailyRatings(today).then(setRatings)
|
||||
api.fetchThemes().then(setThemes)
|
||||
api.fetchPacks().then(data => setPacks(data.packs || []))
|
||||
api.fetchCommunityPuzzles().then(setCommunityPuzzles)
|
||||
api.fetchFederatedPuzzles().then(data => setFederatedPuzzles(data.puzzles || [])).catch(() => {})
|
||||
api.fetchWordOfTheDay().then(data => { if (data.word) setWotd(data) })
|
||||
api.fetchTodayChallenges().then(setCompletedChallenges).catch(() => {})
|
||||
}, [])
|
||||
|
||||
|
||||
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) importPuzzle(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const allCommunity = [...communityPuzzles, ...federatedPuzzles.map(p => ({ ...p, federated: true }))]
|
||||
|
||||
return (
|
||||
<div className="welcome">
|
||||
{/* masthead */}
|
||||
<header className="welcome-masthead">
|
||||
<div className="welcome-rule" />
|
||||
<h1>{t('app.name')}</h1>
|
||||
<p className="welcome-edition">
|
||||
{new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</p>
|
||||
<p className="welcome-sub">
|
||||
{streak && streak.current > 0
|
||||
? t('app.streakKeepGoing', { count: streak.current })
|
||||
: t('app.tagline')
|
||||
}
|
||||
</p>
|
||||
<div className="welcome-rule" />
|
||||
</header>
|
||||
|
||||
{/* the daily five - cards */}
|
||||
<section className="ed-feature">
|
||||
<div className="ed-feature-header">
|
||||
<span className="ed-flourish">❦</span>
|
||||
<h2 className="ed-feature-title">{t('welcome.todaysPuzzles')}</h2>
|
||||
<p className="ed-feature-countdown">
|
||||
{t('welcome.newPuzzlesIn', { countdown: '' })}<span className="ed-countdown-time">{countdown}</span>
|
||||
</p>
|
||||
<span className="ed-flourish">❦</span>
|
||||
</div>
|
||||
<div className="ed-cards">
|
||||
{DIFFICULTIES.map((d) => {
|
||||
const r = ratings[d.preset]
|
||||
return (
|
||||
<button key={d.preset} type="button"
|
||||
className={`ed-card ${preferredPreset === d.preset ? 'ed-card-fav' : ''}`}
|
||||
onClick={() => !loading && loadDaily(today, d.preset)} disabled={loading}>
|
||||
<d.Icon size={20} className="ed-card-icon" />
|
||||
<span className="ed-card-name">
|
||||
{d.name}
|
||||
{preferredPreset === d.preset && <IconHeartFilled size={9} className="wp-heart" />}
|
||||
{completedChallenges[d.preset] && <IconTrophyFilled size={10} className="ed-card-challenge-done" />}
|
||||
</span>
|
||||
<span className="ed-card-desc">{d.desc} {d.tag}</span>
|
||||
{r && r.count > 0 && (
|
||||
<span className="ed-card-rating"><IconStarFilled size={8} /> {r.average}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* word of the day */}
|
||||
{wotd && (
|
||||
<aside className="ed-word">
|
||||
<div className="ed-word-label">{t('welcome.wordOfTheDay')}</div>
|
||||
<div className="ed-word-content">
|
||||
<div className="ed-word-left">
|
||||
<h3 className="ed-word-term">{wotd.word}</h3>
|
||||
<span className="ed-word-length">{wotd.length} letters</span>
|
||||
</div>
|
||||
<div className="ed-word-divider" />
|
||||
<div className="ed-word-right">
|
||||
<p className="ed-word-meaning">{wotd.definition}</p>
|
||||
<p className="ed-word-clue">Clue: {wotd.clue}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* accordion sections for secondary content */}
|
||||
<div className="ed-drawers">
|
||||
|
||||
{/* custom puzzle */}
|
||||
<div className="ed-drawer">
|
||||
<button type="button" className="ed-drawer-toggle" onClick={() => setShowCustomPanel(!showCustomPanel)}>
|
||||
<h2>{t('welcome.customPuzzle')}</h2>
|
||||
<IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCustomPanel ? 'rotate(180deg)' : 'none' }} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showCustomPanel && (
|
||||
<motion.div className="ed-drawer-body"
|
||||
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }}
|
||||
style={{ overflow: 'hidden' }}>
|
||||
<div className="ed-drawer-inner">
|
||||
<div className="ed-selects">
|
||||
<RichSelect label={t('welcome.clueStyle')}
|
||||
value={cryptic ? 'cryptic' : 'standard'}
|
||||
onChange={v => setCryptic(v === 'cryptic')}
|
||||
options={[
|
||||
{ value: 'standard', label: t('welcome.standard'), icon: IconBookFilled,
|
||||
desc: 'Traditional crossword clues. Each clue is a definition, a synonym, or a fill-in-the-blank that points directly to the answer. What you would find in any newspaper.' },
|
||||
{ value: 'cryptic', label: t('welcome.cryptic'), icon: IconBulbFilled,
|
||||
desc: 'British-style wordplay. Every clue contains both a straight definition and a second route to the answer through anagrams, hidden words, reversals, containers, or double definitions. Much harder, but the moment a cryptic clue clicks is unlike anything else in puzzling.' },
|
||||
]} />
|
||||
<RichSelect label={t('welcome.difficulty')}
|
||||
value={clueDiff === undefined ? 'auto' : String(clueDiff)}
|
||||
onChange={v => setClueDiff(v === 'auto' ? undefined : Number(v))}
|
||||
options={[
|
||||
{ value: 'auto', label: t('welcome.auto'), icon: IconAutomaticGearboxFilled,
|
||||
desc: 'Difficulty scales with puzzle size. Smaller grids get friendlier words and clearer clues. Larger grids allow more obscure vocabulary and trickier misdirection.' },
|
||||
{ value: '0', label: t('welcome.veryEasy'), icon: IconFeatherFilled,
|
||||
desc: 'Everyday words you use in conversation. Simple, direct clues with no tricks. Ideal for beginners, kids, or a quick relaxing solve.' },
|
||||
{ value: '1', label: t('welcome.easy'), icon: IconDiamondFilled,
|
||||
desc: 'Mostly familiar vocabulary with a few less common words at the crossings. Clues are fair and approachable.' },
|
||||
{ value: '2', label: t('welcome.medium'), icon: IconCompassFilled,
|
||||
desc: 'A real challenge. Expect multi-meaning clues, less obvious synonyms, and a handful of words that will make you think twice.' },
|
||||
{ value: '3', label: t('welcome.hard'), icon: IconFlameFilled,
|
||||
desc: 'No mercy. Obscure vocabulary, devious misdirection, and crossings that demand knowledge across many subjects. Saturday NYT energy.' },
|
||||
]} />
|
||||
<RichSelect label={t('welcome.gameMode')}
|
||||
value={gameMode}
|
||||
onChange={v => {
|
||||
// SAFETY: the options below are the complete GameMode set, so v is always a GameMode value
|
||||
setGameMode(v as GameMode)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'standard', label: t('welcome.standard'), icon: IconHomeFilled,
|
||||
desc: 'The classic experience. Solve at whatever pace feels right. Hints are available when you are stuck, you can pause and come back later, and your time is tracked for personal bests without any pressure.' },
|
||||
{ value: 'speedrun', label: t('welcome.speedRun'), icon: IconBoltFilled,
|
||||
desc: 'You against the clock. Every preset has a target time - Glyph in 45 seconds, Grimoire in 45 minutes. Hints are completely disabled. Pausing is not allowed. The timer never stops. Finish before it runs out or the puzzle is lost.' },
|
||||
{ value: 'blind', label: t('welcome.blind'), icon: IconZoomQuestionFilled,
|
||||
desc: 'All clues start hidden. You can only read the clue for the word you are currently working on. Completed words reveal their clues permanently, but everything else stays dark. You are forced to build outward from crossings, letter by letter, never seeing the full picture until the end.' },
|
||||
{ value: 'marathon', label: t('welcome.marathon'), icon: IconPlayerTrackNextFilled,
|
||||
desc: 'An endless gauntlet of quick 5x5 puzzles. Each one you solve loads the next immediately. You have exactly 2 minutes per puzzle - if time runs out on any single one, the marathon is over. How far can you go?' },
|
||||
]} />
|
||||
{themes.length > 0 && (
|
||||
<RichSelect label={t('welcome.theme')}
|
||||
value={selectedTheme}
|
||||
onChange={v => setSelectedTheme(v)}
|
||||
options={[
|
||||
{ value: '', label: t('welcome.none'), icon: IconGlobeFilled,
|
||||
desc: 'No theme restriction. Answers drawn freely from the full dictionary based on quality scores.' },
|
||||
...themes.map(th => ({ value: th.id, label: th.name, desc: `${th.word_count.toLocaleString()} themed words available` })),
|
||||
]} />
|
||||
)}
|
||||
{packs.length > 0 && (
|
||||
<RichSelect label={t('welcome.wordPack')}
|
||||
value={selectedPack}
|
||||
onChange={v => setSelectedPack(v)}
|
||||
options={[
|
||||
{ value: '', label: t('welcome.none'), icon: IconGlobeFilled,
|
||||
desc: 'The full default dictionary, curated with quality scores to avoid junk words.' },
|
||||
...packs.map(p => ({ value: p.id, label: p.name, desc: `${p.word_count.toLocaleString()} curated words` })),
|
||||
]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{cryptic && (
|
||||
<motion.div className="customize-explainer" key="cryptic"
|
||||
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<p>{t('welcome.crypticExplainer')}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{gameMode !== 'standard' && (
|
||||
<motion.div className="customize-explainer" key="gm"
|
||||
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{gameMode === 'speedrun' && <motion.p key="sr" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>{speedrunDescription()}</motion.p>}
|
||||
{gameMode === 'blind' && <motion.p key="bl" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>{t('welcome.blindExplainer')}</motion.p>}
|
||||
{gameMode === 'marathon' && <motion.p key="ma" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>{t('welcome.marathonExplainer')}</motion.p>}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="ed-generate-row">
|
||||
{gameMode === 'marathon' ? (
|
||||
<button type="button" className="ed-generate-btn"
|
||||
onClick={() => !loading && generatePuzzle('glyph', undefined, undefined, clueDiff, selectedTheme || undefined, cryptic || undefined, selectedPack || undefined)}
|
||||
disabled={loading}>
|
||||
<span className="ed-gen-name">{t('welcome.startMarathon')}</span>
|
||||
<span className="ed-gen-meta">5x5 - {t('welcome.marathonDesc')}</span>
|
||||
</button>
|
||||
) : (
|
||||
DIFFICULTIES.map((d) => (
|
||||
<button key={`g-${d.preset}`} type="button" className="ed-generate-btn"
|
||||
onClick={() => !loading && generatePuzzle(d.preset, undefined, undefined, clueDiff, selectedTheme || undefined, cryptic || undefined, selectedPack || undefined)}
|
||||
disabled={loading}>
|
||||
<span className="ed-gen-name">{d.name}</span>
|
||||
<span className="ed-gen-meta">{d.desc} {d.tag}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="welcome-import-btn" onClick={() => fileRef.current?.click()} disabled={loading}>
|
||||
<IconFileUploadFilled size={11} /> {t('welcome.importPuz')}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".puz,.ipuz" hidden onChange={handleImport} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* community puzzles */}
|
||||
{allCommunity.length > 0 && (
|
||||
<div className="ed-drawer">
|
||||
<button type="button" className="ed-drawer-toggle" onClick={() => setShowCommunity(!showCommunity)}>
|
||||
<h2>{t('welcome.communityPuzzles')}</h2><span className="ed-drawer-badge">{allCommunity.length}</span>
|
||||
<IconChevronDownFilled size={10} className="ed-drawer-chevron" style={{ transform: showCommunity ? 'rotate(180deg)' : 'none' }} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showCommunity && (
|
||||
<motion.div className="ed-drawer-body"
|
||||
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }}
|
||||
style={{ overflow: 'hidden' }}>
|
||||
<div className="ed-puzzle-grid">
|
||||
{allCommunity.slice(0, 8).map(p => (
|
||||
<button key={p.id} type="button" className="ed-puzzle-card"
|
||||
onClick={() => !loading && loadCommunityPuzzle(p.id)} disabled={loading}>
|
||||
<span className="ed-puzzle-size">{p.width}x{p.height}</span>
|
||||
<span className="ed-puzzle-title">{p.title}</span>
|
||||
<span className="ed-puzzle-by">
|
||||
{p.origin ? `from ${p.origin}` : p.author ? `by ${p.author}` : ''}
|
||||
</span>
|
||||
<span className="ed-puzzle-report"><ReportButton contentType="puzzle" contentId={p.id} /></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user