From a5a80aa93da41714755631ada153d6e9eb15cc44 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Fri, 3 Apr 2026 13:36:23 -0400 Subject: [PATCH] Redesign QuestionBankView with Tailwind, shadcn/ui, and virtualization Rewrite QuestionBankView from inline-styled JSX to TypeScript with Tailwind CSS, shadcn/ui components, and @tanstack/react-virtual. Adds slide-out Sheet form, sortable column headers, custom checkboxes, hover action icons, sticky bulk action bar, and empty state. Co-Authored-By: Claude Opus 4.6 --- .../questionbank/QuestionBankView.jsx | 1132 ----------------- .../questionbank/QuestionBankView.tsx | 997 +++++++++++++++ 2 files changed, 997 insertions(+), 1132 deletions(-) delete mode 100644 frontend/frontend/src/components/questionbank/QuestionBankView.jsx create mode 100644 frontend/frontend/src/components/questionbank/QuestionBankView.tsx diff --git a/frontend/frontend/src/components/questionbank/QuestionBankView.jsx b/frontend/frontend/src/components/questionbank/QuestionBankView.jsx deleted file mode 100644 index a16603b..0000000 --- a/frontend/frontend/src/components/questionbank/QuestionBankView.jsx +++ /dev/null @@ -1,1132 +0,0 @@ -import { useState, useEffect } from "react"; -import { Link } from "react-router-dom"; -import { - questionsAPI, - categoriesAPI, - downloadJobsAPI, -} from "../../services/api"; -import AdminNavbar from "../common/AdminNavbar"; -import ShareDialog from "./ShareDialog"; -import { useAuth } from "../../contexts/AuthContext"; - -export default function QuestionBankView() { - const { dbUser } = useAuth(); - const [questions, setQuestions] = useState([]); - const [categories, setCategories] = useState([]); - const [showForm, setShowForm] = useState(false); - const [editingId, setEditingId] = useState(null); - const [formData, setFormData] = useState({ - type: "text", - question_content: "", - answer: "", - category: "", - image: null, - youtube_url: "", - start_time: 0, - end_time: 0, - }); - const [imagePreview, setImagePreview] = useState(null); - const [selectedQuestions, setSelectedQuestions] = useState([]); - const [showBulkCategory, setShowBulkCategory] = useState(false); - const [bulkCategory, setBulkCategory] = useState(""); - const [downloadJob, setDownloadJob] = useState(null); - const [downloadProgress, setDownloadProgress] = useState(0); - const [shareTarget, setShareTarget] = useState(null); // question or array of ids for sharing - - // Filter and sort state - const [searchTerm, setSearchTerm] = useState(""); - const [filterCategory, setFilterCategory] = useState(""); - const [filterType, setFilterType] = useState(""); - const [filterOwner, setFilterOwner] = useState(""); - const [sortBy, setSortBy] = useState("created_at"); - const [sortOrder, setSortOrder] = useState("desc"); - - useEffect(() => { - loadQuestions(); - loadCategories(); - }, [searchTerm, filterCategory, filterType, filterOwner, sortBy, sortOrder]); - - const loadQuestions = async () => { - try { - const params = { - sort_by: sortBy, - sort_order: sortOrder, - }; - if (searchTerm) params.search = searchTerm; - if (filterCategory) params.category = filterCategory; - if (filterType) params.type = filterType; - if (filterOwner) params.owner = filterOwner; - - const response = await questionsAPI.getAll(params); - setQuestions(response.data); - } catch (error) { - console.error("Error loading questions:", error); - } - }; - - const loadCategories = async () => { - try { - const response = await categoriesAPI.getAll(); - setCategories(response.data); - } catch (error) { - console.error("Error loading categories:", error); - } - }; - - const handleSubmit = async (e) => { - e.preventDefault(); - try { - if (editingId) { - // Update existing question - if (formData.type === "image" && formData.image) { - const formDataToSend = new FormData(); - formDataToSend.append("type", "image"); - formDataToSend.append("question_content", formData.question_content); - formDataToSend.append("answer", formData.answer); - formDataToSend.append("category", formData.category); - formDataToSend.append("image", formData.image); - await questionsAPI.updateWithImage(editingId, formDataToSend); - } else { - await questionsAPI.update(editingId, { - type: formData.type, - question_content: formData.question_content, - answer: formData.answer, - category: formData.category, - }); - } - } else { - // Create new question - if (formData.type === "image" && formData.image) { - const formDataToSend = new FormData(); - formDataToSend.append("type", "image"); - formDataToSend.append("question_content", formData.question_content); - formDataToSend.append("answer", formData.answer); - formDataToSend.append("category", formData.category); - formDataToSend.append("image", formData.image); - await questionsAPI.createWithImage(formDataToSend); - } else if (formData.type === "youtube_audio") { - // Create YouTube audio question - const response = await questionsAPI.create({ - type: "youtube_audio", - question_content: formData.question_content, - answer: formData.answer, - category: formData.category, - youtube_url: formData.youtube_url, - start_time: formData.start_time, - end_time: formData.end_time, - }); - - // Start polling for download progress if job was created - if (response.data.job) { - setDownloadJob(response.data.job); - pollDownloadStatus(response.data.job.id); - return; // Don't close form yet, wait for download - } - } else { - await questionsAPI.create({ - type: formData.type, - question_content: formData.question_content, - answer: formData.answer, - category: formData.category, - }); - } - } - setShowForm(false); - setEditingId(null); - setFormData({ - type: "text", - question_content: "", - answer: "", - category: "", - image: null, - youtube_url: "", - start_time: 0, - end_time: 0, - }); - setImagePreview(null); - loadQuestions(); - } catch (error) { - console.error("Error saving question:", error); - alert("Error saving question"); - } - }; - - const handleEdit = (question) => { - setEditingId(question.id); - setFormData({ - type: question.type, - question_content: question.question_content, - answer: question.answer, - category: question.category || "", - image: null, - }); - setShowForm(true); - }; - - const handleCancelEdit = () => { - setShowForm(false); - setEditingId(null); - setFormData({ - type: "text", - question_content: "", - answer: "", - category: "", - image: null, - youtube_url: "", - start_time: 0, - end_time: 0, - }); - setImagePreview(null); - setDownloadJob(null); - setDownloadProgress(0); - }; - - const pollDownloadStatus = async (jobId) => { - const pollInterval = setInterval(async () => { - try { - const response = await downloadJobsAPI.getStatus(jobId); - const job = response.data; - - setDownloadProgress(job.progress); - - if (job.status === "completed") { - clearInterval(pollInterval); - setDownloadJob(null); - setDownloadProgress(0); - setShowForm(false); - setFormData({ - type: "text", - question_content: "", - answer: "", - category: "", - image: null, - youtube_url: "", - start_time: 0, - end_time: 0, - }); - loadQuestions(); - alert("Audio downloaded successfully!"); - } else if (job.status === "failed") { - clearInterval(pollInterval); - setDownloadJob(null); - setDownloadProgress(0); - alert(`Download failed: ${job.error_message || "Unknown error"}`); - } - } catch (error) { - clearInterval(pollInterval); - console.error("Error polling download status:", error); - setDownloadJob(null); - setDownloadProgress(0); - } - }, 2000); // Poll every 2 seconds - }; - - const handleImageChange = (file) => { - if (file && file.type.startsWith("image/")) { - setFormData({ ...formData, image: file }); - const reader = new FileReader(); - reader.onloadend = () => { - setImagePreview(reader.result); - }; - reader.readAsDataURL(file); - } - }; - - const handlePaste = (e) => { - const items = e.clipboardData?.items; - if (!items) return; - - for (let i = 0; i < items.length; i++) { - if (items[i].type.startsWith("image/")) { - e.preventDefault(); - const file = items[i].getAsFile(); - if (file) { - handleImageChange(file); - } - break; - } - } - }; - - const handleDelete = async (id) => { - if (!confirm("Are you sure you want to delete this question?")) return; - try { - await questionsAPI.delete(id); - loadQuestions(); - } catch (error) { - console.error("Error deleting question:", error); - } - }; - - const toggleQuestionSelection = (id) => { - setSelectedQuestions((prev) => - prev.includes(id) ? prev.filter((qId) => qId !== id) : [...prev, id], - ); - }; - - const toggleSelectAll = () => { - if (selectedQuestions.length === questions.length) { - setSelectedQuestions([]); - } else { - setSelectedQuestions(questions.map((q) => q.id)); - } - }; - - const handleBulkDelete = async () => { - if (selectedQuestions.length === 0) return; - if ( - !confirm( - `Are you sure you want to delete ${selectedQuestions.length} question(s)?`, - ) - ) - return; - - try { - await Promise.all(selectedQuestions.map((id) => questionsAPI.delete(id))); - setSelectedQuestions([]); - loadQuestions(); - } catch (error) { - console.error("Error bulk deleting questions:", error); - alert("Error deleting questions"); - } - }; - - const handleBulkCategoryAssign = async () => { - if (selectedQuestions.length === 0) return; - - try { - await Promise.all( - selectedQuestions.map((id) => { - const question = questions.find((q) => q.id === id); - return questionsAPI.update(id, { - question_content: question.question_content, - answer: question.answer, - type: question.type, - category: bulkCategory, - }); - }), - ); - setSelectedQuestions([]); - setShowBulkCategory(false); - setBulkCategory(""); - loadQuestions(); - } catch (error) { - console.error("Error bulk updating categories:", error); - alert("Error updating categories"); - } - }; - - const handleSort = (column) => { - if (sortBy === column) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(column); - setSortOrder("asc"); - } - }; - - const SortIndicator = ({ column }) => { - if (sortBy !== column) return ; - return {sortOrder === "asc" ? "↑" : "↓"}; - }; - - const sortableHeaderStyle = { - padding: "0.75rem", - textAlign: "left", - borderBottom: "2px solid #ddd", - cursor: "pointer", - userSelect: "none", - }; - - return ( - <> - -
-
-

Question Bank

-
- -
-
- - {showForm && ( -
-

- {editingId ? "Edit Question" : "Add Question"} -

-
- - -
- -
- - - - Manage Categories - -
- -
- - - setFormData({ ...formData, question_content: e.target.value }) - } - required - style={{ padding: "0.5rem", width: "100%" }} - placeholder="Enter question text" - /> -
- - {formData.type === "image" && ( -
- -
-

- 📋 Paste an image here (Ctrl/Cmd+V) or upload below -

- handleImageChange(e.target.files[0])} - required={!editingId && !formData.image} - style={{ padding: "0.5rem" }} - /> -
- {imagePreview && ( -
-

- Preview: -

- Preview -
- )} - {editingId && !imagePreview && ( -

- Leave empty to keep current image -

- )} -
- )} - - {formData.type === "youtube_audio" && ( -
- - -
- - - -
- -

- Clip duration: {formData.end_time - formData.start_time}{" "} - seconds (max 300) -

-
- )} - -
- - - setFormData({ ...formData, answer: e.target.value }) - } - required - style={{ padding: "0.5rem", width: "100%" }} - placeholder="Enter answer" - /> -
- -
- - {editingId && ( - - )} -
-
- )} - -
- {/* Filter and Sort Controls */} -
- {/* Search */} -
- setSearchTerm(e.target.value)} - style={{ - width: "100%", - padding: "0.5rem", - border: "1px solid #ddd", - borderRadius: "4px", - }} - /> -
- - {/* Category Filter */} -
- -
- - {/* Type Filter */} -
- -
- - {/* Owner Filter */} -
- -
- - {/* Clear Filters */} - {(searchTerm || filterCategory || filterType || filterOwner || sortBy !== "created_at" || sortOrder !== "desc") && ( - - )} -
- -
-

Questions ({questions.length})

- {selectedQuestions.length > 0 && ( -
- - {selectedQuestions.length} selected - - - - -
- )} -
- - {showBulkCategory && selectedQuestions.length > 0 && ( -
- - - - -
- )} - - {questions.length === 0 ? ( -

No questions yet. Create your first question above!

- ) : ( -
- - - - - - - - - - - - - - {questions.map((q) => ( - - - - - - - - - - ))} - -
- 0 - } - onChange={toggleSelectAll} - style={{ cursor: "pointer" }} - /> - handleSort("type")} - style={{ - ...sortableHeaderStyle, - width: "80px", - }} - > - Type - - handleSort("category")} - style={{ - ...sortableHeaderStyle, - width: "120px", - }} - > - Category - - handleSort("question_content")} - style={sortableHeaderStyle} - > - Question - - handleSort("answer")} - style={sortableHeaderStyle} - > - Answer - - - Creator - - Actions -
- toggleQuestionSelection(q.id)} - style={{ cursor: "pointer" }} - /> - - - {q.type.toUpperCase()} - - - {q.category ? ( - - {q.category} - - ) : ( - - None - - )} - -
{q.question_content}
- {q.image_path && ( - Question - )} -
- {q.answer} - - - {q.created_by === dbUser?.id ? "You" : (q.creator_name || "Unknown")} - - -
- {q.created_by === dbUser?.id && ( - <> - - - - - )} -
-
-
- )} -
- - {downloadJob && ( -
-

Downloading Audio...

-
-
-
-

- {downloadProgress}% complete -

-
- )} -
- - {shareTarget && ( - setShareTarget(null)} - /> - )} - - ); -} diff --git a/frontend/frontend/src/components/questionbank/QuestionBankView.tsx b/frontend/frontend/src/components/questionbank/QuestionBankView.tsx new file mode 100644 index 0000000..aa47825 --- /dev/null +++ b/frontend/frontend/src/components/questionbank/QuestionBankView.tsx @@ -0,0 +1,997 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { Link } from "react-router-dom"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + questionsAPI, + categoriesAPI, + downloadJobsAPI, +} from "../../services/api"; +import AdminNavbar from "../common/AdminNavbar"; +import ShareDialog from "./ShareDialog"; +import { useAuth } from "../../contexts/AuthContext"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; +import { + Search, + Plus, + X, + FileText, + Image, + Music, + Pencil, + Share2, + Trash2, + ArrowUp, + ArrowDown, + ArrowUpDown, + Upload, + MessageSquare, +} from "lucide-react"; + +/* ─── Types ──────────────────────────────────────────────────────── */ + +interface Question { + id: number; + question_content: string; + answer: string; + type: string; + category?: string; + image_path?: string; + audio_path?: string; + created_by?: number; + creator_name?: string; + [key: string]: unknown; +} + +interface Category { + id: number; + name: string; +} + +interface QuestionFormData { + type: string; + question_content: string; + answer: string; + category: string; + image: File | null; + youtube_url: string; + start_time: number; + end_time: number; +} + +interface DownloadJob { + id: string; + status: string; + progress: number; + error_message?: string; +} + +type SortField = "type" | "category" | "question_content" | "answer" | "created_at"; +type SortOrder = "asc" | "desc"; + +const INITIAL_FORM: QuestionFormData = { + type: "text", + question_content: "", + answer: "", + category: "", + image: null, + youtube_url: "", + start_time: 0, + end_time: 0, +}; + +/* ─── Sort header sub-component ──────────────────────────────────── */ + +function SortHeader({ + label, + field, + sortBy, + sortOrder, + onSort, + className, +}: { + label: string; + field: SortField; + sortBy: SortField; + sortOrder: SortOrder; + onSort: (field: SortField) => void; + className?: string; +}) { + const active = sortBy === field; + return ( + + ); +} + +/* ─── Type icon helper ───────────────────────────────────────────── */ + +function TypeIcon({ type }: { type: string }) { + const cls = "size-4 shrink-0"; + switch (type) { + case "image": + return ; + case "youtube_audio": + return ; + default: + return ; + } +} + +/* ═══════════════════════════════════════════════════════════════════ */ + +export default function QuestionBankView() { + const { dbUser } = useAuth(); + + /* ─── State ──────────────────────────────────────────────── */ + const [questions, setQuestions] = useState([]); + const [categories, setCategories] = useState([]); + const [sheetOpen, setSheetOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formData, setFormData] = useState({ ...INITIAL_FORM }); + const [imagePreview, setImagePreview] = useState(null); + const [selectedQuestions, setSelectedQuestions] = useState([]); + const [showBulkCategory, setShowBulkCategory] = useState(false); + const [bulkCategory, setBulkCategory] = useState(""); + const [downloadJob, setDownloadJob] = useState(null); + const [downloadProgress, setDownloadProgress] = useState(0); + const [shareTarget, setShareTarget] = useState(null); + + // Filter and sort state + const [searchTerm, setSearchTerm] = useState(""); + const [filterCategory, setFilterCategory] = useState(""); + const [filterType, setFilterType] = useState(""); + const [filterOwner, setFilterOwner] = useState(""); + const [sortBy, setSortBy] = useState("created_at"); + const [sortOrder, setSortOrder] = useState("desc"); + + const scrollRef = useRef(null); + + /* ─── Load data ──────────────────────────────────────────── */ + useEffect(() => { + loadQuestions(); + loadCategories(); + }, [searchTerm, filterCategory, filterType, filterOwner, sortBy, sortOrder]); + + const loadQuestions = async () => { + try { + const params: Record = { + sort_by: sortBy, + sort_order: sortOrder, + }; + if (searchTerm) params.search = searchTerm; + if (filterCategory) params.category = filterCategory; + if (filterType) params.type = filterType; + if (filterOwner) params.owner = filterOwner; + + const response = await questionsAPI.getAll(params); + setQuestions(response.data); + } catch (error) { + console.error("Error loading questions:", error); + } + }; + + const loadCategories = async () => { + try { + const response = await categoriesAPI.getAll(); + setCategories(response.data); + } catch (error) { + console.error("Error loading categories:", error); + } + }; + + /* ─── Form handlers ──────────────────────────────────────── */ + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + if (editingId) { + if (formData.type === "image" && formData.image) { + const fd = new FormData(); + fd.append("type", "image"); + fd.append("question_content", formData.question_content); + fd.append("answer", formData.answer); + fd.append("category", formData.category); + fd.append("image", formData.image); + await questionsAPI.updateWithImage(editingId, fd); + } else { + await questionsAPI.update(editingId, { + type: formData.type, + question_content: formData.question_content, + answer: formData.answer, + category: formData.category, + }); + } + } else { + if (formData.type === "image" && formData.image) { + const fd = new FormData(); + fd.append("type", "image"); + fd.append("question_content", formData.question_content); + fd.append("answer", formData.answer); + fd.append("category", formData.category); + fd.append("image", formData.image); + await questionsAPI.createWithImage(fd); + } else if (formData.type === "youtube_audio") { + const response = await questionsAPI.create({ + type: "youtube_audio", + question_content: formData.question_content, + answer: formData.answer, + category: formData.category, + youtube_url: formData.youtube_url, + start_time: formData.start_time, + end_time: formData.end_time, + }); + if (response.data.job) { + setDownloadJob(response.data.job); + pollDownloadStatus(response.data.job.id); + return; + } + } else { + await questionsAPI.create({ + type: formData.type, + question_content: formData.question_content, + answer: formData.answer, + category: formData.category, + }); + } + } + closeSheet(); + loadQuestions(); + } catch (error) { + console.error("Error saving question:", error); + alert("Error saving question"); + } + }; + + const closeSheet = () => { + setSheetOpen(false); + setEditingId(null); + setFormData({ ...INITIAL_FORM }); + setImagePreview(null); + setDownloadJob(null); + setDownloadProgress(0); + }; + + const handleEdit = (question: Question) => { + setEditingId(question.id); + setFormData({ + type: question.type, + question_content: question.question_content, + answer: question.answer, + category: question.category || "", + image: null, + youtube_url: "", + start_time: 0, + end_time: 0, + }); + setSheetOpen(true); + }; + + const pollDownloadStatus = async (jobId: string) => { + const pollInterval = setInterval(async () => { + try { + const response = await downloadJobsAPI.getStatus(jobId); + const job = response.data; + setDownloadProgress(job.progress); + + if (job.status === "completed") { + clearInterval(pollInterval); + setDownloadJob(null); + setDownloadProgress(0); + closeSheet(); + loadQuestions(); + alert("Audio downloaded successfully!"); + } else if (job.status === "failed") { + clearInterval(pollInterval); + setDownloadJob(null); + setDownloadProgress(0); + alert(`Download failed: ${job.error_message || "Unknown error"}`); + } + } catch (error) { + clearInterval(pollInterval); + console.error("Error polling download status:", error); + setDownloadJob(null); + setDownloadProgress(0); + } + }, 2000); + }; + + const handleImageChange = (file: File | undefined) => { + if (file && file.type.startsWith("image/")) { + setFormData({ ...formData, image: file }); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + for (let i = 0; i < items.length; i++) { + if (items[i].type.startsWith("image/")) { + e.preventDefault(); + const file = items[i].getAsFile(); + if (file) handleImageChange(file); + break; + } + } + }; + + /* ─── Question actions ───────────────────────────────────── */ + + const handleDelete = async (id: number) => { + if (!confirm("Are you sure you want to delete this question?")) return; + try { + await questionsAPI.delete(id); + loadQuestions(); + } catch (error) { + console.error("Error deleting question:", error); + } + }; + + const toggleQuestionSelection = (id: number) => { + setSelectedQuestions((prev) => + prev.includes(id) ? prev.filter((qId) => qId !== id) : [...prev, id], + ); + }; + + const toggleSelectAll = () => { + if (selectedQuestions.length === questions.length) { + setSelectedQuestions([]); + } else { + setSelectedQuestions(questions.map((q) => q.id)); + } + }; + + const handleBulkDelete = async () => { + if (selectedQuestions.length === 0) return; + if (!confirm(`Are you sure you want to delete ${selectedQuestions.length} question(s)?`)) return; + try { + await Promise.all(selectedQuestions.map((id) => questionsAPI.delete(id))); + setSelectedQuestions([]); + loadQuestions(); + } catch (error) { + console.error("Error bulk deleting questions:", error); + alert("Error deleting questions"); + } + }; + + const handleBulkCategoryAssign = async () => { + if (selectedQuestions.length === 0) return; + try { + await Promise.all( + selectedQuestions.map((id) => { + const question = questions.find((q) => q.id === id); + if (!question) return Promise.resolve(); + return questionsAPI.update(id, { + question_content: question.question_content, + answer: question.answer, + type: question.type, + category: bulkCategory, + }); + }), + ); + setSelectedQuestions([]); + setShowBulkCategory(false); + setBulkCategory(""); + loadQuestions(); + } catch (error) { + console.error("Error bulk updating categories:", error); + alert("Error updating categories"); + } + }; + + const handleSort = (column: SortField) => { + if (sortBy === column) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(column); + setSortOrder("asc"); + } + }; + + /* ─── Derived ────────────────────────────────────────────── */ + + const hasActiveFilters = + searchTerm || filterCategory || filterType || filterOwner || sortBy !== "created_at" || sortOrder !== "desc"; + + const clearFilters = () => { + setSearchTerm(""); + setFilterCategory(""); + setFilterType(""); + setFilterOwner(""); + setSortBy("created_at"); + setSortOrder("desc"); + }; + + /* ─── Virtualizer ────────────────────────────────────────── */ + + const rowVirtualizer = useVirtualizer({ + count: questions.length, + getScrollElement: () => scrollRef.current, + estimateSize: useCallback(() => 56, []), + overscan: 10, + }); + + const selectStyle = + "h-9 rounded-lg border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"; + + /* ═══ Render ══════════════════════════════════════════════════ */ + return ( + <> + +
+ {/* ── Toolbar ─────────────────────────────────────── */} +
+ {/* Search */} +
+ + setSearchTerm(e.target.value)} + placeholder="Search questions or answers..." + className="h-9 pl-9" + /> +
+ + {/* Category filter */} + + + {/* Type filter */} + + + {/* Owner filter */} + + + {/* Count */} + + {questions.length} question{questions.length !== 1 && "s"} + + + {/* Clear filters */} + {hasActiveFilters && ( + + )} + + {/* Add Question */} + +
+ + {/* ── List header (sort row) ──────────────────────── */} +
+ {/* Checkbox */} +
+ 0 + ? "border-emerald-500 bg-emerald-500 text-white" + : "border-muted-foreground/30 hover:border-muted-foreground/60", + )} + > + {selectedQuestions.length === questions.length && questions.length > 0 && "✓"} + +
+ {/* Type */} +
+ +
+ {/* Question */} +
+ +
+ {/* Answer */} +
+ +
+ {/* Category */} +
+ +
+ {/* Creator */} +
+ Creator +
+ {/* Actions spacer */} +
+
+ + {/* ── Virtualized list ────────────────────────────── */} +
+ {questions.length === 0 ? ( +
+
+ +
+
+

No questions yet

+

Create your first question to get started.

+
+ +
+ ) : ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const q = questions[virtualRow.index]; + const isSelected = selectedQuestions.includes(q.id); + const isOwner = q.created_by === dbUser?.id; + + return ( +
+ {/* Checkbox */} +
+ toggleQuestionSelection(q.id)} + className={cn( + "flex size-4 cursor-pointer items-center justify-center rounded border text-[0.6rem]", + isSelected + ? "border-emerald-500 bg-emerald-500 text-white" + : "border-muted-foreground/30 hover:border-muted-foreground/60", + )} + > + {isSelected && "✓"} + +
+ + {/* Type icon */} +
+ +
+ + {/* Question text */} +
+ {q.question_content} +
+ + {/* Answer */} +
+ {q.answer} +
+ + {/* Category */} +
+ {q.category ? ( + + {q.category} + + ) : ( + -- + )} +
+ + {/* Creator */} +
+ {isOwner ? ( + + You + + ) : ( + + {q.creator_name || "Unknown"} + + )} +
+ + {/* Hover actions */} +
+ {isOwner && ( + <> + + + + + )} +
+
+ ); + })} +
+ )} +
+ + {/* ── Bulk action bar ─────────────────────────────── */} + {selectedQuestions.length > 0 && ( +
+ + {selectedQuestions.length} selected + + + {/* Assign Category */} + {showBulkCategory ? ( +
+ + + +
+ ) : ( + + )} + + {/* Share Selected */} + + + {/* Delete Selected */} + +
+ )} +
+ + {/* ── Sheet (Add / Edit form) ────────────────────────── */} + { if (!open) closeSheet(); else setSheetOpen(true); }}> + + + {editingId ? "Edit Question" : "Add Question"} + + {editingId ? "Update this question's details." : "Create a new trivia question."} + + + +
+ {/* Type selector */} +
+ +
+ {[ + { value: "text", icon: FileText, label: "Text" }, + { value: "image", icon: Image, label: "Image" }, + { value: "youtube_audio", icon: Music, label: "Audio" }, + ].map(({ value, icon: Icon, label }) => ( + + ))} +
+
+ + {/* Category */} +
+ + + + Manage Categories + +
+ + {/* Question content */} +
+ + setFormData({ ...formData, question_content: e.target.value })} + required + placeholder="Enter question text" + className="h-9" + /> +
+ + {/* Image upload */} + {formData.type === "image" && ( +
+ +
+ +

+ Paste an image (Ctrl/Cmd+V) or upload below +

+ handleImageChange(e.target.files?.[0])} + required={!editingId && !formData.image} + className="text-sm" + /> +
+ {imagePreview && ( +
+

Preview:

+ Preview +
+ )} + {editingId && !imagePreview && ( +

+ Leave empty to keep current image +

+ )} +
+ )} + + {/* YouTube audio fields */} + {formData.type === "youtube_audio" && ( +
+
+ + setFormData({ ...formData, youtube_url: e.target.value })} + placeholder="https://www.youtube.com/watch?v=..." + required + className="h-9" + /> +
+
+
+ + + setFormData({ ...formData, start_time: parseInt(e.target.value) || 0 }) + } + min={0} + required + className="h-9" + /> +
+
+ + + setFormData({ ...formData, end_time: parseInt(e.target.value) || 0 }) + } + min={0} + required + className="h-9" + /> +
+
+

+ Clip duration: {formData.end_time - formData.start_time} seconds (max 300) +

+
+ )} + + {/* Answer */} +
+ + setFormData({ ...formData, answer: e.target.value })} + required + placeholder="Enter answer" + className="h-9" + /> +
+ + {/* Download progress inline */} + {downloadJob && ( +
+

Downloading Audio...

+
+
+
+

{downloadProgress}% complete

+
+ )} + + + + + + + + + {/* ── Download progress toast (outside sheet) ─────────── */} + {downloadJob && !sheetOpen && ( +
+

Downloading Audio...

+
+
+
+

{downloadProgress}% complete

+
+ )} + + {/* ── Share dialog ────────────────────────────────────── */} + {shareTarget && ( + setShareTarget(null)} + /> + )} + + ); +}