Initial commit: Who Did It Clue character preference collector
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Who Did It? — Clue Character Preference</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;1,400&family=Crimson+Text:ital@0;1&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1756
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "who-did-it-client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,284 @@
|
||||
:root {
|
||||
--cream: #f5efe0;
|
||||
--ink: #1a1208;
|
||||
--gold: #c9a84c;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #1a1208;
|
||||
background-image:
|
||||
repeating-linear-gradient(45deg, rgba(201,168,76,0.04) 0px, rgba(201,168,76,0.04) 1px, transparent 1px, transparent 10px),
|
||||
repeating-linear-gradient(-45deg, rgba(201,168,76,0.04) 0px, rgba(201,168,76,0.04) 1px, transparent 1px, transparent 10px);
|
||||
min-height: 100vh;
|
||||
font-family: 'Crimson Text', serif;
|
||||
color: var(--cream);
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.ornament {
|
||||
color: var(--gold);
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
font-family: 'Crimson Text', serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: clamp(2.8rem, 6vw, 4.5rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--cream);
|
||||
margin: 8px 0 4px;
|
||||
text-shadow: 0 2px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-style: italic;
|
||||
font-size: 1.2rem;
|
||||
color: rgba(245,239,224,0.6);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.gold-rule {
|
||||
width: 120px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--gold), transparent);
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
font-style: italic;
|
||||
color: rgba(245,239,224,0.7);
|
||||
font-size: 1.05rem;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.player-setup {
|
||||
max-width: 500px;
|
||||
margin: 0 auto 48px;
|
||||
background: rgba(245,239,224,0.04);
|
||||
border: 1px solid rgba(201,168,76,0.25);
|
||||
border-radius: 2px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
|
||||
.player-setup h2 {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 1.3rem;
|
||||
color: var(--gold);
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.name-row input {
|
||||
flex: 1;
|
||||
background: rgba(245,239,224,0.08);
|
||||
border: 1px solid rgba(201,168,76,0.3);
|
||||
border-radius: 2px;
|
||||
color: var(--cream);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 1.1rem;
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.name-row input:focus {
|
||||
border-color: var(--gold);
|
||||
}
|
||||
|
||||
.name-row input::placeholder {
|
||||
color: rgba(245,239,224,0.3);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(201,168,76,0.5);
|
||||
color: var(--gold);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 1.1rem;
|
||||
padding: 12px 28px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: rgba(201,168,76,0.15);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #e74c3c;
|
||||
font-size: 0.95rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.98); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Character cards */
|
||||
.characters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto 40px;
|
||||
}
|
||||
|
||||
.character-card {
|
||||
border: 2px solid rgba(201,168,76,0.2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.25s, box-shadow 0.25s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.character-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.character-card.card-ranked {
|
||||
box-shadow: 0 0 0 2px var(--card-color);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 22px 24px 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.character-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background: rgba(26,18,8,0.9);
|
||||
padding: 16px 24px 20px;
|
||||
}
|
||||
|
||||
.costume-hint {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(245,239,224,0.65);
|
||||
font-style: italic;
|
||||
margin-bottom: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rank-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(245,239,224,0.3);
|
||||
color: var(--cream);
|
||||
font-family: 'Crimson Text', serif;
|
||||
font-size: 0.9rem;
|
||||
padding: 6px 12px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.rank-badge:hover {
|
||||
border-color: var(--gold);
|
||||
background: rgba(201,168,76,0.1);
|
||||
}
|
||||
|
||||
.rank-badge.selected {
|
||||
border-color: var(--gold);
|
||||
background: rgba(201,168,76,0.2);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
color: rgba(245,239,224,0.25);
|
||||
font-style: italic;
|
||||
font-size: 0.95rem;
|
||||
padding: 40px 20px 20px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 20px 12px;
|
||||
}
|
||||
|
||||
.characters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ornament {
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 8vw, 3rem);
|
||||
}
|
||||
|
||||
.player-setup {
|
||||
padding: 20px 20px;
|
||||
}
|
||||
|
||||
.rank-badges {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import NameEntry from './pages/NameEntry'
|
||||
import Ranking from './pages/Ranking'
|
||||
import Thanks from './pages/Thanks'
|
||||
import Admin from './pages/Admin'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<NameEntry />} />
|
||||
<Route path="/rank" element={<Ranking />} />
|
||||
<Route path="/thanks" element={<Thanks />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import './App.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
function assignTeams(submissions, characters) {
|
||||
const charMap = Object.fromEntries(characters.map(c => [c.id, { ...c, players: [] }]))
|
||||
const assignments = { ...charMap }
|
||||
|
||||
const firstPicks = {}
|
||||
for (const c of characters) firstPicks[c.id] = []
|
||||
for (const sub of submissions) {
|
||||
const c1 = sub.picks[0]
|
||||
if (c1) firstPicks[c1].push(sub.name)
|
||||
}
|
||||
|
||||
// Step 1: Assign 1st choices (one per character, random on ties)
|
||||
const assigned = new Set()
|
||||
for (const c of characters) {
|
||||
const picks = firstPicks[c.id]
|
||||
if (picks.length === 1) {
|
||||
assignments[c.id].players.push(picks[0])
|
||||
assigned.add(picks[0])
|
||||
} else if (picks.length > 1) {
|
||||
const chosen = picks[Math.floor(Math.random() * picks.length)]
|
||||
assignments[c.id].players.push(chosen)
|
||||
assigned.add(chosen)
|
||||
}
|
||||
}
|
||||
|
||||
// Unassigned: all who didn't get 1st, with full picks
|
||||
const unassigned = submissions
|
||||
.filter(s => !assigned.has(s.name))
|
||||
.map(s => ({ name: s.name, picks: s.picks }))
|
||||
|
||||
// Step 2: Try 2nd, then 3rd (teams allowed)
|
||||
const stillUnassigned = []
|
||||
for (const u of unassigned) {
|
||||
const [c1, c2, c3] = u.picks
|
||||
const fallbacks = [c2, c3].filter(Boolean)
|
||||
let placed = false
|
||||
for (const cid of fallbacks) {
|
||||
if (assignments[cid]) {
|
||||
assignments[cid].players.push(u.name)
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!placed) stillUnassigned.push(u)
|
||||
}
|
||||
|
||||
// Step 3: Overflow -> least-filled character
|
||||
for (const u of stillUnassigned) {
|
||||
const least = characters.reduce((acc, c) =>
|
||||
assignments[c.id].players.length < assignments[acc.id].players.length ? c : acc
|
||||
)
|
||||
assignments[least.id].players.push(u.name)
|
||||
}
|
||||
|
||||
return assignments
|
||||
}
|
||||
|
||||
const ADMIN_KEY = 'whoDidItAdminUnlocked'
|
||||
|
||||
export default function Admin() {
|
||||
const [unlocked, setUnlocked] = useState(() => sessionStorage.getItem(ADMIN_KEY) === '1')
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordError, setPasswordError] = useState('')
|
||||
const [submissions, setSubmissions] = useState([])
|
||||
const [characters, setCharacters] = useState([])
|
||||
const [assignments, setAssignments] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const handleUnlock = async (e) => {
|
||||
e.preventDefault()
|
||||
setPasswordError('')
|
||||
const res = await fetch('/api/admin/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.ok) {
|
||||
sessionStorage.setItem(ADMIN_KEY, '1')
|
||||
setUnlocked(true)
|
||||
} else {
|
||||
setPasswordError('Incorrect password')
|
||||
}
|
||||
}
|
||||
|
||||
const load = () => {
|
||||
Promise.all([
|
||||
fetch('/api/submissions').then(r => r.json()),
|
||||
fetch('/api/characters').then(r => r.json()),
|
||||
]).then(([subs, chars]) => {
|
||||
setSubmissions(subs)
|
||||
setCharacters(chars)
|
||||
setAssignments(null)
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => { if (unlocked) load() }, [unlocked])
|
||||
|
||||
const handleAssign = () => {
|
||||
setAssignments(assignTeams(submissions, characters))
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
const password = prompt('Enter admin password')
|
||||
if (!password) return
|
||||
const res = await fetch('/api/submissions', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Admin-Password': password,
|
||||
},
|
||||
})
|
||||
if (res.ok) {
|
||||
load()
|
||||
} else {
|
||||
alert('Incorrect password')
|
||||
}
|
||||
}
|
||||
|
||||
// Build pick breakdown per character
|
||||
const pickBreakdown = {}
|
||||
for (const c of characters) {
|
||||
pickBreakdown[c.id] = { first: [], second: [], third: [] }
|
||||
}
|
||||
for (const sub of submissions) {
|
||||
const [c1, c2, c3] = sub.picks
|
||||
if (c1) pickBreakdown[c1].first.push(sub.name)
|
||||
if (c2) pickBreakdown[c2].second.push(sub.name)
|
||||
if (c3) pickBreakdown[c3].third.push(sub.name)
|
||||
}
|
||||
|
||||
if (!unlocked) {
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<div className="ornament">✦ Admin ✦</div>
|
||||
<h1>Who Did It?</h1>
|
||||
<p className="subtitle">Admin</p>
|
||||
<div className="gold-rule"></div>
|
||||
</header>
|
||||
<div className="player-setup" style={{ maxWidth: 400 }}>
|
||||
<h2>Enter admin password</h2>
|
||||
<form onSubmit={handleUnlock}>
|
||||
<div className="name-row">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setPasswordError('') }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary">Unlock</button>
|
||||
{passwordError && <p className="error-msg">{passwordError}</p>}
|
||||
</form>
|
||||
</div>
|
||||
<footer>
|
||||
<Link to="/" style={{ color: 'var(--gold)' }}>← Back to home</Link>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) return <div className="page">Loading…</div>
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<div className="ornament">✦ Admin ✦</div>
|
||||
<h1>Who Did It?</h1>
|
||||
<p className="subtitle">Results & Assignments</p>
|
||||
<div className="gold-rule"></div>
|
||||
<p style={{ marginTop: 8 }}>
|
||||
<Link to="/" style={{ color: 'var(--gold)', textDecoration: 'underline' }}>← Back to home</Link>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div style={{ width: '100%', maxWidth: 800, marginBottom: 32 }}>
|
||||
<h2 style={{ fontFamily: "'Playfair Display', serif", color: 'var(--gold)', marginBottom: 16, fontSize: '1.3rem' }}>
|
||||
Results
|
||||
</h2>
|
||||
<div className="characters-grid" style={{ marginBottom: 24 }}>
|
||||
{characters.map((ch) => {
|
||||
const b = pickBreakdown[ch.id] || { first: [], second: [], third: [] }
|
||||
return (
|
||||
<div key={ch.id} className="character-card" style={{ borderColor: ch.color + '66' }}>
|
||||
<div className="card-header" style={{ color: ch.color }}>
|
||||
<span className="character-icon">{ch.icon}</span>
|
||||
<span className="character-name">{ch.name}</span>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p style={{ fontSize: '0.9rem', marginBottom: 8 }}>
|
||||
<strong>1st:</strong> {b.first.length} — {b.first.join(', ') || '—'}
|
||||
</p>
|
||||
<p style={{ fontSize: '0.9rem', marginBottom: 8 }}>
|
||||
<strong>2nd:</strong> {b.second.length} — {b.second.join(', ') || '—'}
|
||||
</p>
|
||||
<p style={{ fontSize: '0.9rem' }}>
|
||||
<strong>3rd:</strong> {b.third.length} — {b.third.join(', ') || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 32 }}>
|
||||
<button type="button" className="btn-primary" onClick={handleAssign} style={{ width: 'auto' }}>
|
||||
Assign Teams
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleClear}
|
||||
style={{ width: 'auto', borderColor: 'rgba(231,76,60,0.6)', color: '#e74c3c' }}
|
||||
>
|
||||
Clear All Submissions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{assignments && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h2 style={{ fontFamily: "'Playfair Display', serif", color: 'var(--gold)', marginBottom: 16, fontSize: '1.3rem' }}>
|
||||
Final Assignments
|
||||
</h2>
|
||||
<div className="characters-grid">
|
||||
{characters.map((ch) => {
|
||||
const a = assignments[ch.id]
|
||||
return (
|
||||
<div key={ch.id} className="character-card" style={{ borderColor: ch.color }}>
|
||||
<div className="card-header" style={{ color: ch.color }}>
|
||||
<span className="character-icon">{ch.icon}</span>
|
||||
<span className="character-name">{ch.name}</span>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{a.players.length === 0 ? (
|
||||
<p style={{ fontStyle: 'italic', color: 'rgba(245,239,224,0.5)' }}>No one assigned</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none' }}>
|
||||
{a.players.map((p) => (
|
||||
<li key={p} style={{ padding: '4px 0' }}>🎭 {p}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer>Gather your suspects. Choose your costumes. Let the mystery begin.</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
|
||||
export default function NameEntry() {
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submissions, setSubmissions] = useState([])
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/submissions')
|
||||
.then(r => r.json())
|
||||
.then(setSubmissions)
|
||||
.catch(() => setError('Could not load data'))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
const lower = trimmed.toLowerCase()
|
||||
const res = await fetch('/api/names')
|
||||
const allowedNames = await res.json()
|
||||
const isAllowed = allowedNames.some(n => n.toLowerCase() === lower)
|
||||
if (!isAllowed) {
|
||||
setError("We don't have that name on our guest list.")
|
||||
return
|
||||
}
|
||||
|
||||
const alreadySubmitted = submissions.some(s => s.name.toLowerCase() === lower)
|
||||
if (alreadySubmitted) {
|
||||
setError('This name has already submitted picks.')
|
||||
return
|
||||
}
|
||||
|
||||
sessionStorage.setItem('whoDidItName', trimmed)
|
||||
navigate('/rank', { state: { name: trimmed } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<div className="ornament">✦ A Murder Mystery ✦</div>
|
||||
<h1>Who Did It?</h1>
|
||||
<p className="subtitle">A Clue Costume Planner</p>
|
||||
<div className="gold-rule"></div>
|
||||
<p className="instructions">Enter your name to rank your top 3 character choices.</p>
|
||||
</header>
|
||||
|
||||
<div className="player-setup">
|
||||
<h2>Enter your name</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="name-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter your name…"
|
||||
maxLength={30}
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError('') }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary" disabled={!name.trim()}>
|
||||
Continue
|
||||
</button>
|
||||
{error && <p className="error-msg">{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<Link to="/admin" style={{ opacity: 0.5, fontSize: '0.85rem' }}>Admin</Link>
|
||||
<br />
|
||||
Gather your suspects. Choose your costumes. Let the mystery begin.
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
|
||||
export default function Ranking() {
|
||||
const [characters, setCharacters] = useState([])
|
||||
const [ranks, setRanks] = useState({}) // { 1: charId, 2: charId, 3: charId }
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const name = location.state?.name || sessionStorage.getItem('whoDidItName')
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) {
|
||||
navigate('/', { replace: true })
|
||||
return
|
||||
}
|
||||
fetch('/api/characters')
|
||||
.then(r => r.json())
|
||||
.then(setCharacters)
|
||||
.catch(() => setError('Could not load characters'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [name, navigate])
|
||||
|
||||
const handleRank = (charId, rankNum) => {
|
||||
setRanks(prev => {
|
||||
const next = { ...prev }
|
||||
// Remove this rank from any other card
|
||||
for (const [r, cid] of Object.entries(next)) {
|
||||
if (cid === charId && Number(r) !== rankNum) delete next[r]
|
||||
if (Number(r) === rankNum && cid !== charId) delete next[r]
|
||||
}
|
||||
next[rankNum] = charId
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getRankForChar = (charId) => {
|
||||
for (const [r, cid] of Object.entries(ranks)) {
|
||||
if (cid === charId) return Number(r)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const isRankTaken = (rankNum) => !!ranks[rankNum]
|
||||
|
||||
const allThreeAssigned = ranks[1] && ranks[2] && ranks[3]
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!allThreeAssigned || !name) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/submissions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
picks: [ranks[1], ranks[2], ranks[3]],
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Submission failed')
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
navigate('/thanks', { state: { name } })
|
||||
} catch {
|
||||
setError('Submission failed')
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!name) return null
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<div className="ornament">✦ Rank Your Choices ✦</div>
|
||||
<h1>Who Did It?</h1>
|
||||
<p className="subtitle">Pick your top 3 characters, {name}</p>
|
||||
<div className="gold-rule"></div>
|
||||
<p className="instructions">Select 1st, 2nd, and 3rd choice. Click a rank on a card to assign it.</p>
|
||||
</header>
|
||||
|
||||
{loading && <p>Loading characters…</p>}
|
||||
{error && <p className="error-msg" style={{ marginBottom: 16 }}>{error}</p>}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<div className="characters-grid">
|
||||
{characters.map((ch) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className={`character-card ${getRankForChar(ch.id) ? 'card-ranked' : ''}`}
|
||||
style={{ '--card-color': ch.color }}
|
||||
>
|
||||
<div className="card-header" style={{ color: ch.color }}>
|
||||
<span className="character-icon">{ch.icon}</span>
|
||||
<span className="character-name">{ch.name}</span>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="costume-hint">{ch.costumeHint}</p>
|
||||
<div className="rank-badges">
|
||||
{[1, 2, 3].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={`rank-badge ${getRankForChar(ch.id) === r ? 'selected' : ''}`}
|
||||
onClick={() => handleRank(ch.id, r)}
|
||||
>
|
||||
{r}{r === 1 ? 'st' : r === 2 ? 'nd' : 'rd'} choice
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ maxWidth: 400, width: '100%' }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={!allThreeAssigned || submitting}
|
||||
>
|
||||
{submitting ? 'Submitting…' : 'Submit my picks'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
<footer>Gather your suspects. Choose your costumes. Let the mystery begin.</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useLocation, Link } from 'react-router-dom'
|
||||
|
||||
export default function Thanks() {
|
||||
const location = useLocation()
|
||||
const name = location.state?.name || sessionStorage.getItem('whoDidItName') || 'there'
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<div className="ornament">✦ Thank You ✦</div>
|
||||
<h1>Who Did It?</h1>
|
||||
<div className="gold-rule"></div>
|
||||
</header>
|
||||
|
||||
<div className="player-setup" style={{ textAlign: 'center', padding: '48px 32px' }}>
|
||||
<p style={{ fontSize: '1.4rem', marginBottom: 24 }}>
|
||||
Thanks, {name}! Your picks are in. Good luck. 🕯️
|
||||
</p>
|
||||
<Link to="/" style={{ color: 'var(--gold)', textDecoration: 'underline', fontStyle: 'italic' }}>
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<footer>Gather your suspects. Choose your costumes. Let the mystery begin.</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user