Skip to content

Commit bc4a1fd

Browse files
committed
feat(settings): add manage categories and difficulties
Settings now exposes full CRUD for categories and difficulty levels. Categories have name + colour. Difficulties add a score field and support drag-to-reorder via the HTML5 drag API, with `order` values written back to IndexedDB on drop and re-normalised after deletion. Delete is blocked on both entity types when questions reference them. Inline edit rows replace modal dialogs to keep the flow compact. Adds 12 tests covering add, edit, cancel, keyboard save, use-guard, ordering, and order re-normalisation. fix(lint): resolve unused vars and no-explicit-any in settings files Remove unused `i` parameter from ManageCategories map callback. Prefix unused `idx` parameter from ManageDifficulties onDragOver. Replace `as any` test casts with the correct `Parameters<typeof db.questions.add>[0]` type.
1 parent d9225c2 commit bc4a1fd

6 files changed

Lines changed: 810 additions & 53 deletions

File tree

package-lock.json

Lines changed: 13 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
},
2222
"dependencies": {
2323
"dexie": "^4.4.2",
24+
"dexie-react-hooks": "^4.4.0",
2425
"fuse.js": "^7.3.0",
2526
"gun": "^0.2020.1241",
2627
"peerjs": "^1.5.5",
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { useEffect, useState } from 'react'
2+
import { useLiveQuery } from 'dexie-react-hooks'
3+
import { db } from '@/db'
4+
import type { Category } from '@/db'
5+
import { Button, Input } from '@/components/ui'
6+
7+
interface EditState {
8+
id: string | null // null = new
9+
name: string
10+
color: string
11+
}
12+
13+
const DEFAULT_COLOR = '#6366f1'
14+
15+
function empty(): EditState {
16+
return { id: null, name: '', color: DEFAULT_COLOR }
17+
}
18+
19+
export default function ManageCategories() {
20+
const categories = useLiveQuery(() => db.categories.orderBy('name').toArray(), [])
21+
const [editing, setEditing] = useState<EditState | null>(null)
22+
const [error, setError] = useState<string | null>(null)
23+
const [busy, setBusy] = useState(false)
24+
25+
useEffect(() => {
26+
setError(null)
27+
}, [editing])
28+
29+
function startAdd() {
30+
setEditing(empty())
31+
}
32+
33+
function startEdit(cat: Category) {
34+
setEditing({ id: cat.id, name: cat.name, color: cat.color })
35+
}
36+
37+
function cancel() {
38+
setEditing(null)
39+
setError(null)
40+
}
41+
42+
async function save() {
43+
if (!editing) return
44+
const name = editing.name.trim()
45+
if (!name) {
46+
setError('Name is required')
47+
return
48+
}
49+
50+
setBusy(true)
51+
try {
52+
if (editing.id) {
53+
await db.categories.update(editing.id, { name, color: editing.color })
54+
} else {
55+
await db.categories.add({ id: crypto.randomUUID(), name, color: editing.color })
56+
}
57+
setEditing(null)
58+
} catch {
59+
setError('Failed to save — name may already be in use')
60+
} finally {
61+
setBusy(false)
62+
}
63+
}
64+
65+
async function remove(cat: Category) {
66+
const count = await db.questions.where('categoryId').equals(cat.id).count()
67+
if (count > 0) {
68+
setError(
69+
`"${cat.name}" is used by ${count} question${count === 1 ? '' : 's'} and cannot be deleted`
70+
)
71+
return
72+
}
73+
await db.categories.delete(cat.id)
74+
if (editing?.id === cat.id) setEditing(null)
75+
}
76+
77+
const isEditing = (id: string) => editing?.id === id
78+
79+
return (
80+
<section>
81+
<div className="flex items-center justify-between mb-3">
82+
<div>
83+
<h2 className="font-semibold text-base" style={{ color: 'var(--color-ink)' }}>
84+
Categories
85+
</h2>
86+
<p className="text-xs mt-0.5" style={{ color: 'var(--color-muted)' }}>
87+
Question classifiers used for filtering and search
88+
</p>
89+
</div>
90+
{!editing && (
91+
<Button variant="secondary" size="sm" onClick={startAdd}>
92+
+ Add
93+
</Button>
94+
)}
95+
</div>
96+
97+
{error && (
98+
<p
99+
className="text-xs mb-3 px-3 py-2 rounded"
100+
style={{ color: 'var(--color-red)', background: 'var(--color-red)1a' }}
101+
>
102+
{error}
103+
</p>
104+
)}
105+
106+
<div
107+
className="rounded-lg overflow-hidden border"
108+
style={{ borderColor: 'var(--color-border)' }}
109+
>
110+
{/* Add row */}
111+
{editing?.id === null && (
112+
<div
113+
className="flex items-center gap-2 px-3 py-2 border-b"
114+
style={{ borderColor: 'var(--color-border)', background: 'var(--color-surface)' }}
115+
>
116+
<input
117+
type="color"
118+
value={editing.color}
119+
onChange={e => setEditing(s => s && { ...s, color: e.target.value })}
120+
className="w-7 h-7 rounded cursor-pointer border"
121+
style={{ borderColor: 'var(--color-border)', padding: '1px' }}
122+
aria-label="Category colour"
123+
/>
124+
<Input
125+
value={editing.name}
126+
onChange={e => setEditing(s => s && { ...s, name: e.target.value })}
127+
placeholder="Category name"
128+
className="flex-1"
129+
onKeyDown={e => {
130+
if (e.key === 'Enter') save()
131+
if (e.key === 'Escape') cancel()
132+
}}
133+
autoFocus
134+
/>
135+
<Button variant="primary" size="sm" onClick={save} disabled={busy}>
136+
Save
137+
</Button>
138+
<Button variant="ghost" size="sm" onClick={cancel}>
139+
Cancel
140+
</Button>
141+
</div>
142+
)}
143+
144+
{/* List */}
145+
{categories?.length === 0 && !editing && (
146+
<p className="px-4 py-6 text-sm text-center" style={{ color: 'var(--color-muted)' }}>
147+
No categories yet — add one above
148+
</p>
149+
)}
150+
151+
{categories?.map(cat => (
152+
<div
153+
key={cat.id}
154+
className={`border-b last:border-b-0 ${isEditing(cat.id) ? '' : ''}`}
155+
style={{ borderColor: 'var(--color-border)' }}
156+
>
157+
{isEditing(cat.id) ? (
158+
/* Inline edit row */
159+
<div
160+
className="flex items-center gap-2 px-3 py-2"
161+
style={{ background: 'var(--color-surface)' }}
162+
>
163+
<input
164+
type="color"
165+
value={editing!.color}
166+
onChange={e => setEditing(s => s && { ...s, color: e.target.value })}
167+
className="w-7 h-7 rounded cursor-pointer border"
168+
style={{ borderColor: 'var(--color-border)', padding: '1px' }}
169+
aria-label="Category colour"
170+
/>
171+
<Input
172+
value={editing!.name}
173+
onChange={e => setEditing(s => s && { ...s, name: e.target.value })}
174+
className="flex-1"
175+
onKeyDown={e => {
176+
if (e.key === 'Enter') save()
177+
if (e.key === 'Escape') cancel()
178+
}}
179+
autoFocus
180+
/>
181+
<Button variant="primary" size="sm" onClick={save} disabled={busy}>
182+
Save
183+
</Button>
184+
<Button variant="ghost" size="sm" onClick={cancel}>
185+
Cancel
186+
</Button>
187+
</div>
188+
) : (
189+
/* Display row */
190+
<div className="flex items-center gap-3 px-3 py-2.5">
191+
<span
192+
className="w-3 h-3 rounded-full shrink-0"
193+
style={{ background: cat.color }}
194+
aria-hidden
195+
/>
196+
<span className="flex-1 text-sm" style={{ color: 'var(--color-ink)' }}>
197+
{cat.name}
198+
</span>
199+
<button
200+
onClick={() => startEdit(cat)}
201+
className="text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
202+
style={{ color: 'var(--color-muted)' }}
203+
aria-label={`Edit ${cat.name}`}
204+
>
205+
Edit
206+
</button>
207+
<button
208+
onClick={() => remove(cat)}
209+
className="text-xs px-2 py-1 rounded transition-colors hover:bg-black/5"
210+
style={{ color: 'var(--color-red)' }}
211+
aria-label={`Delete ${cat.name}`}
212+
>
213+
Delete
214+
</button>
215+
</div>
216+
)}
217+
</div>
218+
))}
219+
</div>
220+
</section>
221+
)
222+
}

0 commit comments

Comments
 (0)