mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
course,tasklist,task and completed validation
Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, GripVertical, ChevronsUpDown, BookOpen, Layers, FileText, Link2, Unlink } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '@/components/ui/command';
|
||||
|
||||
// ─── Prerequisite type config ──────────────────────────────────────────────────
|
||||
// idKey is the numeric FK field each flat list carries (course_id/unit_id/
|
||||
// lesson_id) — CoursePrerequisite.ref_id is a BIGINT FK, not a uuid, so the
|
||||
// picker must select on the numeric id even though the list is keyed/searched
|
||||
// by uuid for React purposes.
|
||||
const REF_TYPES = [
|
||||
{ value: 'course', label: 'Course', icon: BookOpen, idKey: 'course_id' },
|
||||
{ value: 'unit', label: 'Unit', icon: Layers, idKey: 'unit_id' },
|
||||
{ value: 'lesson', label: 'Lesson', icon: FileText, idKey: 'lesson_id' },
|
||||
];
|
||||
const TYPE_MAP = Object.fromEntries(REF_TYPES.map((t) => [t.value, t]));
|
||||
|
||||
// ─── Course binding indicator (units/lessons may sit under 0..N courses) ──────
|
||||
function BindingLine({ courses = [] }) {
|
||||
if (!courses.length) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs italic text-muted-foreground truncate">
|
||||
<Unlink className="size-3 shrink-0" />
|
||||
Standalone
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground truncate">
|
||||
<Link2 className="size-3 shrink-0" />
|
||||
<span className="truncate">{courses.map((c) => c.title).join(', ')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom content picker — own Dialog rather than an anchored Popover ───────
|
||||
function ContentPicker({ value, options, idKey, placeholder = 'Select…', dialogTitle, onSelect, renderTrigger, renderItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((o) => String(o[idKey]) === String(value));
|
||||
|
||||
const searchValue = (o) => {
|
||||
const courseNames = (o.courses ?? []).map((c) => c.title).join(' ');
|
||||
return `${o.title ?? ''} ${courseNames}`.trim() || String(o[idKey]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-auto min-h-8 w-full justify-between text-sm font-normal py-1.5 px-3"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{selected
|
||||
? renderTrigger(selected)
|
||||
: <span className="text-muted-foreground">{placeholder}</span>}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="top-[15%] translate-y-0 flex flex-col max-h-[70vh] overflow-hidden rounded-xl! p-0 gap-0 sm:max-w-md">
|
||||
<DialogHeader className="px-4 pt-4 pb-3 border-b pr-10">
|
||||
<DialogTitle className="text-sm">{dialogTitle ?? placeholder}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Command className="flex-1 min-h-0 rounded-none! bg-transparent p-0" loop>
|
||||
<CommandInput placeholder="Search…" />
|
||||
<CommandList className="max-h-[50vh]">
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((o) => (
|
||||
<CommandItem
|
||||
key={o[idKey]}
|
||||
value={searchValue(o)}
|
||||
onSelect={() => { onSelect(o); setOpen(false); }}
|
||||
className="p-0"
|
||||
>
|
||||
{renderItem(o)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function createPrereq(type = 'course') {
|
||||
return { _key: crypto.randomUUID(), ref_type: type, ref_id: '', title: '' };
|
||||
}
|
||||
|
||||
// ─── CoursePrerequisiteBuilder ─────────────────────────────────────────────────
|
||||
// value: [{ prereq_id?, ref_type, ref_id, title? }]
|
||||
export default function CoursePrerequisiteBuilder({ value = [], onChange, courses = [], units = [], lessons = [], excludeCourseId }) {
|
||||
const [items, setItems] = useState(
|
||||
value.length > 0
|
||||
? value.map((v) => ({ _key: crypto.randomUUID(), ...v }))
|
||||
: []
|
||||
);
|
||||
|
||||
const filteredCourses = courses.filter((c) => String(c.course_id) !== String(excludeCourseId));
|
||||
const optionsFor = { course: filteredCourses, unit: units, lesson: lessons };
|
||||
|
||||
const emit = (next) => {
|
||||
setItems(next);
|
||||
onChange?.(next.map(({ _key, ...r }) => r));
|
||||
};
|
||||
|
||||
const addItem = () => emit([...items, createPrereq('course')]);
|
||||
const removeItem = (key) => emit(items.filter((i) => i._key !== key));
|
||||
const updateItem = (key, patch) =>
|
||||
emit(items.map((i) => (i._key === key ? { ...i, ...patch } : i)));
|
||||
|
||||
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 prerequisites added. Learners can start this course freely.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item, idx) => {
|
||||
const typeDef = TYPE_MAP[item.ref_type];
|
||||
const Icon = typeDef?.icon ?? BookOpen;
|
||||
const options = optionsFor[item.ref_type] ?? [];
|
||||
|
||||
return (
|
||||
<Card key={item._key}>
|
||||
<CardContent className="pt-4 pb-4 space-y-3">
|
||||
<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>
|
||||
|
||||
<Select
|
||||
value={item.ref_type}
|
||||
onValueChange={(v) => updateItem(item._key, { ref_type: v, ref_id: '', title: '' })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm w-32 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REF_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>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<ContentPicker
|
||||
value={item.ref_id}
|
||||
options={options}
|
||||
idKey={typeDef.idKey}
|
||||
placeholder={`Select a ${typeDef.label.toLowerCase()}`}
|
||||
dialogTitle={`Select a ${typeDef.label.toLowerCase()}`}
|
||||
onSelect={(o) => updateItem(item._key, { ref_id: o[typeDef.idKey], title: o.title })}
|
||||
renderTrigger={(o) => (
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{item.ref_type !== 'course' && (
|
||||
<div className="flex flex-col items-start flex-1 min-w-0">
|
||||
<BindingLine courses={o.courses} />
|
||||
<span className="text-sm leading-tight truncate">{o.title}</span>
|
||||
</div>
|
||||
)}
|
||||
{item.ref_type === 'course' && (
|
||||
<span className="flex-1 truncate text-sm">{o.title}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
renderItem={(o) => (
|
||||
<div className="flex items-center gap-2 px-3 py-2 w-full">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="text-sm leading-tight truncate">{o.title}</span>
|
||||
{item.ref_type !== 'course' && <BindingLine courses={o.courses} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button type="button" variant="outline" size="sm" className="w-full gap-2" onClick={addItem}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Prerequisite
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,11 +135,12 @@ export function PreviewVideo({ url, thumb }) {
|
||||
// AudioBlock themselves) attaches the block's own id/type to each call, since a lesson
|
||||
// can have several blocks of the same type and watch_video/listen_audio need to know
|
||||
// which specific one just reported progress.
|
||||
export function PreviewBlock({ block, onWatchProgress }) {
|
||||
export function PreviewBlock({ block, onWatchProgress, resumeMap, antiSkipEnabled }) {
|
||||
const { id, type, content } = block;
|
||||
const withBlockMeta = onWatchProgress
|
||||
? (percent) => onWatchProgress(percent, { blockId: id, blockType: type })
|
||||
: undefined;
|
||||
const resumePercent = resumeMap?.[id];
|
||||
|
||||
switch (type) {
|
||||
case "text":
|
||||
@@ -149,11 +150,11 @@ export function PreviewBlock({ block, onWatchProgress }) {
|
||||
case "text-image":
|
||||
return <TextImageBlock blockId={id} content={content} readOnly />;
|
||||
case "video":
|
||||
return <VideoBlock content={content} readOnly onWatchProgress={withBlockMeta} />;
|
||||
return <VideoBlock content={content} readOnly onWatchProgress={withBlockMeta} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />;
|
||||
case "text-video":
|
||||
return <TextVideoBlock blockId={id} content={content} readOnly onWatchProgress={withBlockMeta} />;
|
||||
return <TextVideoBlock blockId={id} content={content} readOnly onWatchProgress={withBlockMeta} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />;
|
||||
case "audio":
|
||||
return <AudioBlock content={content} onWatchProgress={withBlockMeta} />;
|
||||
return <AudioBlock content={content} onWatchProgress={withBlockMeta} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />;
|
||||
case "code":
|
||||
return <CodeBlock content={content} />;
|
||||
case "markdown":
|
||||
@@ -167,7 +168,13 @@ export function PreviewBlock({ block, onWatchProgress }) {
|
||||
// PhotoProvider wraps ALL blocks so images across the whole lesson share
|
||||
// one lightbox session — users can swipe between them naturally.
|
||||
|
||||
// Only these completion-requirement types gate video/audio behind the anti-skip
|
||||
// seek-cap — everything else (or no requirement configured) allows free seeking.
|
||||
const WATCH_TYPE_REQUIREMENTS = ["watch_percent", "watch_video", "listen_audio"];
|
||||
|
||||
export function PreviewContent({ lesson, blocks, empty = "No content yet.", showHeader = true, onWatchProgress }) {
|
||||
const resumeMap = lesson?.resume_positions;
|
||||
const antiSkipEnabled = WATCH_TYPE_REQUIREMENTS.includes(lesson?.completion?.type);
|
||||
return (
|
||||
<PhotoProvider
|
||||
speed={() => 300}
|
||||
@@ -194,7 +201,7 @@ export function PreviewContent({ lesson, blocks, empty = "No content yet.", show
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
{blocks.map((block) => (
|
||||
<div key={block.id}>
|
||||
<PreviewBlock block={block} onWatchProgress={onWatchProgress} />
|
||||
<PreviewBlock block={block} onWatchProgress={onWatchProgress} resumeMap={resumeMap} antiSkipEnabled={antiSkipEnabled} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user