Skip to content

Commit ce2dda1

Browse files
committed
feat(players-teams): player CRUD, page scaffold, and routing
Closes #112
1 parent 9050c7b commit ce2dda1

7 files changed

Lines changed: 850 additions & 1 deletion

File tree

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const Layouts = lazy(() => import('@/pages/admin/Layouts'))
1111
const Notes = lazy(() => import('@/pages/admin/Notes'))
1212
const NoteDetail = lazy(() => import('@/pages/admin/NoteDetail'))
1313
const Settings = lazy(() => import('@/pages/admin/Settings'))
14+
const PlayersTeams = lazy(() => import('@/pages/admin/PlayersTeams'))
1415

1516
// Pages — Player (lazy-loaded per route)
1617
const Join = lazy(() => import('@/pages/player/Join'))
@@ -37,6 +38,7 @@ export default function App() {
3738
<Route path="/admin/games" element={<Games />} />
3839
<Route path="/admin/game/:id" element={<GameMaster />} />
3940
<Route path="/admin/layouts/:gameId" element={<Layouts />} />
41+
<Route path="/admin/players-teams" element={<PlayersTeams />} />
4042
<Route path="/admin/notes" element={<Notes />} />
4143
<Route path="/admin/notes/:id" element={<NoteDetail />} />
4244
<Route path="/admin/settings" element={<Settings />} />

src/components/AdminLayout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import {
44
LayoutDashboard,
55
CircleHelp,
66
Trophy,
7+
Users,
78
NotebookPen,
89
Settings,
9-
Users,
1010
ChevronLeft,
1111
ChevronRight,
1212
} from 'lucide-react'
@@ -23,6 +23,7 @@ const nav: { to: string; label: string; icon: LucideIcon }[] = [
2323
{ to: '/admin', label: 'Dashboard', icon: LayoutDashboard },
2424
{ to: '/admin/questions', label: 'Questions', icon: CircleHelp },
2525
{ to: '/admin/games', label: 'Games', icon: Trophy },
26+
{ to: '/admin/players-teams', label: 'Players & Teams', icon: Users },
2627
{ to: '/admin/notes', label: 'Notes', icon: NotebookPen },
2728
{ to: '/admin/settings', label: 'Settings', icon: Settings },
2829
]
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { useEffect, useState } from 'react'
2+
import { useLiveQuery } from 'dexie-react-hooks'
3+
import { db } from '@/db'
4+
import type { ManagedPlayer } from '@/types/players-teams'
5+
import { Modal, Button, Input } from '@/components/ui'
6+
7+
interface Props {
8+
/** Existing player to edit, or null to create a new one. */
9+
player: ManagedPlayer | null
10+
/** Pre-selected team ID when creating from within a team context. */
11+
defaultTeamId?: string
12+
open: boolean
13+
onClose: () => void
14+
}
15+
16+
interface FormState {
17+
name: string
18+
labelIds: string[]
19+
}
20+
21+
function emptyForm(): FormState {
22+
return { name: '', labelIds: [] }
23+
}
24+
25+
function playerToForm(p: ManagedPlayer): FormState {
26+
return { name: p.name, labelIds: p.labelIds }
27+
}
28+
29+
export default function PlayerForm({ player, defaultTeamId, open, onClose }: Props) {
30+
const isNew = player === null
31+
const labels = useLiveQuery(() => db.managedLabels.orderBy('name').toArray(), [])
32+
const teams = useLiveQuery(() => db.managedTeams.orderBy('name').toArray(), [])
33+
34+
const [form, setForm] = useState<FormState>(emptyForm)
35+
const [teamIds, setTeamIds] = useState<string[]>([])
36+
const [error, setError] = useState<string | null>(null)
37+
const [busy, setBusy] = useState(false)
38+
39+
// Reset form whenever the modal opens
40+
useEffect(() => {
41+
if (!open) return
42+
if (player) {
43+
setForm(playerToForm(player))
44+
setTeamIds(player.teamIds)
45+
} else {
46+
setForm(emptyForm())
47+
setTeamIds(defaultTeamId ? [defaultTeamId] : [])
48+
}
49+
setError(null)
50+
}, [open, player, defaultTeamId])
51+
52+
function toggleLabel(id: string) {
53+
setForm(f => ({
54+
...f,
55+
labelIds: f.labelIds.includes(id) ? f.labelIds.filter(l => l !== id) : [...f.labelIds, id],
56+
}))
57+
}
58+
59+
function toggleTeam(id: string) {
60+
setTeamIds(prev => (prev.includes(id) ? prev.filter(t => t !== id) : [...prev, id]))
61+
}
62+
63+
async function save() {
64+
const name = form.name.trim()
65+
if (!name) {
66+
setError('Name is required')
67+
return
68+
}
69+
70+
setBusy(true)
71+
try {
72+
if (player) {
73+
// Edit: update player fields; team sync handled by the dual-pane view
74+
await db.managedPlayers.update(player.id, {
75+
name,
76+
labelIds: form.labelIds,
77+
teamIds,
78+
})
79+
// Sync team.playerIds for any teams whose membership changed
80+
await syncTeamMembership(player.id, player.teamIds, teamIds)
81+
} else {
82+
const id = crypto.randomUUID()
83+
await db.managedPlayers.add({
84+
id,
85+
name,
86+
teamIds,
87+
labelIds: form.labelIds,
88+
archivedAt: null,
89+
totalScore: 0,
90+
gameLog: [],
91+
})
92+
await syncTeamMembership(id, [], teamIds)
93+
}
94+
onClose()
95+
} catch {
96+
setError('Failed to save — please try again')
97+
} finally {
98+
setBusy(false)
99+
}
100+
}
101+
102+
return (
103+
<Modal
104+
open={open}
105+
onClose={onClose}
106+
title={isNew ? 'New player' : 'Edit player'}
107+
maxWidth="440px"
108+
>
109+
<div className="flex flex-col gap-4">
110+
{error && (
111+
<p
112+
className="text-xs px-3 py-2 rounded"
113+
style={{ color: 'var(--color-red)', background: 'var(--color-red)1a' }}
114+
>
115+
{error}
116+
</p>
117+
)}
118+
119+
<Input
120+
label="Name"
121+
value={form.name}
122+
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
123+
placeholder="Player name"
124+
onKeyDown={e => {
125+
if (e.key === 'Enter') save()
126+
}}
127+
autoFocus
128+
/>
129+
130+
{/* Team assignment */}
131+
{teams && teams.length > 0 && (
132+
<div className="flex flex-col gap-1.5">
133+
<span className="text-xs font-medium" style={{ color: 'var(--color-muted)' }}>
134+
Teams
135+
</span>
136+
<div className="flex flex-wrap gap-2">
137+
{teams.map(team => {
138+
const selected = teamIds.includes(team.id)
139+
return (
140+
<button
141+
key={team.id}
142+
type="button"
143+
onClick={() => toggleTeam(team.id)}
144+
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border transition-all"
145+
style={{
146+
borderColor: selected ? team.color : 'var(--color-border)',
147+
background: selected ? team.color + '22' : 'transparent',
148+
color: selected ? team.color : 'var(--color-muted)',
149+
}}
150+
aria-pressed={selected}
151+
aria-label={`${selected ? 'Remove from' : 'Add to'} ${team.name}`}
152+
>
153+
<span>{team.icon}</span>
154+
{team.name}
155+
</button>
156+
)
157+
})}
158+
</div>
159+
</div>
160+
)}
161+
162+
{/* Label assignment */}
163+
{labels && labels.length > 0 && (
164+
<div className="flex flex-col gap-1.5">
165+
<span className="text-xs font-medium" style={{ color: 'var(--color-muted)' }}>
166+
Labels
167+
</span>
168+
<div className="flex flex-wrap gap-2">
169+
{labels.map(label => {
170+
const selected = form.labelIds.includes(label.id)
171+
return (
172+
<button
173+
key={label.id}
174+
type="button"
175+
onClick={() => toggleLabel(label.id)}
176+
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border transition-all"
177+
style={{
178+
borderColor: selected ? label.color : 'var(--color-border)',
179+
background: selected ? label.color + '22' : 'transparent',
180+
color: selected ? label.color : 'var(--color-muted)',
181+
}}
182+
aria-pressed={selected}
183+
aria-label={`${selected ? 'Remove' : 'Add'} label ${label.name}`}
184+
>
185+
<span
186+
className="w-2 h-2 rounded-full"
187+
style={{ background: label.color }}
188+
aria-hidden
189+
/>
190+
{label.name}
191+
</button>
192+
)
193+
})}
194+
</div>
195+
</div>
196+
)}
197+
198+
<div className="flex justify-end gap-2 pt-1">
199+
<Button variant="ghost" onClick={onClose} disabled={busy}>
200+
Cancel
201+
</Button>
202+
<Button variant="primary" onClick={save} disabled={busy}>
203+
{busy ? 'Saving...' : isNew ? 'Create player' : 'Save changes'}
204+
</Button>
205+
</div>
206+
</div>
207+
</Modal>
208+
)
209+
}
210+
211+
// ── Helpers ───────────────────────────────────────────────────────────────────
212+
213+
/**
214+
* Sync team.playerIds for any teams whose membership changed.
215+
* Runs all updates in a single transaction.
216+
*/
217+
async function syncTeamMembership(
218+
playerId: string,
219+
prevTeamIds: string[],
220+
nextTeamIds: string[]
221+
): Promise<void> {
222+
const added = nextTeamIds.filter(id => !prevTeamIds.includes(id))
223+
const removed = prevTeamIds.filter(id => !nextTeamIds.includes(id))
224+
if (added.length === 0 && removed.length === 0) return
225+
226+
await db.transaction('rw', [db.managedTeams], async () => {
227+
const affectedIds = [...new Set([...added, ...removed])]
228+
const teams = await db.managedTeams.bulkGet(affectedIds)
229+
await Promise.all(
230+
teams.map(team => {
231+
if (!team) return
232+
const isAdded = added.includes(team.id)
233+
const newPlayerIds = isAdded
234+
? team.playerIds.includes(playerId)
235+
? team.playerIds
236+
: [...team.playerIds, playerId]
237+
: team.playerIds.filter(id => id !== playerId)
238+
return db.managedTeams.update(team.id, { playerIds: newPlayerIds })
239+
})
240+
)
241+
})
242+
}

0 commit comments

Comments
 (0)