mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
adjusted
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
// ─── Requirement type config ──────────────────────────────────────────────────
|
||||
const REQUIREMENT_TYPES = [
|
||||
{ value: 'visit_link', label: 'Visit a Link', icon: Link, category: 'Action' },
|
||||
{ value: 'upload_file', label: 'Upload a File', icon: Upload, category: 'Action' },
|
||||
{ value: 'read_course', label: 'Read a Course', icon: BookOpen, category: 'Content' },
|
||||
{ value: 'read_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
|
||||
{ value: 'read_lesson', label: 'Read a Lesson', icon: FileText, category: 'Content' },
|
||||
];
|
||||
|
||||
const TYPE_MAP = Object.fromEntries(REQUIREMENT_TYPES.map((t) => [t.value, t]));
|
||||
|
||||
const FILE_TYPE_OPTIONS = [
|
||||
{ value: 'pdf', label: 'PDF' },
|
||||
{ value: 'docx', label: 'DOCX' },
|
||||
{ value: 'xlsx', label: 'XLSX' },
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'jpg', label: 'JPG' },
|
||||
{ value: 'mp4', label: 'MP4' },
|
||||
{ value: 'zip', label: 'ZIP' },
|
||||
];
|
||||
|
||||
// ─── Empty requirement factory ────────────────────────────────────────────────
|
||||
function createRequirement(type = 'visit_link') {
|
||||
return {
|
||||
_key: crypto.randomUUID(),
|
||||
type,
|
||||
// visit_link
|
||||
link_url: '',
|
||||
link_label: '',
|
||||
// upload_file
|
||||
allowed_file_types: [],
|
||||
max_file_count: 1,
|
||||
// read_*
|
||||
reference_id: '',
|
||||
reference_label: '',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── RequirementBuilder ───────────────────────────────────────────────────────
|
||||
export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [] }) {
|
||||
const [items, setItems] = useState(
|
||||
value.length > 0
|
||||
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
|
||||
: []
|
||||
);
|
||||
|
||||
const emit = (next) => {
|
||||
setItems(next);
|
||||
// strip _key before calling onChange
|
||||
onChange?.(next.map(({ _key, ...r }) => r));
|
||||
};
|
||||
|
||||
const addItem = () => emit([...items, createRequirement('visit_link')]);
|
||||
|
||||
const removeItem = (key) => emit(items.filter((i) => i._key !== key));
|
||||
|
||||
const updateItem = (key, patch) =>
|
||||
emit(items.map((i) => (i._key === key ? { ...i, ...patch } : i)));
|
||||
|
||||
const toggleFileType = (key, ft) => {
|
||||
const item = items.find((i) => i._key === key);
|
||||
if (!item) return;
|
||||
const current = item.allowed_file_types ?? [];
|
||||
const next = current.includes(ft)
|
||||
? current.filter((t) => t !== ft)
|
||||
: [...current, ft];
|
||||
updateItem(key, { allowed_file_types: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-6 border border-dashed rounded-lg">
|
||||
No requirements added. Click "Add Requirement" to start.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item, idx) => {
|
||||
const typeDef = TYPE_MAP[item.type];
|
||||
const Icon = typeDef?.icon ?? Link;
|
||||
|
||||
return (
|
||||
<Card key={item._key} className="relative">
|
||||
<CardContent className="pt-4 pb-4 space-y-3">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Badge variant="outline" className="text-xs gap-1 shrink-0">
|
||||
<Icon className="h-3 w-3" />
|
||||
{idx + 1}
|
||||
</Badge>
|
||||
|
||||
{/* Type selector */}
|
||||
<Select
|
||||
value={item.type}
|
||||
onValueChange={(v) => updateItem(item._key, { type: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REQUIREMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<t.icon className="h-3.5 w-3.5" />
|
||||
{t.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
|
||||
onClick={() => removeItem(item._key)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── visit_link fields ── */}
|
||||
{item.type === 'visit_link' && (
|
||||
<div className="grid grid-cols-2 gap-3 pl-7">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">URL *</Label>
|
||||
<Input
|
||||
placeholder="https://example.com"
|
||||
value={item.link_url}
|
||||
onChange={(e) => updateItem(item._key, { link_url: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Label (optional)</Label>
|
||||
<Input
|
||||
placeholder="Link description"
|
||||
value={item.link_label}
|
||||
onChange={(e) => updateItem(item._key, { link_label: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── upload_file fields ── */}
|
||||
{item.type === 'upload_file' && (
|
||||
<div className="pl-7 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Allowed File Types</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILE_TYPE_OPTIONS.map((ft) => (
|
||||
<Badge
|
||||
key={ft.value}
|
||||
variant={(item.allowed_file_types ?? []).includes(ft.value) ? 'default' : 'outline'}
|
||||
className="cursor-pointer select-none text-xs"
|
||||
onClick={() => toggleFileType(item._key, ft.value)}
|
||||
>
|
||||
{ft.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 w-32">
|
||||
<Label className="text-xs">Max Files</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={item.max_file_count}
|
||||
onChange={(e) => updateItem(item._key, { max_file_count: parseInt(e.target.value) || 1 })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── read_course / read_unit / read_lesson fields ── */}
|
||||
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
|
||||
<div className="pl-7 space-y-1">
|
||||
<Label className="text-xs">
|
||||
{item.type === 'read_course' ? 'Course' : item.type === 'read_unit' ? 'Unit' : 'Lesson'}
|
||||
</Label>
|
||||
|
||||
{/* Reference selector */}
|
||||
{item.type === 'read_course' && (
|
||||
<Select
|
||||
value={item.reference_id}
|
||||
onValueChange={(v) => {
|
||||
const course = courses.find((c) => c.course_id === v);
|
||||
updateItem(item._key, {
|
||||
reference_id: v,
|
||||
reference_label: course?.title ?? '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select a course" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{courses.map((c) => (
|
||||
<SelectItem key={c.course_id} value={c.course_id}>
|
||||
{c.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{item.type === 'read_unit' && (
|
||||
<Select
|
||||
value={item.reference_id}
|
||||
onValueChange={(v) => {
|
||||
const unit = units.find((u) => u.unit_id === v);
|
||||
updateItem(item._key, {
|
||||
reference_id: v,
|
||||
reference_label: unit?.title ?? '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select a unit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{units.map((u) => (
|
||||
<SelectItem key={u.unit_id} value={u.unit_id}>
|
||||
{u.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{item.type === 'read_lesson' && (
|
||||
<Select
|
||||
value={item.reference_id}
|
||||
onValueChange={(v) => {
|
||||
const lesson = lessons.find((l) => l.lesson_id === v);
|
||||
updateItem(item._key, {
|
||||
reference_id: v,
|
||||
reference_label: lesson?.title ?? '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select a lesson" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lessons.map((l) => (
|
||||
<SelectItem key={l.lesson_id} value={l.lesson_id}>
|
||||
{l.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button type="button" variant="outline" size="sm" className="w-full gap-2" onClick={addItem}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Requirement
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user