Initial commit: Who Did It Clue character preference collector

Made-with: Cursor
This commit is contained in:
2026-03-04 22:00:59 -05:00
commit 9b71099658
27 changed files with 4048 additions and 0 deletions
+14
View File
@@ -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>
+1756
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+1
View File
@@ -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

+284
View File
@@ -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%;
}
}
+16
View File
@@ -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>
)
}
+13
View File
@@ -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>,
)
+260
View File
@@ -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"> &nbsp; Admin &nbsp; </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"> &nbsp; Admin &nbsp; </div>
<h1>Who Did It?</h1>
<p className="subtitle">Results &amp; 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>
)
}
+79
View File
@@ -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"> &nbsp; A Murder Mystery &nbsp; </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>
)
}
+140
View File
@@ -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"> &nbsp; Rank Your Choices &nbsp; </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>
)
}
+27
View File
@@ -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"> &nbsp; Thank You &nbsp; </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>
)
}
+14
View File
@@ -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,
},
},
},
})