mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added new requirements and fix UI bugs
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, GripVertical, BookOpenCheck, Save, Info } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { TYPE_DEFS, DEFAULT_BEHAVIOR_TEXT } from "./completionRequirementTypes";
|
||||
|
||||
function createRequirement(type) {
|
||||
return { _key: crypto.randomUUID(), type, min_percent: 100, button_label: "", is_required: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained editor for one entity's CompletionRequirement rows — fetches on mount,
|
||||
* saves via its own button (matches EditCourse.jsx's per-section-save convention, not a
|
||||
* bundled page-level submit). Works for both nested (courseId present) and standalone
|
||||
* library (courseId omitted) entities — fetchFn/syncFn + args resolve that server-side.
|
||||
*/
|
||||
export default function CompletionRequirementBuilder({ entityType, fetchFn, syncFn, args = [] }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetchFn(...args).then((rows) => {
|
||||
if (!active) return;
|
||||
setItems((rows ?? []).map((r) => ({ _key: crypto.randomUUID(), ...r })));
|
||||
setLoading(false);
|
||||
setDirty(false);
|
||||
});
|
||||
return () => { active = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...args]);
|
||||
|
||||
const availableTypes = Object.entries(TYPE_DEFS)
|
||||
.filter(([, def]) => def.entityTypes.includes(entityType))
|
||||
.map(([value, def]) => ({ value, ...def }));
|
||||
|
||||
const usedTypes = new Set(items.map((i) => i.type));
|
||||
const addableTypes = availableTypes.filter((t) => !usedTypes.has(t.value));
|
||||
|
||||
const update = (key, patch) => {
|
||||
setItems((prev) => prev.map((i) => (i._key === key ? { ...i, ...patch } : i)));
|
||||
setDirty(true);
|
||||
};
|
||||
const addItem = (type) => {
|
||||
setItems((prev) => [...prev, createRequirement(type)]);
|
||||
setDirty(true);
|
||||
};
|
||||
const removeItem = (key) => {
|
||||
setItems((prev) => prev.filter((i) => i._key !== key));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
const clean = items.map(({ _key, ...r }) => r);
|
||||
const result = await syncFn(...args, clean);
|
||||
if (result) {
|
||||
setItems(result.map((r) => ({ _key: crypto.randomUUID(), ...r })));
|
||||
setDirty(false);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && (
|
||||
<p className="flex items-start gap-2 text-sm text-muted-foreground py-4 px-3 border border-dashed rounded-lg">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
No completion requirements configured — by default, {DEFAULT_BEHAVIOR_TEXT[entityType]}.
|
||||
Add a requirement below to override this.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item, idx) => {
|
||||
const def = TYPE_DEFS[item.type];
|
||||
const Icon = def?.icon ?? BookOpenCheck;
|
||||
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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{def?.label ?? item.type}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{def?.describe(entityType)}</p>
|
||||
</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>
|
||||
|
||||
{item.type === "watch_percent" && (
|
||||
<div className="pl-7 flex items-center gap-2 max-w-48">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={item.min_percent ?? 100}
|
||||
onChange={(e) => update(item._key, { min_percent: Math.min(100, Math.max(1, parseInt(e.target.value) || 1)) })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">% watched required</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.type === "manual_complete" && (
|
||||
<div className="pl-7 space-y-1 max-w-64">
|
||||
<Label className="text-xs">Button label (optional)</Label>
|
||||
<Input
|
||||
placeholder='Defaults to "Mark Complete"'
|
||||
value={item.button_label ?? ""}
|
||||
onChange={(e) => update(item._key, { button_label: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{addableTypes.length > 0 && (
|
||||
<Select value="" onValueChange={(v) => addItem(v)}>
|
||||
<SelectTrigger className="h-9 w-full text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Requirement
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{addableTypes.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 justify-end pt-1">
|
||||
<Button type="button" size="sm" className="gap-2" onClick={handleSave} disabled={saving || !dirty}>
|
||||
{saving ? <Spinner className="size-4" /> : <Save className="size-4" />}
|
||||
Save Requirements
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Plus, Trash2, GripVertical, BookOpenCheck, ListChecks, Info } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { TYPE_DEFS, DEFAULT_BEHAVIOR_TEXT } from "./completionRequirementTypes";
|
||||
|
||||
function createRequirement(type) {
|
||||
return { _key: crypto.randomUUID(), type, min_percent: 100, button_label: "", is_required: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft-only requirements editor for creation wizards — no fetch, no
|
||||
* independent save. The parent wizard holds `items` in local state and only
|
||||
* persists them (via its own sync call) together with the rest of the entity
|
||||
* when the wizard's final Create/Finish action fires — the entity doesn't
|
||||
* exist yet while this renders, so there's nothing to fetch or save against
|
||||
* until then. Mirrors CompletionRequirementBuilder's item UI, minus the
|
||||
* loading/save plumbing that assumes an already-existing entity.
|
||||
*/
|
||||
export default function DraftRequirementsEditor({ entityType, items, onChange, blockTypes }) {
|
||||
// blockTypes (optional) — the actual block types currently on this lesson's page
|
||||
// (e.g. ["video", "text"]). When provided, requirement types that need a specific
|
||||
// block (watch_video needs "video", listen_audio needs "audio") only show up once
|
||||
// that block actually exists — "detection" rather than always offering them.
|
||||
const availableTypes = Object.entries(TYPE_DEFS)
|
||||
.filter(([, def]) => def.entityTypes.includes(entityType))
|
||||
.filter(([, def]) => !blockTypes || !def.requiresBlockTypes || def.requiresBlockTypes.some((bt) => blockTypes.includes(bt)))
|
||||
.map(([value, def]) => ({ value, ...def }));
|
||||
|
||||
const usedTypes = new Set(items.map((i) => i.type));
|
||||
const addableTypes = availableTypes.filter((t) => !usedTypes.has(t.value));
|
||||
|
||||
const update = (key, patch) => onChange(items.map((i) => (i._key === key ? { ...i, ...patch } : i)));
|
||||
const addItem = (type) => onChange([...items, createRequirement(type)]);
|
||||
const removeItem = (key) => onChange(items.filter((i) => i._key !== key));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 && (
|
||||
<p className="flex items-start gap-2 text-sm text-muted-foreground py-4 px-3 border border-dashed rounded-lg">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
No completion requirements configured — by default, {DEFAULT_BEHAVIOR_TEXT[entityType]}.
|
||||
Add a requirement below to override this.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item, idx) => {
|
||||
const def = TYPE_DEFS[item.type];
|
||||
const Icon = def?.icon ?? BookOpenCheck;
|
||||
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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{def?.label ?? item.type}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{def?.describe(entityType)}</p>
|
||||
</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>
|
||||
|
||||
{item.type === "watch_percent" && (
|
||||
<div className="pl-7 flex items-center gap-2 max-w-48">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={item.min_percent ?? 100}
|
||||
onChange={(e) => update(item._key, { min_percent: Math.min(100, Math.max(1, parseInt(e.target.value) || 1)) })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Label className="text-xs text-muted-foreground whitespace-nowrap">% watched required</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.type === "manual_complete" && (
|
||||
<div className="pl-7 space-y-1 max-w-64">
|
||||
<Label className="text-xs">Button label (optional)</Label>
|
||||
<Input
|
||||
placeholder='Defaults to "Mark Complete"'
|
||||
value={item.button_label ?? ""}
|
||||
onChange={(e) => update(item._key, { button_label: e.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{addableTypes.length > 0 && (
|
||||
<Select value="" onValueChange={(v) => addItem(v)}>
|
||||
<SelectTrigger className="h-9 w-full text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Requirement
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{addableTypes.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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only summary of a draft requirements array for a wizard's Review step
|
||||
* — reads directly from local state (no fetch), since nothing has been
|
||||
* persisted yet at that point.
|
||||
*/
|
||||
export function DraftRequirementsSummary({ entityType, items }) {
|
||||
const describeValue = (r) => {
|
||||
if (r.type === "watch_percent") return `${r.min_percent}% watched`;
|
||||
if (r.type === "manual_complete") return r.button_label || "Mark Complete";
|
||||
return "Required";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Completion Requirements</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
None added — default behavior applies ({DEFAULT_BEHAVIOR_TEXT[entityType]}).
|
||||
</p>
|
||||
) : (
|
||||
items.map((r) => (
|
||||
<div key={r._key ?? r.type} className="flex justify-between py-1.5 text-sm">
|
||||
<span className="text-muted-foreground min-w-[140px]">{TYPE_DEFS[r.type]?.label ?? r.type}</span>
|
||||
<span className="text-foreground text-right">{describeValue(r)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,8 +129,17 @@ export function PreviewVideo({ url, thumb }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewBlock({ block }) {
|
||||
// onWatchProgress(percent, { blockId, blockType }) — only meaningful for video/audio
|
||||
// blocks; optional, undefined in admin preview mode (only the client reader passes it,
|
||||
// for watch_percent/watch_video/listen_audio tracking). PreviewBlock (not VideoBlock/
|
||||
// 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 }) {
|
||||
const { id, type, content } = block;
|
||||
const withBlockMeta = onWatchProgress
|
||||
? (percent) => onWatchProgress(percent, { blockId: id, blockType: type })
|
||||
: undefined;
|
||||
|
||||
switch (type) {
|
||||
case "text":
|
||||
@@ -140,11 +149,11 @@ export function PreviewBlock({ block }) {
|
||||
case "text-image":
|
||||
return <TextImageBlock blockId={id} content={content} readOnly />;
|
||||
case "video":
|
||||
return <VideoBlock content={content} readOnly />;
|
||||
return <VideoBlock content={content} readOnly onWatchProgress={withBlockMeta} />;
|
||||
case "text-video":
|
||||
return <TextVideoBlock blockId={id} content={content} readOnly />;
|
||||
case "audio":
|
||||
return <AudioBlock content={content} />;
|
||||
return <AudioBlock content={content} onWatchProgress={withBlockMeta} />;
|
||||
case "code":
|
||||
return <CodeBlock content={content} />;
|
||||
case "markdown":
|
||||
@@ -158,7 +167,7 @@ export function PreviewBlock({ block }) {
|
||||
// PhotoProvider wraps ALL blocks so images across the whole lesson share
|
||||
// one lightbox session — users can swipe between them naturally.
|
||||
|
||||
export function PreviewContent({ lesson, blocks, empty = "No content yet.", showHeader = true }) {
|
||||
export function PreviewContent({ lesson, blocks, empty = "No content yet.", showHeader = true, onWatchProgress }) {
|
||||
return (
|
||||
<PhotoProvider
|
||||
speed={() => 300}
|
||||
@@ -185,7 +194,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} />
|
||||
<PreviewBlock block={block} onWatchProgress={onWatchProgress} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { BookOpenCheck, ClipboardCheck, PlayCircle, Video, Headphones, MousePointerClick } from "lucide-react";
|
||||
|
||||
// ─── Requirement type config ──────────────────────────────────────────────────
|
||||
// Mirrors utils/courses/completion_requirements.registry.js's VALID_ENTITY_TYPES
|
||||
// on the backend — keep in sync if a new type is added there.
|
||||
// Split out of CompletionRequirementBuilder.jsx (rather than exported from
|
||||
// there) so both it and other views (e.g. wizard Review steps) can import
|
||||
// these without tripping the react-refresh/only-export-components rule,
|
||||
// which requires component files to export components only.
|
||||
export const TYPE_DEFS = {
|
||||
read_all_content: {
|
||||
label: "Read / View All Content",
|
||||
icon: BookOpenCheck,
|
||||
entityTypes: ["course", "unit", "lesson"],
|
||||
describe: (entityType) =>
|
||||
entityType === "lesson"
|
||||
? "Learner must read through this lesson's content."
|
||||
: entityType === "unit"
|
||||
? "Every lesson in this unit must be completed."
|
||||
: "Every unit in this course must be completed.",
|
||||
},
|
||||
pass_quiz: {
|
||||
label: "Pass the Quiz",
|
||||
icon: ClipboardCheck,
|
||||
entityTypes: ["unit", "course"],
|
||||
describe: (entityType) =>
|
||||
entityType === "unit"
|
||||
? "Learner must pass this unit's quiz."
|
||||
: "Learner must pass the course's final assessment.",
|
||||
},
|
||||
watch_percent: {
|
||||
label: "Watch % of Video/Audio",
|
||||
icon: PlayCircle,
|
||||
entityTypes: ["lesson"],
|
||||
// Either block type satisfies this one — it's one aggregate percent across
|
||||
// whichever is playing, unlike watch_video/listen_audio below.
|
||||
requiresBlockTypes: ["video", "audio"],
|
||||
describe: () => "Learner must watch at least the configured percentage of the lesson's video/audio content.",
|
||||
},
|
||||
watch_video: {
|
||||
label: "Finish Watching the Full Video",
|
||||
icon: Video,
|
||||
entityTypes: ["lesson"],
|
||||
requiresBlockTypes: ["video"],
|
||||
describe: () => "Learner must watch every video block on this lesson all the way through (100%).",
|
||||
},
|
||||
listen_audio: {
|
||||
label: "Finish Listening to the Full Audio",
|
||||
icon: Headphones,
|
||||
entityTypes: ["lesson"],
|
||||
requiresBlockTypes: ["audio"],
|
||||
describe: () => "Learner must listen to every audio block on this lesson all the way through (100%).",
|
||||
},
|
||||
manual_complete: {
|
||||
label: 'Manual "Mark Complete"',
|
||||
icon: MousePointerClick,
|
||||
entityTypes: ["course", "unit", "lesson"],
|
||||
describe: () => "Learner clicks a button to self-report completion — no automatic tracking.",
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_BEHAVIOR_TEXT = {
|
||||
lesson: "the learner reading through the content marks it complete",
|
||||
unit: "every lesson in the unit must be completed",
|
||||
course: "every unit must be completed and the course assessment (if any) must be passed",
|
||||
};
|
||||
Reference in New Issue
Block a user