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 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||||
|
<button
|
||||||
|
onClick={() => onSort(field)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-xs font-medium text-muted-foreground select-none hover:text-foreground",
|
||||||
|
active && "text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{active ? (
|
||||||
|
sortOrder === "asc" ? (
|
||||||
|
<ArrowUp className="size-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="size-3" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="size-3 opacity-40" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Type icon helper ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function TypeIcon({ type }: { type: string }) {
|
||||||
|
const cls = "size-4 shrink-0";
|
||||||
|
switch (type) {
|
||||||
|
case "image":
|
||||||
|
return <Image className={cn(cls, "text-blue-500")} />;
|
||||||
|
case "youtube_audio":
|
||||||
|
return <Music className={cn(cls, "text-purple-500")} />;
|
||||||
|
default:
|
||||||
|
return <FileText className={cn(cls, "text-muted-foreground")} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
export default function QuestionBankView() {
|
||||||
|
const { dbUser } = useAuth();
|
||||||
|
|
||||||
|
/* ─── State ──────────────────────────────────────────────── */
|
||||||
|
const [questions, setQuestions] = useState<Question[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [formData, setFormData] = useState<QuestionFormData>({ ...INITIAL_FORM });
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [selectedQuestions, setSelectedQuestions] = useState<number[]>([]);
|
||||||
|
const [showBulkCategory, setShowBulkCategory] = useState(false);
|
||||||
|
const [bulkCategory, setBulkCategory] = useState("");
|
||||||
|
const [downloadJob, setDownloadJob] = useState<DownloadJob | null>(null);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
|
const [shareTarget, setShareTarget] = useState<Question | number[] | null>(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<SortField>("created_at");
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
/* ─── Load data ──────────────────────────────────────────── */
|
||||||
|
useEffect(() => {
|
||||||
|
loadQuestions();
|
||||||
|
loadCategories();
|
||||||
|
}, [searchTerm, filterCategory, filterType, filterOwner, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
const loadQuestions = async () => {
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<AdminNavbar />
|
||||||
|
<div className="mx-auto flex h-[calc(100vh-60px)] max-w-7xl flex-col px-4 lg:px-8">
|
||||||
|
{/* ── Toolbar ─────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 py-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative min-w-[200px] flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Search questions or answers..."
|
||||||
|
className="h-9 pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category filter */}
|
||||||
|
<select
|
||||||
|
value={filterCategory}
|
||||||
|
onChange={(e) => setFilterCategory(e.target.value)}
|
||||||
|
className={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
<option value="none">Uncategorized</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.name}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Type filter */}
|
||||||
|
<select
|
||||||
|
value={filterType}
|
||||||
|
onChange={(e) => setFilterType(e.target.value)}
|
||||||
|
className={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="image">Image</option>
|
||||||
|
<option value="youtube_audio">YouTube Audio</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Owner filter */}
|
||||||
|
<select
|
||||||
|
value={filterOwner}
|
||||||
|
onChange={(e) => setFilterOwner(e.target.value)}
|
||||||
|
className={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="">All Questions</option>
|
||||||
|
<option value="mine">My Questions</option>
|
||||||
|
<option value="shared">Shared with Me</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Count */}
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{questions.length} question{questions.length !== 1 && "s"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Clear filters */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Question */}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData({ ...INITIAL_FORM });
|
||||||
|
setImagePreview(null);
|
||||||
|
setSheetOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add Question
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── List header (sort row) ──────────────────────── */}
|
||||||
|
<div className="flex items-center gap-2 border-b px-2 pb-2">
|
||||||
|
{/* Checkbox */}
|
||||||
|
<div className="flex w-8 shrink-0 items-center justify-center">
|
||||||
|
<span
|
||||||
|
onClick={toggleSelectAll}
|
||||||
|
className={cn(
|
||||||
|
"flex size-4 cursor-pointer items-center justify-center rounded border text-[0.6rem]",
|
||||||
|
selectedQuestions.length === questions.length && questions.length > 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 && "✓"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Type */}
|
||||||
|
<div className="w-10 shrink-0">
|
||||||
|
<SortHeader label="Type" field="type" sortBy={sortBy} sortOrder={sortOrder} onSort={handleSort} />
|
||||||
|
</div>
|
||||||
|
{/* Question */}
|
||||||
|
<div className="min-w-0 flex-[2]">
|
||||||
|
<SortHeader label="Question" field="question_content" sortBy={sortBy} sortOrder={sortOrder} onSort={handleSort} />
|
||||||
|
</div>
|
||||||
|
{/* Answer */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<SortHeader label="Answer" field="answer" sortBy={sortBy} sortOrder={sortOrder} onSort={handleSort} />
|
||||||
|
</div>
|
||||||
|
{/* Category */}
|
||||||
|
<div className="w-28 shrink-0">
|
||||||
|
<SortHeader label="Category" field="category" sortBy={sortBy} sortOrder={sortOrder} onSort={handleSort} />
|
||||||
|
</div>
|
||||||
|
{/* Creator */}
|
||||||
|
<div className="w-20 shrink-0 text-xs font-medium text-muted-foreground">
|
||||||
|
Creator
|
||||||
|
</div>
|
||||||
|
{/* Actions spacer */}
|
||||||
|
<div className="w-24 shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Virtualized list ────────────────────────────── */}
|
||||||
|
<div ref={scrollRef} className="flex-1 overflow-auto">
|
||||||
|
{questions.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-4">
|
||||||
|
<MessageSquare className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">No questions yet</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70">Create your first question to get started.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setFormData({ ...INITIAL_FORM });
|
||||||
|
setImagePreview(null);
|
||||||
|
setSheetOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add Question
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: rowVirtualizer.getTotalSize(),
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const q = questions[virtualRow.index];
|
||||||
|
const isSelected = selectedQuestions.includes(q.id);
|
||||||
|
const isOwner = q.created_by === dbUser?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
className={cn(
|
||||||
|
"group absolute left-0 flex w-full items-center gap-2 border-b px-2 transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "bg-emerald-50 dark:bg-emerald-950/20"
|
||||||
|
: "hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: virtualRow.start,
|
||||||
|
height: virtualRow.size,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Checkbox */}
|
||||||
|
<div className="flex w-8 shrink-0 items-center justify-center">
|
||||||
|
<span
|
||||||
|
onClick={() => 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 && "✓"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type icon */}
|
||||||
|
<div className="flex w-10 shrink-0 items-center justify-center">
|
||||||
|
<TypeIcon type={q.type} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Question text */}
|
||||||
|
<div className="min-w-0 flex-[2] truncate text-sm">
|
||||||
|
{q.question_content}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Answer */}
|
||||||
|
<div className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||||
|
{q.answer}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div className="w-28 shrink-0">
|
||||||
|
{q.category ? (
|
||||||
|
<Badge variant="outline" className="text-[0.65rem]">
|
||||||
|
{q.category}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">--</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Creator */}
|
||||||
|
<div className="w-20 shrink-0">
|
||||||
|
{isOwner ? (
|
||||||
|
<Badge variant="secondary" className="text-[0.6rem]">
|
||||||
|
You
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{q.creator_name || "Unknown"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover actions */}
|
||||||
|
<div className="flex w-24 shrink-0 items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
{isOwner && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => handleEdit(q)}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => setShareTarget(q)}
|
||||||
|
title="Share"
|
||||||
|
>
|
||||||
|
<Share2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => handleDelete(q.id)}
|
||||||
|
title="Delete"
|
||||||
|
className="hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Bulk action bar ─────────────────────────────── */}
|
||||||
|
{selectedQuestions.length > 0 && (
|
||||||
|
<div className="sticky bottom-0 -mx-4 flex items-center gap-3 border-t bg-background/95 px-4 py-3 backdrop-blur lg:-mx-8 lg:px-8">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedQuestions.length} selected
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Assign Category */}
|
||||||
|
{showBulkCategory ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={bulkCategory}
|
||||||
|
onChange={(e) => setBulkCategory(e.target.value)}
|
||||||
|
className={cn(selectStyle, "h-8")}
|
||||||
|
>
|
||||||
|
<option value="">-- No Category --</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.name}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button size="sm" onClick={handleBulkCategoryAssign}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowBulkCategory(false);
|
||||||
|
setBulkCategory("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowBulkCategory(true)}
|
||||||
|
>
|
||||||
|
Assign Category
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Share Selected */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShareTarget(selectedQuestions)}
|
||||||
|
>
|
||||||
|
<Share2 className="size-3.5" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Delete Selected */}
|
||||||
|
<Button variant="destructive" size="sm" onClick={handleBulkDelete}>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sheet (Add / Edit form) ────────────────────────── */}
|
||||||
|
<Sheet open={sheetOpen} onOpenChange={(open) => { if (!open) closeSheet(); else setSheetOpen(true); }}>
|
||||||
|
<SheetContent side="right" className="sm:max-w-lg overflow-y-auto">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{editingId ? "Edit Question" : "Add Question"}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
{editingId ? "Update this question's details." : "Create a new trivia question."}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-5 px-4">
|
||||||
|
{/* Type selector */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium">Type</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{[
|
||||||
|
{ value: "text", icon: FileText, label: "Text" },
|
||||||
|
{ value: "image", icon: Image, label: "Image" },
|
||||||
|
{ value: "youtube_audio", icon: Music, label: "Audio" },
|
||||||
|
].map(({ value, icon: Icon, label }) => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
variant={formData.type === value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFormData({ ...formData, type: value })}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium">
|
||||||
|
Category <span className="font-normal text-muted-foreground">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.category}
|
||||||
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||||
|
className={cn(selectStyle, "w-full")}
|
||||||
|
>
|
||||||
|
<option value="">-- No Category --</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.name}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Link
|
||||||
|
to="/categories"
|
||||||
|
className="mt-1 inline-block text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Manage Categories
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Question content */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium">Question</label>
|
||||||
|
<Input
|
||||||
|
value={formData.question_content}
|
||||||
|
onChange={(e) => setFormData({ ...formData, question_content: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Enter question text"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image upload */}
|
||||||
|
{formData.type === "image" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium">Image</label>
|
||||||
|
<div
|
||||||
|
onPaste={handlePaste}
|
||||||
|
className="cursor-pointer rounded-lg border-2 border-dashed bg-muted/30 p-6 text-center transition-colors hover:border-primary/50"
|
||||||
|
>
|
||||||
|
<Upload className="mx-auto mb-2 size-6 text-muted-foreground" />
|
||||||
|
<p className="mb-2 text-sm text-muted-foreground">
|
||||||
|
Paste an image (Ctrl/Cmd+V) or upload below
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => handleImageChange(e.target.files?.[0])}
|
||||||
|
required={!editingId && !formData.image}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{imagePreview && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="mb-1 text-xs text-muted-foreground">Preview:</p>
|
||||||
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt="Preview"
|
||||||
|
className="max-h-48 max-w-full rounded-md border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editingId && !imagePreview && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Leave empty to keep current image
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* YouTube audio fields */}
|
||||||
|
{formData.type === "youtube_audio" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium">YouTube URL</label>
|
||||||
|
<Input
|
||||||
|
value={formData.youtube_url}
|
||||||
|
onChange={(e) => setFormData({ ...formData, youtube_url: e.target.value })}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
required
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium">Start Time (s)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.start_time}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, start_time: parseInt(e.target.value) || 0 })
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium">End Time (s)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.end_time}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, end_time: parseInt(e.target.value) || 0 })
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
required
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Clip duration: {formData.end_time - formData.start_time} seconds (max 300)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Answer */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium">Answer</label>
|
||||||
|
<Input
|
||||||
|
value={formData.answer}
|
||||||
|
onChange={(e) => setFormData({ ...formData, answer: e.target.value })}
|
||||||
|
required
|
||||||
|
placeholder="Enter answer"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download progress inline */}
|
||||||
|
{downloadJob && (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3">
|
||||||
|
<p className="mb-2 text-sm font-medium">Downloading Audio...</p>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-emerald-500 transition-[width] duration-300"
|
||||||
|
style={{ width: `${downloadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{downloadProgress}% complete</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button className="w-full" onClick={(e) => {
|
||||||
|
const form = (e.target as HTMLElement).closest('[data-slot="sheet-content"]')?.querySelector('form');
|
||||||
|
form?.requestSubmit();
|
||||||
|
}}>
|
||||||
|
{editingId ? "Update Question" : "Create Question"}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* ── Download progress toast (outside sheet) ─────────── */}
|
||||||
|
{downloadJob && !sheetOpen && (
|
||||||
|
<div className="fixed bottom-6 right-6 z-50 min-w-[300px] rounded-lg border bg-popover p-4 shadow-lg">
|
||||||
|
<h4 className="mb-2 text-sm font-semibold">Downloading Audio...</h4>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-emerald-500 transition-[width] duration-300"
|
||||||
|
style={{ width: `${downloadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{downloadProgress}% complete</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Share dialog ────────────────────────────────────── */}
|
||||||
|
{shareTarget && (
|
||||||
|
<ShareDialog
|
||||||
|
target={shareTarget}
|
||||||
|
onClose={() => setShareTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user