@exxatdesignux/ui 0.2.6 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/template/.agents/skills/shadcn/SKILL.md +242 -0
- package/template/.agents/skills/shadcn/agents/openai.yml +5 -0
- package/template/.agents/skills/shadcn/assets/shadcn-small.png +0 -0
- package/template/.agents/skills/shadcn/assets/shadcn.png +0 -0
- package/template/.agents/skills/shadcn/cli.md +257 -0
- package/template/.agents/skills/shadcn/customization.md +202 -0
- package/template/.agents/skills/shadcn/evals/evals.json +47 -0
- package/template/.agents/skills/shadcn/mcp.md +94 -0
- package/template/.agents/skills/shadcn/rules/base-vs-radix.md +306 -0
- package/template/.agents/skills/shadcn/rules/composition.md +195 -0
- package/template/.agents/skills/shadcn/rules/forms.md +192 -0
- package/template/.agents/skills/shadcn/rules/icons.md +101 -0
- package/template/.agents/skills/shadcn/rules/styling.md +162 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +712 -0
- package/template/.cursor/rules/exxat-accessibility.mdc +33 -0
- package/template/.cursor/rules/exxat-command-menu.mdc +23 -0
- package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +53 -0
- package/template/.cursor/rules/exxat-data-tables.mdc +31 -0
- package/template/.cursor/rules/exxat-ds-agents.mdc +26 -0
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +100 -0
- package/template/.cursor/rules/exxat-list-page-connected-views.mdc +16 -0
- package/template/.cursor/rules/exxat-no-toast.mdc +26 -0
- package/template/.cursor/rules/exxat-page-vs-drawer.mdc +22 -0
- package/template/.cursor/rules/exxat-table-properties-drawer.mdc +40 -0
- package/template/AGENTS.md +52 -11
- package/template/app/(app)/dashboard/page.tsx +1 -1
- package/template/app/(app)/data-list/[id]/page.tsx +24 -8
- package/template/app/(app)/data-list/new/page.tsx +7 -4
- package/template/app/(app)/data-list/page.tsx +1 -1
- package/template/app/(app)/examples/page.tsx +41 -0
- package/template/app/(app)/question-bank/page.tsx +3 -3
- package/template/app/globals.css +1 -1
- package/template/components/app-sidebar.tsx +52 -35
- package/template/components/compliance-table.tsx +79 -0
- package/template/components/data-list-client.tsx +36 -25
- package/template/components/data-list-table.tsx +797 -10
- package/template/components/data-views/finder-panel-view.tsx +405 -0
- package/template/components/data-views/folder-grid-view.tsx +86 -0
- package/template/components/data-views/index.ts +59 -0
- package/template/components/data-views/list-page-split-details-placeholder.tsx +39 -0
- package/template/components/data-views/list-page-split-hub-chrome.tsx +60 -0
- package/template/components/data-views/list-page-split-hub-tokens.ts +16 -0
- package/template/components/data-views/list-page-tree-column-header.tsx +31 -0
- package/template/components/data-views/list-page-tree-panel-shell.tsx +91 -0
- package/template/components/data-views/list-page-view-frame.tsx +53 -0
- package/template/components/data-views/os-folder-glyph.tsx +121 -0
- package/template/components/folder-details-shell.tsx +230 -0
- package/template/components/hub-tree-panel-view.tsx +672 -0
- package/template/components/list-hub-status-badge.tsx +17 -3
- package/template/components/placements-page-header.tsx +14 -8
- package/template/components/placements-table-columns.tsx +8 -8
- package/template/components/question-bank-client.tsx +157 -40
- package/template/components/question-bank-new-folder-sheet.tsx +248 -0
- package/template/components/question-bank-os-folder-view.tsx +648 -0
- package/template/components/question-bank-page-header.tsx +3 -3
- package/template/components/question-bank-panel-activator.tsx +9 -0
- package/template/components/question-bank-secondary-nav.tsx +226 -0
- package/template/components/question-bank-table.tsx +707 -22
- package/template/components/secondary-panel.tsx +41 -107
- package/template/components/sites-table.tsx +66 -0
- package/template/components/team-client.tsx +7 -0
- package/template/components/team-table.tsx +156 -1
- package/template/components/templates/list-page.tsx +2 -2
- package/template/components/ui/avatar.tsx +1 -1
- package/template/components/ui/badge.tsx +1 -1
- package/template/components/ui/banner.tsx +1 -1
- package/template/components/ui/breadcrumb.tsx +1 -1
- package/template/components/ui/button.tsx +1 -1
- package/template/components/ui/calendar.tsx +1 -1
- package/template/components/ui/card.tsx +1 -1
- package/template/components/ui/chart.tsx +1 -1
- package/template/components/ui/checkbox.tsx +1 -1
- package/template/components/ui/coach-mark.tsx +1 -1
- package/template/components/ui/collapsible.tsx +1 -1
- package/template/components/ui/command.tsx +1 -1
- package/template/components/ui/date-picker-field.tsx +1 -1
- package/template/components/ui/dialog.tsx +1 -1
- package/template/components/ui/drag-handle-grip.tsx +1 -1
- package/template/components/ui/drawer.tsx +1 -1
- package/template/components/ui/dropdown-menu.tsx +1 -1
- package/template/components/ui/field.tsx +1 -1
- package/template/components/ui/form.tsx +1 -1
- package/template/components/ui/input-group.tsx +1 -1
- package/template/components/ui/input-mask.tsx +1 -1
- package/template/components/ui/input.tsx +1 -1
- package/template/components/ui/kbd.tsx +1 -1
- package/template/components/ui/label.tsx +1 -1
- package/template/components/ui/payment-card-fields.tsx +1 -1
- package/template/components/ui/popover.tsx +1 -1
- package/template/components/ui/radio-group.tsx +1 -1
- package/template/components/ui/resizable.tsx +68 -0
- package/template/components/ui/select.tsx +1 -1
- package/template/components/ui/selection-tile-grid.tsx +1 -1
- package/template/components/ui/separator.tsx +1 -1
- package/template/components/ui/sheet.tsx +1 -1
- package/template/components/ui/sidebar.tsx +1 -1
- package/template/components/ui/skeleton.tsx +1 -1
- package/template/components/ui/sonner.tsx +1 -1
- package/template/components/ui/status-badge.tsx +1 -1
- package/template/components/ui/table.tsx +1 -1
- package/template/components/ui/tabs.tsx +1 -1
- package/template/components/ui/textarea.tsx +1 -1
- package/template/components/ui/tip.tsx +1 -1
- package/template/components/ui/toggle-group.tsx +1 -1
- package/template/components/ui/toggle-switch.tsx +1 -1
- package/template/components/ui/toggle.tsx +1 -1
- package/template/components/ui/tooltip.tsx +1 -1
- package/template/components/ui/view-segmented-control.tsx +1 -1
- package/template/docs/data-views-pattern.md +7 -0
- package/template/hooks/use-app-theme.ts +1 -1
- package/template/hooks/use-coach-mark.ts +1 -1
- package/template/hooks/use-location-hash.ts +15 -0
- package/template/hooks/use-mobile.ts +1 -1
- package/template/hooks/use-mod-key-label.ts +1 -1
- package/template/hooks/use-sidebar-reflow-zoom.ts +40 -0
- package/template/lib/ask-leo-route-context.ts +25 -57
- package/template/lib/coach-mark-registry.ts +13 -13
- package/template/lib/command-menu-config.ts +28 -23
- package/template/lib/command-menu-search-data.ts +10 -9
- package/template/lib/data-list-view-surface.ts +12 -1
- package/template/lib/data-list-view.ts +6 -3
- package/template/lib/date-filter.ts +1 -1
- package/template/lib/mock/dashboard.ts +11 -11
- package/template/lib/mock/navigation.tsx +22 -63
- package/template/lib/mock/placements-kpi.ts +19 -19
- package/template/lib/mock/question-bank-folders.ts +167 -0
- package/template/lib/mock/question-bank-inspector.ts +109 -0
- package/template/lib/mock/question-bank-kpi.ts +1 -1
- package/template/lib/mock/question-bank.ts +80 -0
- package/template/lib/question-bank-nav.ts +91 -0
- package/template/lib/utils.ts +1 -1
- package/template/next.config.mjs +8 -0
- package/template/package.json +1 -0
- package/template/public/folders/icons8-folder-windows-11.svg +1 -0
- package/template/app/(app)/compliance/page.tsx +0 -10
- package/template/app/(app)/rotations/page.tsx +0 -15
- package/template/app/(app)/sites/all/page.tsx +0 -13
- package/template/app/(app)/team/page.tsx +0 -10
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OS-style icon folder view for Question bank — hierarchy, appearance (color + icon),
|
|
5
|
+
* create (floating sheet drawer like `ExportDrawer` + preview), inline rename, move / delete, and move questions between folders.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as React from "react"
|
|
9
|
+
import { Button } from "@/components/ui/button"
|
|
10
|
+
import {
|
|
11
|
+
Dialog,
|
|
12
|
+
DialogContent,
|
|
13
|
+
DialogDescription,
|
|
14
|
+
DialogFooter,
|
|
15
|
+
DialogHeader,
|
|
16
|
+
DialogTitle,
|
|
17
|
+
} from "@/components/ui/dialog"
|
|
18
|
+
import {
|
|
19
|
+
DropdownMenu,
|
|
20
|
+
DropdownMenuContent,
|
|
21
|
+
DropdownMenuItem,
|
|
22
|
+
DropdownMenuSeparator,
|
|
23
|
+
DropdownMenuSub,
|
|
24
|
+
DropdownMenuSubContent,
|
|
25
|
+
DropdownMenuSubTrigger,
|
|
26
|
+
DropdownMenuTrigger,
|
|
27
|
+
} from "@/components/ui/dropdown-menu"
|
|
28
|
+
import { Input } from "@/components/ui/input"
|
|
29
|
+
import { Label } from "@/components/ui/label"
|
|
30
|
+
import { Tip } from "@/components/ui/tip"
|
|
31
|
+
import { cn } from "@/lib/utils"
|
|
32
|
+
import type { QuestionBankItem } from "@/lib/mock/question-bank"
|
|
33
|
+
import {
|
|
34
|
+
collectFolderDescendantIds,
|
|
35
|
+
isValidFolderMove,
|
|
36
|
+
newFolderId,
|
|
37
|
+
QUESTION_BANK_FOLDER_COLOR_STYLES,
|
|
38
|
+
QUESTION_BANK_FOLDER_ICON_OPTIONS,
|
|
39
|
+
type QuestionBankFolder,
|
|
40
|
+
type QuestionBankFolderColorKey,
|
|
41
|
+
} from "@/lib/mock/question-bank-folders"
|
|
42
|
+
import {
|
|
43
|
+
QUESTION_BANK_STATUS_BADGE_CLASS,
|
|
44
|
+
QUESTION_BANK_STATUS_ICON,
|
|
45
|
+
QUESTION_BANK_STATUS_LABEL,
|
|
46
|
+
} from "@/lib/list-status-badges"
|
|
47
|
+
import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
|
|
48
|
+
import {
|
|
49
|
+
ListPageViewFrame,
|
|
50
|
+
LIST_PAGE_VIEW_FRAME_MAX_WIDE,
|
|
51
|
+
} from "@/components/data-views/list-page-view-frame"
|
|
52
|
+
import { OsFolderGlyph } from "@/components/data-views/os-folder-glyph"
|
|
53
|
+
import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
|
|
54
|
+
|
|
55
|
+
const COLOR_OPTIONS: QuestionBankFolderColorKey[] = [
|
|
56
|
+
"brand",
|
|
57
|
+
"success",
|
|
58
|
+
"warning",
|
|
59
|
+
"destructive",
|
|
60
|
+
"muted",
|
|
61
|
+
"chart1",
|
|
62
|
+
"chart2",
|
|
63
|
+
"chart3",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
export interface QuestionBankOsFolderViewProps {
|
|
67
|
+
folders: QuestionBankFolder[]
|
|
68
|
+
onFoldersChange: React.Dispatch<React.SetStateAction<QuestionBankFolder[]>>
|
|
69
|
+
questions: QuestionBankItem[]
|
|
70
|
+
onQuestionsChange: React.Dispatch<React.SetStateAction<QuestionBankItem[]>>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function folderTrail(folders: QuestionBankFolder[], folderId: string | null): QuestionBankFolder[] {
|
|
74
|
+
if (!folderId) return []
|
|
75
|
+
const byId = new Map(folders.map(f => [f.id, f]))
|
|
76
|
+
const trail: QuestionBankFolder[] = []
|
|
77
|
+
let cur: string | null = folderId
|
|
78
|
+
while (cur) {
|
|
79
|
+
const f = byId.get(cur)
|
|
80
|
+
if (!f) break
|
|
81
|
+
trail.unshift(f)
|
|
82
|
+
cur = f.parentId
|
|
83
|
+
}
|
|
84
|
+
return trail
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function folderHoverCounts(
|
|
88
|
+
folder: QuestionBankFolder,
|
|
89
|
+
folders: QuestionBankFolder[],
|
|
90
|
+
questions: QuestionBankItem[],
|
|
91
|
+
) {
|
|
92
|
+
const subfolders = folders.filter(f => f.parentId === folder.id).length
|
|
93
|
+
const questionsInFolder = questions.filter(q => q.folderId === folder.id).length
|
|
94
|
+
return { subfolders, questionsInFolder }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validMoveTargets(
|
|
98
|
+
folders: QuestionBankFolder[],
|
|
99
|
+
movingId: string,
|
|
100
|
+
): Array<{ id: string | null; label: string }> {
|
|
101
|
+
const out: Array<{ id: string | null; label: string }> = [{ id: null, label: "Question bank (root)" }]
|
|
102
|
+
for (const f of folders) {
|
|
103
|
+
if (f.id === movingId) continue
|
|
104
|
+
if (!isValidFolderMove(folders, movingId, f.id)) continue
|
|
105
|
+
out.push({ id: f.id, label: f.name })
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function QuestionBankOsFolderView({
|
|
111
|
+
folders,
|
|
112
|
+
onFoldersChange,
|
|
113
|
+
questions,
|
|
114
|
+
onQuestionsChange,
|
|
115
|
+
}: QuestionBankOsFolderViewProps) {
|
|
116
|
+
const [currentId, setCurrentId] = React.useState<string | null>(null)
|
|
117
|
+
|
|
118
|
+
const childFolders = React.useMemo(
|
|
119
|
+
() => folders.filter(f => f.parentId === currentId),
|
|
120
|
+
[folders, currentId],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const filesHere = React.useMemo(
|
|
124
|
+
() => questions.filter(q => q.folderId === currentId),
|
|
125
|
+
[questions, currentId],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const trail = React.useMemo(() => folderTrail(folders, currentId), [folders, currentId])
|
|
129
|
+
|
|
130
|
+
const [createFolderOpen, setCreateFolderOpen] = React.useState(false)
|
|
131
|
+
const [customizeFolderOpen, setCustomizeFolderOpen] = React.useState(false)
|
|
132
|
+
const [customizingFolder, setCustomizingFolder] = React.useState<QuestionBankFolder | null>(null)
|
|
133
|
+
|
|
134
|
+
const [renamingFolderId, setRenamingFolderId] = React.useState<string | null>(null)
|
|
135
|
+
const [renameValue, setRenameValue] = React.useState("")
|
|
136
|
+
const renameInputRef = React.useRef<HTMLInputElement>(null)
|
|
137
|
+
|
|
138
|
+
const [appearanceDialog, setAppearanceDialog] = React.useState<QuestionBankFolder | null>(null)
|
|
139
|
+
const [moveFolderId, setMoveFolderId] = React.useState<string | null>(null)
|
|
140
|
+
const [deleteFolderId, setDeleteFolderId] = React.useState<string | null>(null)
|
|
141
|
+
|
|
142
|
+
React.useEffect(() => {
|
|
143
|
+
if (renamingFolderId && renameInputRef.current) {
|
|
144
|
+
renameInputRef.current.focus()
|
|
145
|
+
renameInputRef.current.select()
|
|
146
|
+
}
|
|
147
|
+
}, [renamingFolderId])
|
|
148
|
+
|
|
149
|
+
function openCreateFolderPanel() {
|
|
150
|
+
setCreateFolderOpen(true)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function startRename(folder: QuestionBankFolder) {
|
|
154
|
+
setRenamingFolderId(folder.id)
|
|
155
|
+
setRenameValue(folder.name)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function commitRename() {
|
|
159
|
+
if (!renamingFolderId) return
|
|
160
|
+
const v = renameValue.trim()
|
|
161
|
+
const folder = folders.find(f => f.id === renamingFolderId)
|
|
162
|
+
if (!folder) {
|
|
163
|
+
setRenamingFolderId(null)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (!v) {
|
|
167
|
+
setRenameValue(folder.name)
|
|
168
|
+
setRenamingFolderId(null)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
onFoldersChange(prev => prev.map(f => (f.id === renamingFolderId ? { ...f, name: v } : f)))
|
|
172
|
+
setRenamingFolderId(null)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function cancelRename() {
|
|
176
|
+
setRenamingFolderId(null)
|
|
177
|
+
setRenameValue("")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function commitMoveFolder(targetParentId: string | null) {
|
|
181
|
+
if (!moveFolderId) return
|
|
182
|
+
if (!isValidFolderMove(folders, moveFolderId, targetParentId)) return
|
|
183
|
+
onFoldersChange(prev =>
|
|
184
|
+
prev.map(f => (f.id === moveFolderId ? { ...f, parentId: targetParentId } : f)),
|
|
185
|
+
)
|
|
186
|
+
setMoveFolderId(null)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function commitDeleteFolder() {
|
|
190
|
+
if (!deleteFolderId) return
|
|
191
|
+
const victim = folders.find(f => f.id === deleteFolderId)
|
|
192
|
+
if (!victim) return
|
|
193
|
+
const parent = victim.parentId
|
|
194
|
+
const desc = collectFolderDescendantIds(folders, deleteFolderId)
|
|
195
|
+
const remaining = folders.filter(f => !desc.has(f.id))
|
|
196
|
+
if (remaining.length === 0) {
|
|
197
|
+
setDeleteFolderId(null)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const parentStillExists = parent !== null && remaining.some(f => f.id === parent)
|
|
201
|
+
const fallbackRoot = remaining.find(f => f.parentId === null)?.id
|
|
202
|
+
const reassignTarget =
|
|
203
|
+
parentStillExists ? parent : (fallbackRoot ?? remaining[0]!.id)
|
|
204
|
+
|
|
205
|
+
onFoldersChange(remaining)
|
|
206
|
+
onQuestionsChange(prev =>
|
|
207
|
+
prev.map(q => (desc.has(q.folderId) ? { ...q, folderId: reassignTarget } : q)),
|
|
208
|
+
)
|
|
209
|
+
if (currentId && desc.has(currentId)) setCurrentId(parentStillExists ? parent : null)
|
|
210
|
+
setDeleteFolderId(null)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function moveQuestionToFolder(questionId: string, folderId: string) {
|
|
214
|
+
onQuestionsChange(prev =>
|
|
215
|
+
prev.map(q => (q.id === questionId ? { ...q, folderId } : q)),
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<ListPageViewFrame
|
|
221
|
+
className="flex min-h-0 flex-1 flex-col gap-3"
|
|
222
|
+
maxWidthClassName={LIST_PAGE_VIEW_FRAME_MAX_WIDE}
|
|
223
|
+
>
|
|
224
|
+
{/* Breadcrumb navigation */}
|
|
225
|
+
<nav aria-label="Folder breadcrumb" className="flex items-center gap-1.5 min-w-0 overflow-hidden pb-4">
|
|
226
|
+
<span className="flex items-center gap-1.5 shrink-0">
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
onClick={() => setCurrentId(null)}
|
|
230
|
+
className="font-sans text-sm text-muted-foreground hover:text-interactive-hover-foreground transition-colors tracking-normal"
|
|
231
|
+
>
|
|
232
|
+
Question bank
|
|
233
|
+
</button>
|
|
234
|
+
{trail.length > 0 && (
|
|
235
|
+
<i className="fa-light fa-chevron-right text-xs text-muted-foreground/50" aria-hidden="true" />
|
|
236
|
+
)}
|
|
237
|
+
</span>
|
|
238
|
+
{trail.map((f, i) => (
|
|
239
|
+
<span key={f.id} className="flex items-center gap-1.5 shrink-0">
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
onClick={() => setCurrentId(f.id)}
|
|
243
|
+
className="font-sans text-sm text-muted-foreground hover:text-interactive-hover-foreground transition-colors tracking-normal"
|
|
244
|
+
>
|
|
245
|
+
{f.name}
|
|
246
|
+
</button>
|
|
247
|
+
{i < trail.length - 1 && (
|
|
248
|
+
<i className="fa-light fa-chevron-right text-xs text-muted-foreground/50" aria-hidden="true" />
|
|
249
|
+
)}
|
|
250
|
+
</span>
|
|
251
|
+
))}
|
|
252
|
+
</nav>
|
|
253
|
+
|
|
254
|
+
{/* Icon grid — new folder first, then folders + question files */}
|
|
255
|
+
<div
|
|
256
|
+
className="grid w-full grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
|
|
257
|
+
role="list"
|
|
258
|
+
aria-label="Folders and questions in this location"
|
|
259
|
+
>
|
|
260
|
+
{/* First tile: create folder */}
|
|
261
|
+
<div role="listitem" className="relative">
|
|
262
|
+
<Tip
|
|
263
|
+
side="bottom"
|
|
264
|
+
label={(
|
|
265
|
+
<span className="flex max-w-[14rem] flex-col gap-1.5 text-start">
|
|
266
|
+
<span className="text-xs font-semibold text-background">New folder</span>
|
|
267
|
+
<span className="text-[11px] leading-snug text-background/85">
|
|
268
|
+
Opens the same floating panel as Export to name the folder, pick tint and icon, then create it here.
|
|
269
|
+
</span>
|
|
270
|
+
</span>
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
className={cn(
|
|
276
|
+
"flex w-full flex-col items-center gap-2 rounded-xl border border-dashed border-muted-foreground/45 bg-muted/10 p-3 text-center transition-all",
|
|
277
|
+
"hover:border-muted-foreground/70 hover:bg-muted/20",
|
|
278
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
279
|
+
)}
|
|
280
|
+
onClick={openCreateFolderPanel}
|
|
281
|
+
aria-label="Create new folder"
|
|
282
|
+
>
|
|
283
|
+
<OsFolderGlyph
|
|
284
|
+
colorKey="muted"
|
|
285
|
+
icon="fa-folder-plus"
|
|
286
|
+
size="md"
|
|
287
|
+
variant="outline"
|
|
288
|
+
/>
|
|
289
|
+
<span className="line-clamp-2 w-full text-xs font-medium text-muted-foreground">
|
|
290
|
+
New folder
|
|
291
|
+
</span>
|
|
292
|
+
</button>
|
|
293
|
+
</Tip>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
{childFolders.map(folder => {
|
|
297
|
+
const isRenaming = renamingFolderId === folder.id
|
|
298
|
+
const { subfolders, questionsInFolder } = folderHoverCounts(folder, folders, questions)
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<div key={folder.id} role="listitem" className="group relative">
|
|
302
|
+
{isRenaming ? (
|
|
303
|
+
<div
|
|
304
|
+
className={cn(
|
|
305
|
+
"flex w-full flex-col items-center gap-2 rounded-xl border border-border/70 bg-card/40 p-3 text-center shadow-sm",
|
|
306
|
+
)}
|
|
307
|
+
>
|
|
308
|
+
<OsFolderGlyph
|
|
309
|
+
colorKey={folder.colorKey}
|
|
310
|
+
icon={folder.icon}
|
|
311
|
+
size="md"
|
|
312
|
+
/>
|
|
313
|
+
<Label htmlFor={`rename-${folder.id}`} className="sr-only">
|
|
314
|
+
Rename folder {folder.name}
|
|
315
|
+
</Label>
|
|
316
|
+
<Input
|
|
317
|
+
id={`rename-${folder.id}`}
|
|
318
|
+
ref={renameInputRef}
|
|
319
|
+
value={renameValue}
|
|
320
|
+
onChange={e => setRenameValue(e.target.value)}
|
|
321
|
+
onBlur={commitRename}
|
|
322
|
+
onKeyDown={e => {
|
|
323
|
+
if (e.key === "Enter") {
|
|
324
|
+
e.preventDefault()
|
|
325
|
+
commitRename()
|
|
326
|
+
}
|
|
327
|
+
if (e.key === "Escape") {
|
|
328
|
+
e.preventDefault()
|
|
329
|
+
cancelRename()
|
|
330
|
+
}
|
|
331
|
+
}}
|
|
332
|
+
className="h-8 text-center text-xs font-medium"
|
|
333
|
+
aria-describedby={`rename-hint-${folder.id}`}
|
|
334
|
+
/>
|
|
335
|
+
<p id={`rename-hint-${folder.id}`} className="sr-only">
|
|
336
|
+
Press Enter to save, Escape to cancel.
|
|
337
|
+
</p>
|
|
338
|
+
</div>
|
|
339
|
+
) : (
|
|
340
|
+
<Tip
|
|
341
|
+
side="bottom"
|
|
342
|
+
label={(
|
|
343
|
+
<span className="flex max-w-[14rem] flex-col gap-1.5 text-start">
|
|
344
|
+
<span className="text-xs font-semibold text-background">{folder.name}</span>
|
|
345
|
+
<span className="text-[11px] leading-snug text-background/85">
|
|
346
|
+
Double-click to open. Use the menu (⋯) for rename, appearance, move, or delete.
|
|
347
|
+
</span>
|
|
348
|
+
<span className="text-[11px] text-background/80">
|
|
349
|
+
{questionsInFolder} question{questionsInFolder === 1 ? "" : "s"} in this folder
|
|
350
|
+
{" · "}
|
|
351
|
+
{subfolders} subfolder{subfolders === 1 ? "" : "s"}
|
|
352
|
+
</span>
|
|
353
|
+
</span>
|
|
354
|
+
)}
|
|
355
|
+
>
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
className={cn(
|
|
359
|
+
"flex w-full flex-col items-center gap-2 rounded-xl border border-transparent p-3 text-center transition-all",
|
|
360
|
+
"hover:border-border/80 hover:bg-muted/35",
|
|
361
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
362
|
+
)}
|
|
363
|
+
onDoubleClick={() => setCurrentId(folder.id)}
|
|
364
|
+
aria-label={`Open folder ${folder.name}`}
|
|
365
|
+
>
|
|
366
|
+
<OsFolderGlyph
|
|
367
|
+
colorKey={folder.colorKey}
|
|
368
|
+
icon={folder.icon}
|
|
369
|
+
size="md"
|
|
370
|
+
/>
|
|
371
|
+
<span className="line-clamp-2 w-full text-xs font-medium text-foreground">
|
|
372
|
+
{folder.name}
|
|
373
|
+
</span>
|
|
374
|
+
</button>
|
|
375
|
+
</Tip>
|
|
376
|
+
)}
|
|
377
|
+
{!isRenaming && (
|
|
378
|
+
<div className="absolute end-1.5 top-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
|
379
|
+
<DropdownMenu>
|
|
380
|
+
<Tip label="Folder actions" side="bottom">
|
|
381
|
+
<DropdownMenuTrigger asChild>
|
|
382
|
+
<Button
|
|
383
|
+
type="button"
|
|
384
|
+
size="icon-xs"
|
|
385
|
+
variant="ghost"
|
|
386
|
+
aria-label={`Actions for folder ${folder.name}`}
|
|
387
|
+
>
|
|
388
|
+
<i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
|
|
389
|
+
</Button>
|
|
390
|
+
</DropdownMenuTrigger>
|
|
391
|
+
</Tip>
|
|
392
|
+
<DropdownMenuContent align="end" className="w-40">
|
|
393
|
+
<DropdownMenuItem
|
|
394
|
+
onSelect={() => {
|
|
395
|
+
setTimeout(() => startRename(folder), 0)
|
|
396
|
+
}}
|
|
397
|
+
>
|
|
398
|
+
<i className="fa-light fa-pen text-xs" aria-hidden="true" />
|
|
399
|
+
Rename
|
|
400
|
+
</DropdownMenuItem>
|
|
401
|
+
<DropdownMenuItem onSelect={() => {
|
|
402
|
+
setCustomizingFolder(folder)
|
|
403
|
+
setCustomizeFolderOpen(true)
|
|
404
|
+
}}>
|
|
405
|
+
<i className="fa-light fa-wand-magic-sparkles text-xs" aria-hidden="true" />
|
|
406
|
+
Customize
|
|
407
|
+
</DropdownMenuItem>
|
|
408
|
+
<DropdownMenuItem onSelect={() => setMoveFolderId(folder.id)}>
|
|
409
|
+
<i className="fa-light fa-arrow-right-arrow-left text-xs" aria-hidden="true" />
|
|
410
|
+
Move
|
|
411
|
+
</DropdownMenuItem>
|
|
412
|
+
<DropdownMenuSeparator />
|
|
413
|
+
<DropdownMenuItem
|
|
414
|
+
className="text-destructive focus:text-destructive"
|
|
415
|
+
onSelect={() => setDeleteFolderId(folder.id)}
|
|
416
|
+
>
|
|
417
|
+
<i className="fa-light fa-trash text-xs" aria-hidden="true" />
|
|
418
|
+
Delete
|
|
419
|
+
</DropdownMenuItem>
|
|
420
|
+
</DropdownMenuContent>
|
|
421
|
+
</DropdownMenu>
|
|
422
|
+
</div>
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
)
|
|
426
|
+
})}
|
|
427
|
+
|
|
428
|
+
{filesHere.map(q => (
|
|
429
|
+
<div key={q.id} role="listitem" className="group relative">
|
|
430
|
+
<div
|
|
431
|
+
className="flex w-full flex-col items-center gap-3 rounded-2xl border border-border bg-card p-4 text-center shadow-sm"
|
|
432
|
+
>
|
|
433
|
+
<div className="flex size-[4.5rem] items-center justify-center rounded-xl bg-muted">
|
|
434
|
+
<i className="fa-solid fa-file-lines text-3xl text-muted-foreground" aria-hidden="true" />
|
|
435
|
+
</div>
|
|
436
|
+
<span className="line-clamp-2 w-full text-xs font-medium text-foreground">{q.stem}</span>
|
|
437
|
+
<ListHubStatusBadge
|
|
438
|
+
surface="board"
|
|
439
|
+
label={QUESTION_BANK_STATUS_LABEL[q.status]}
|
|
440
|
+
tintClassName={QUESTION_BANK_STATUS_BADGE_CLASS[q.status]}
|
|
441
|
+
icon={QUESTION_BANK_STATUS_ICON[q.status]}
|
|
442
|
+
/>
|
|
443
|
+
</div>
|
|
444
|
+
<div className="absolute end-1.5 top-1.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
|
445
|
+
<DropdownMenu>
|
|
446
|
+
<Tip label="Question actions" side="bottom">
|
|
447
|
+
<DropdownMenuTrigger asChild>
|
|
448
|
+
<Button
|
|
449
|
+
type="button"
|
|
450
|
+
size="icon-xs"
|
|
451
|
+
variant="ghost"
|
|
452
|
+
aria-label={`Move question ${q.stem}`}
|
|
453
|
+
>
|
|
454
|
+
<i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
|
|
455
|
+
</Button>
|
|
456
|
+
</DropdownMenuTrigger>
|
|
457
|
+
</Tip>
|
|
458
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
459
|
+
<DropdownMenuSub>
|
|
460
|
+
<DropdownMenuSubTrigger>
|
|
461
|
+
<i className="fa-solid fa-folder-arrow-up me-2 text-xs" aria-hidden="true" />
|
|
462
|
+
Move to folder
|
|
463
|
+
</DropdownMenuSubTrigger>
|
|
464
|
+
<DropdownMenuSubContent className="max-h-64 overflow-y-auto">
|
|
465
|
+
{folders
|
|
466
|
+
.filter(f => f.id !== q.folderId)
|
|
467
|
+
.map(f => (
|
|
468
|
+
<DropdownMenuItem key={f.id} onSelect={() => moveQuestionToFolder(q.id, f.id)}>
|
|
469
|
+
<i className={cn("fa-solid me-2 text-xs", f.icon)} aria-hidden="true" />
|
|
470
|
+
{f.name}
|
|
471
|
+
</DropdownMenuItem>
|
|
472
|
+
))}
|
|
473
|
+
</DropdownMenuSubContent>
|
|
474
|
+
</DropdownMenuSub>
|
|
475
|
+
</DropdownMenuContent>
|
|
476
|
+
</DropdownMenu>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
))}
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
{childFolders.length === 0 && filesHere.length === 0 && (
|
|
483
|
+
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
484
|
+
This folder is empty. Add a folder with the first tile, or open another location.
|
|
485
|
+
</p>
|
|
486
|
+
)}
|
|
487
|
+
|
|
488
|
+
<QuestionBankNewFolderSheet
|
|
489
|
+
open={createFolderOpen}
|
|
490
|
+
onOpenChange={setCreateFolderOpen}
|
|
491
|
+
parentFolderId={currentId}
|
|
492
|
+
onCreated={data =>
|
|
493
|
+
onFoldersChange(prev => [...prev, { ...data, id: newFolderId() }])
|
|
494
|
+
}
|
|
495
|
+
/>
|
|
496
|
+
|
|
497
|
+
{/* Customize folder */}
|
|
498
|
+
<QuestionBankNewFolderSheet
|
|
499
|
+
open={customizeFolderOpen}
|
|
500
|
+
onOpenChange={setCustomizeFolderOpen}
|
|
501
|
+
parentFolderId={customizingFolder?.parentId ?? null}
|
|
502
|
+
customizingFolder={customizingFolder}
|
|
503
|
+
onCreated={data => {
|
|
504
|
+
if (customizingFolder) {
|
|
505
|
+
onFoldersChange(prev =>
|
|
506
|
+
prev.map(f =>
|
|
507
|
+
f.id === customizingFolder.id
|
|
508
|
+
? {
|
|
509
|
+
...f,
|
|
510
|
+
name: data.name,
|
|
511
|
+
icon: data.icon,
|
|
512
|
+
colorKey: data.colorKey,
|
|
513
|
+
}
|
|
514
|
+
: f,
|
|
515
|
+
),
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
setCustomizingFolder(null)
|
|
519
|
+
setCustomizeFolderOpen(false)
|
|
520
|
+
}}
|
|
521
|
+
/>
|
|
522
|
+
|
|
523
|
+
{/* Appearance (existing folders) */}
|
|
524
|
+
<Dialog open={!!appearanceDialog} onOpenChange={open => !open && setAppearanceDialog(null)}>
|
|
525
|
+
<DialogContent className="max-w-md">
|
|
526
|
+
<DialogHeader>
|
|
527
|
+
<DialogTitle>Folder appearance</DialogTitle>
|
|
528
|
+
<DialogDescription>
|
|
529
|
+
Pick a tint and icon. These are demo-only until wired to your API.
|
|
530
|
+
</DialogDescription>
|
|
531
|
+
</DialogHeader>
|
|
532
|
+
{appearanceDialog && (
|
|
533
|
+
<div className="flex flex-col gap-4 py-2">
|
|
534
|
+
<div>
|
|
535
|
+
<p className="mb-2 text-xs font-medium text-muted-foreground">Color</p>
|
|
536
|
+
<div className="flex flex-wrap gap-2">
|
|
537
|
+
{COLOR_OPTIONS.map(c => (
|
|
538
|
+
<button
|
|
539
|
+
key={c}
|
|
540
|
+
type="button"
|
|
541
|
+
aria-label={`Color ${c}`}
|
|
542
|
+
aria-pressed={appearanceDialog.colorKey === c}
|
|
543
|
+
className={cn(
|
|
544
|
+
"size-9 rounded-lg border-2 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
545
|
+
QUESTION_BANK_FOLDER_COLOR_STYLES[c].tile,
|
|
546
|
+
appearanceDialog.colorKey === c ? "ring-2 ring-ring" : "border-transparent opacity-80 hover:opacity-100",
|
|
547
|
+
)}
|
|
548
|
+
onClick={() =>
|
|
549
|
+
onFoldersChange(prev =>
|
|
550
|
+
prev.map(f =>
|
|
551
|
+
f.id === appearanceDialog.id ? { ...f, colorKey: c } : f,
|
|
552
|
+
),
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
/>
|
|
556
|
+
))}
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
<div>
|
|
560
|
+
<p className="mb-2 text-xs font-medium text-muted-foreground">Icon</p>
|
|
561
|
+
<div className="grid max-h-40 grid-cols-5 gap-2 overflow-y-auto rounded-md border border-border p-2">
|
|
562
|
+
{QUESTION_BANK_FOLDER_ICON_OPTIONS.map(ic => (
|
|
563
|
+
<button
|
|
564
|
+
key={ic}
|
|
565
|
+
type="button"
|
|
566
|
+
aria-label={`Icon ${ic}`}
|
|
567
|
+
aria-pressed={appearanceDialog.icon === ic}
|
|
568
|
+
className={cn(
|
|
569
|
+
"flex size-9 items-center justify-center rounded-md border text-sm transition-colors",
|
|
570
|
+
appearanceDialog.icon === ic
|
|
571
|
+
? "border-brand bg-brand/10 text-brand"
|
|
572
|
+
: "border-transparent hover:bg-muted",
|
|
573
|
+
)}
|
|
574
|
+
onClick={() =>
|
|
575
|
+
onFoldersChange(prev =>
|
|
576
|
+
prev.map(f =>
|
|
577
|
+
f.id === appearanceDialog.id ? { ...f, icon: ic } : f,
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
>
|
|
582
|
+
<i className={cn("fa-solid", ic)} aria-hidden="true" />
|
|
583
|
+
</button>
|
|
584
|
+
))}
|
|
585
|
+
</div>
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
)}
|
|
589
|
+
<DialogFooter>
|
|
590
|
+
<Button type="button" size="sm" onClick={() => setAppearanceDialog(null)}>
|
|
591
|
+
Done
|
|
592
|
+
</Button>
|
|
593
|
+
</DialogFooter>
|
|
594
|
+
</DialogContent>
|
|
595
|
+
</Dialog>
|
|
596
|
+
|
|
597
|
+
{/* Move folder */}
|
|
598
|
+
<Dialog open={moveFolderId !== null} onOpenChange={open => !open && setMoveFolderId(null)}>
|
|
599
|
+
<DialogContent className="max-w-sm">
|
|
600
|
+
<DialogHeader>
|
|
601
|
+
<DialogTitle>Move folder</DialogTitle>
|
|
602
|
+
<DialogDescription>Choose a new parent. Subfolders stay with this folder.</DialogDescription>
|
|
603
|
+
</DialogHeader>
|
|
604
|
+
<div className="flex max-h-64 flex-col gap-1 overflow-y-auto py-2">
|
|
605
|
+
{moveFolderId &&
|
|
606
|
+
validMoveTargets(folders, moveFolderId).map(t => (
|
|
607
|
+
<Button
|
|
608
|
+
key={t.id ?? "root"}
|
|
609
|
+
type="button"
|
|
610
|
+
variant="ghost"
|
|
611
|
+
size="sm"
|
|
612
|
+
className="justify-start font-normal"
|
|
613
|
+
onClick={() => commitMoveFolder(t.id)}
|
|
614
|
+
>
|
|
615
|
+
{t.label}
|
|
616
|
+
</Button>
|
|
617
|
+
))}
|
|
618
|
+
</div>
|
|
619
|
+
<DialogFooter>
|
|
620
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setMoveFolderId(null)}>
|
|
621
|
+
Cancel
|
|
622
|
+
</Button>
|
|
623
|
+
</DialogFooter>
|
|
624
|
+
</DialogContent>
|
|
625
|
+
</Dialog>
|
|
626
|
+
|
|
627
|
+
{/* Delete confirm */}
|
|
628
|
+
<Dialog open={deleteFolderId !== null} onOpenChange={open => !open && setDeleteFolderId(null)}>
|
|
629
|
+
<DialogContent className="max-w-sm">
|
|
630
|
+
<DialogHeader>
|
|
631
|
+
<DialogTitle>Delete folder?</DialogTitle>
|
|
632
|
+
<DialogDescription>
|
|
633
|
+
Subfolders are removed. Questions inside move to the parent folder (or the first top-level folder).
|
|
634
|
+
</DialogDescription>
|
|
635
|
+
</DialogHeader>
|
|
636
|
+
<DialogFooter className="gap-2 sm:gap-0">
|
|
637
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setDeleteFolderId(null)}>
|
|
638
|
+
Cancel
|
|
639
|
+
</Button>
|
|
640
|
+
<Button type="button" variant="destructive" size="sm" onClick={commitDeleteFolder}>
|
|
641
|
+
Delete
|
|
642
|
+
</Button>
|
|
643
|
+
</DialogFooter>
|
|
644
|
+
</DialogContent>
|
|
645
|
+
</Dialog>
|
|
646
|
+
</ListPageViewFrame>
|
|
647
|
+
)
|
|
648
|
+
}
|
|
@@ -18,8 +18,8 @@ import { Tip } from "@/components/ui/tip"
|
|
|
18
18
|
import { QUESTION_BANK_HEADER_COLLABORATORS } from "@/lib/mock/question-bank-header-collaborators"
|
|
19
19
|
|
|
20
20
|
export interface QuestionBankPageHeaderProps {
|
|
21
|
-
/**
|
|
22
|
-
title
|
|
21
|
+
/** Scoped hub title (All / My / folder name) — keep in sync with `SiteHeader`. */
|
|
22
|
+
title: string
|
|
23
23
|
questionCount: number
|
|
24
24
|
onNewQuestion: () => void
|
|
25
25
|
onExport: () => void
|
|
@@ -37,7 +37,7 @@ export interface QuestionBankPageHeaderProps {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export function QuestionBankPageHeader({
|
|
40
|
-
title
|
|
40
|
+
title,
|
|
41
41
|
questionCount,
|
|
42
42
|
onNewQuestion,
|
|
43
43
|
onExport,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useAutoPanel } from "@/components/secondary-panel"
|
|
4
|
+
|
|
5
|
+
/** Opens the Question bank secondary panel while this route is mounted (same pattern as rotations). */
|
|
6
|
+
export function QuestionBankPanelActivator() {
|
|
7
|
+
useAutoPanel("question-bank")
|
|
8
|
+
return null
|
|
9
|
+
}
|