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:
2026-04-03 13:36:23 -04:00
parent b3b6827a89
commit a5a80aa93d
2 changed files with 997 additions and 1132 deletions

View File

@@ -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)}
/>
)}
</>
);
}