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