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:
@@ -2,6 +2,7 @@ import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,11 +17,6 @@ const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
||||
|
||||
// How often onWatchProgress may fire while playing (ms) — keeps the watch-progress
|
||||
// endpoint from getting hit on every timeupdate tick. onEnded still always reports
|
||||
// a final 100% immediately regardless of this window, so completion never lags.
|
||||
const WATCH_PROGRESS_THROTTLE_MS = 10000;
|
||||
|
||||
// ─── AudioBlock (Client — secure) ────────────────────────────────────────────
|
||||
//
|
||||
// S3/Garage:
|
||||
@@ -39,9 +35,10 @@ const WATCH_PROGRESS_THROTTLE_MS = 10000;
|
||||
|
||||
// onWatchProgress(percent) — optional, called (throttled) as playback advances and
|
||||
// immediately at 100% on end. Backing the watch_percent completion requirement type.
|
||||
export function AudioBlock({ content, onWatchProgress }) {
|
||||
export function AudioBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
|
||||
const audioRef = useRef(null);
|
||||
const lastReportRef = useRef(0);
|
||||
const guard = useMediaWatchGuard({ onWatchProgress, antiSkipEnabled });
|
||||
const resumedRef = useRef(false);
|
||||
|
||||
// ── Stream state ──────────────────────────────────────────────────────────
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
@@ -76,6 +73,8 @@ export function AudioBlock({ content, onWatchProgress }) {
|
||||
setPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
guard.reset();
|
||||
resumedRef.current = false;
|
||||
|
||||
// Legacy direct URL — no asset_id
|
||||
if (!assetId && directUrl) {
|
||||
@@ -127,17 +126,40 @@ export function AudioBlock({ content, onWatchProgress }) {
|
||||
const onTimeUpdate = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
setCurrentTime(el?.currentTime ?? 0);
|
||||
if (onWatchProgress && el?.duration) {
|
||||
guard.trackTimeUpdate(el?.currentTime ?? 0, el?.duration);
|
||||
if (el?.duration) {
|
||||
const pct = (el.currentTime / el.duration) * 100;
|
||||
const now = Date.now();
|
||||
if (now - lastReportRef.current > WATCH_PROGRESS_THROTTLE_MS) {
|
||||
lastReportRef.current = now;
|
||||
onWatchProgress(pct);
|
||||
}
|
||||
guard.maybeReport(pct);
|
||||
}
|
||||
}, [onWatchProgress]);
|
||||
const onLoadedMeta = useCallback(() => setDuration(audioRef.current?.duration ?? 0), []);
|
||||
}, [guard]);
|
||||
const onLoadedMeta = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
const d = el?.duration ?? 0;
|
||||
setDuration(d);
|
||||
guard.setDuration(d);
|
||||
// Resume from the last position reached, once per asset. Left alone once
|
||||
// fully watched (>=99%) — restarting reads better than resuming at the end.
|
||||
if (el && !resumedRef.current && resumePercent > 0 && resumePercent < 99 && d) {
|
||||
resumedRef.current = true;
|
||||
const resumeSeconds = Math.min((resumePercent / 100) * d, d - 0.25);
|
||||
el.currentTime = resumeSeconds;
|
||||
setCurrentTime(resumeSeconds);
|
||||
// Seeds the seek-cap so forward-seeking within already-watched territory
|
||||
// works immediately, instead of clamping back to 0 until the next tick.
|
||||
guard.trackTimeUpdate(resumeSeconds, d);
|
||||
}
|
||||
}, [guard, resumePercent]);
|
||||
const onEnded = useCallback(() => { setPlaying(false); onWatchProgress?.(100); }, [onWatchProgress]);
|
||||
const onPlay = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (el?.duration) guard.reportPlayStart((el.currentTime / el.duration) * 100);
|
||||
}, [guard]);
|
||||
const onPause = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (el?.duration) guard.flush((el.currentTime / el.duration) * 100);
|
||||
}, [guard]);
|
||||
const onSeeking = useCallback(() => guard.markSeeking(), [guard]);
|
||||
const onSeeked = useCallback(() => guard.markSeeked(), [guard]);
|
||||
const onProgress = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (el?.buffered.length && el.duration) {
|
||||
@@ -158,13 +180,13 @@ export function AudioBlock({ content, onWatchProgress }) {
|
||||
const el = audioRef.current;
|
||||
const bar = e.currentTarget;
|
||||
const pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth;
|
||||
el.currentTime = pct * duration;
|
||||
el.currentTime = guard.clampSeekTarget(pct * duration);
|
||||
};
|
||||
|
||||
const skip = (secs) => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.currentTime = Math.min(Math.max(0, el.currentTime + secs), duration);
|
||||
el.currentTime = guard.clampSeekTarget(Math.min(Math.max(0, el.currentTime + secs), duration));
|
||||
};
|
||||
|
||||
const handleVolume = (e) => {
|
||||
@@ -215,6 +237,10 @@ export function AudioBlock({ content, onWatchProgress }) {
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMeta}
|
||||
onEnded={onEnded}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onSeeking={onSeeking}
|
||||
onSeeked={onSeeked}
|
||||
onProgress={onProgress}
|
||||
preload="auto"
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { VideoBlock } from "./VideoBlock";
|
||||
|
||||
export function TextVideoBlock({ content, onWatchProgress }) {
|
||||
export function TextVideoBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
|
||||
const vidLeft = content.video_position === "left";
|
||||
return (
|
||||
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
||||
{vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} />}
|
||||
{vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />}
|
||||
<div
|
||||
className="typeset text-sm w-full"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
|
||||
}}
|
||||
/>
|
||||
{!vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} />}
|
||||
{!vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,11 +24,6 @@ const PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"];
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
||||
|
||||
// How often onWatchProgress may fire while playing (ms) — keeps the watch-progress
|
||||
// endpoint from getting hit on every timeupdate tick. onEnded still always reports
|
||||
// a final 100% immediately regardless of this window, so completion never lags.
|
||||
const WATCH_PROGRESS_THROTTLE_MS = 10000;
|
||||
|
||||
// ─── Tooltip control button ───────────────────────────────────────────────────
|
||||
|
||||
function CtrlBtn({ label, onClick, children, className = "" }) {
|
||||
@@ -167,10 +163,11 @@ function SettingsPanel({ speed, onSpeed, onClose }) {
|
||||
// onWatchProgress(percent) — optional, called (throttled) as playback advances and
|
||||
// immediately at 100% on end. Backing the watch_percent completion requirement type;
|
||||
// harmless/unused when the lesson isn't configured for it (caller just won't pass it).
|
||||
export function VideoBlock({ content, onWatchProgress }) {
|
||||
export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
|
||||
const wrapRef = useRef(null);
|
||||
const vidRef = useRef(null);
|
||||
const lastReportRef = useRef(0);
|
||||
const guard = useMediaWatchGuard({ onWatchProgress, antiSkipEnabled });
|
||||
const resumedRef = useRef(false);
|
||||
|
||||
// ── Stream state ──────────────────────────────────────────────────────────
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
@@ -229,6 +226,8 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
setOverlayVisible(true);
|
||||
setSettingsOpen(false);
|
||||
setEnded(false);
|
||||
guard.reset();
|
||||
resumedRef.current = false;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -275,23 +274,41 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setCurrentTime(v.currentTime);
|
||||
guard.trackTimeUpdate(v.currentTime, v.duration);
|
||||
if (v.duration) {
|
||||
const pct = (v.currentTime / v.duration) * 100;
|
||||
setProgress(pct);
|
||||
if (onWatchProgress) {
|
||||
const now = Date.now();
|
||||
if (now - lastReportRef.current > WATCH_PROGRESS_THROTTLE_MS) {
|
||||
lastReportRef.current = now;
|
||||
onWatchProgress(pct);
|
||||
}
|
||||
}
|
||||
guard.maybeReport(pct);
|
||||
}
|
||||
};
|
||||
const onLoaded = () => {
|
||||
setTotalDuration(v.duration);
|
||||
guard.setDuration(v.duration);
|
||||
// Resume from the last position reached, once per asset. Left alone once
|
||||
// fully watched (>=99%) — restarting reads better than resuming at the end.
|
||||
if (!resumedRef.current && resumePercent > 0 && resumePercent < 99 && v.duration) {
|
||||
resumedRef.current = true;
|
||||
const resumeSeconds = Math.min((resumePercent / 100) * v.duration, v.duration - 0.25);
|
||||
v.currentTime = resumeSeconds;
|
||||
setCurrentTime(resumeSeconds);
|
||||
setProgress(resumePercent);
|
||||
// Seeds the seek-cap so forward-seeking within already-watched territory
|
||||
// works immediately, instead of clamping back to 0 until the next tick.
|
||||
guard.trackTimeUpdate(resumeSeconds, v.duration);
|
||||
}
|
||||
};
|
||||
const onLoaded = () => setTotalDuration(v.duration);
|
||||
const onEnded = () => {
|
||||
setPlaying(false); setOverlayVisible(false); setEnded(true);
|
||||
onWatchProgress?.(100);
|
||||
};
|
||||
const onPlay = () => {
|
||||
if (v.duration) guard.reportPlayStart((v.currentTime / v.duration) * 100);
|
||||
};
|
||||
const onPause = () => {
|
||||
if (v.duration) guard.flush((v.currentTime / v.duration) * 100);
|
||||
};
|
||||
const onSeeking = () => guard.markSeeking();
|
||||
const onSeeked = () => guard.markSeeked();
|
||||
const onWaiting = () => setBuffering(true);
|
||||
const onCanPlay = () => setBuffering(false);
|
||||
const onProgress = () => {
|
||||
@@ -305,9 +322,13 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
v.addEventListener("timeupdate", onTimeUpdate);
|
||||
v.addEventListener("loadedmetadata", onLoaded);
|
||||
v.addEventListener("ended", onEnded);
|
||||
v.addEventListener("play", onPlay);
|
||||
v.addEventListener("pause", onPause);
|
||||
v.addEventListener("seeking", onSeeking);
|
||||
v.addEventListener("seeked", onSeeked);
|
||||
v.addEventListener("progress", onProgress);
|
||||
|
||||
if (v.readyState >= 1 && v.duration) setTotalDuration(v.duration);
|
||||
if (v.readyState >= 1 && v.duration) onLoaded();
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("waiting", onWaiting);
|
||||
@@ -315,6 +336,10 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
v.removeEventListener("timeupdate", onTimeUpdate);
|
||||
v.removeEventListener("loadedmetadata", onLoaded);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("seeking", onSeeking);
|
||||
v.removeEventListener("seeked", onSeeked);
|
||||
v.removeEventListener("progress", onProgress);
|
||||
};
|
||||
}, [blobUrl]);
|
||||
@@ -372,7 +397,7 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
const handleSeek = (e) => {
|
||||
const v = vidRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = (parseFloat(e.target.value) / 100) * v.duration;
|
||||
v.currentTime = guard.clampSeekTarget((parseFloat(e.target.value) / 100) * v.duration);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e) => {
|
||||
@@ -407,7 +432,7 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (vidRef.current) vidRef.current.currentTime = Math.min(vidRef.current.currentTime + 5, vidRef.current.duration);
|
||||
if (vidRef.current) vidRef.current.currentTime = guard.clampSeekTarget(Math.min(vidRef.current.currentTime + 5, vidRef.current.duration));
|
||||
showFeedback(<SkipForward className="size-7 text-white" />, "+5s");
|
||||
resetHideTimer();
|
||||
break;
|
||||
@@ -445,7 +470,7 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback]);
|
||||
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback, guard]);
|
||||
|
||||
// ── States ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : TaskMultiSelect.jsx
|
||||
* Type : Reusable Component
|
||||
* Description : Searchable multi-select dropdown for sibling Tasks (used to pick
|
||||
* a task's prerequisite tasks). Cloned from GroupMultiSelect.jsx's
|
||||
* UI/UX — portal dropdown, badge + "+N" overflow, select all/clear —
|
||||
* but takes `tasks` directly from the caller instead of fetching its
|
||||
* own endpoint, since the parent page already has the sibling task
|
||||
* list loaded.
|
||||
*
|
||||
* Props:
|
||||
* value : string[] — selected task_ids
|
||||
* onChange : (ids: string[]) => void
|
||||
* tasks : { task_id, name }[] — candidate tasks (already excludes self)
|
||||
* disabled? : boolean
|
||||
* placeholder?: string
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronsUpDown, X, ListChecks } from 'lucide-react';
|
||||
|
||||
export default function TaskMultiSelect({
|
||||
value = [],
|
||||
onChange,
|
||||
tasks = [],
|
||||
disabled = false,
|
||||
placeholder = 'Select tasks…',
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [dropdownStyle, setDropdownStyle] = useState({});
|
||||
|
||||
const triggerRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// ── Position portal dropdown under trigger ────────────────────────────────
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !triggerRef.current) return;
|
||||
|
||||
const reposition = () => {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropdownStyle({
|
||||
position: 'fixed',
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
zIndex: 9999,
|
||||
});
|
||||
};
|
||||
|
||||
reposition();
|
||||
window.addEventListener('scroll', reposition, true);
|
||||
window.addEventListener('resize', reposition);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
window.removeEventListener('resize', reposition);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// ── Close on outside click ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (
|
||||
triggerRef.current?.contains(e.target) ||
|
||||
dropdownRef.current?.contains(e.target)
|
||||
) return;
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
const filtered = tasks.filter((t) =>
|
||||
t.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const selectedTasks = tasks.filter((t) => value.includes(t.task_id));
|
||||
const overflowCount = selectedTasks.length - 1;
|
||||
|
||||
// All currently visible (filtered) IDs — used for select-all scope
|
||||
const filteredIds = filtered.map((t) => t.task_id);
|
||||
const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id));
|
||||
|
||||
const toggle = (taskId) =>
|
||||
onChange(value.includes(taskId)
|
||||
? value.filter((id) => id !== taskId)
|
||||
: [...value, taskId]
|
||||
);
|
||||
|
||||
const remove = (e, taskId) => {
|
||||
e.stopPropagation();
|
||||
onChange(value.filter((id) => id !== taskId));
|
||||
};
|
||||
|
||||
// Select all visible (filtered) tasks
|
||||
const handleSelectAll = () => {
|
||||
const merged = Array.from(new Set([...value, ...filteredIds]));
|
||||
onChange(merged);
|
||||
};
|
||||
|
||||
// Clear all selections
|
||||
const handleClear = () => onChange([]);
|
||||
|
||||
// ── Portal dropdown ───────────────────────────────────────────────────────
|
||||
const dropdown = open && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={dropdownStyle}
|
||||
className="rounded-md border border-border bg-popover shadow-lg"
|
||||
>
|
||||
{/* Search row */}
|
||||
<div className="p-2 border-b border-border">
|
||||
<Input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search tasks…"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Select all / Clear row */}
|
||||
{tasks.length > 0 && (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/40">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={allFilteredSelected ? handleClear : handleSelectAll}
|
||||
className="text-xs text-primary hover:underline underline-offset-2 font-medium"
|
||||
>
|
||||
{allFilteredSelected ? 'Deselect all' : 'Select all'}
|
||||
{search && ` (${filteredIds.length})`}
|
||||
</button>
|
||||
{value.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleClear}
|
||||
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
Clear ({value.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<ul className="max-h-52 overflow-y-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
No tasks found.
|
||||
</li>
|
||||
) : (
|
||||
filtered.map((t) => {
|
||||
const selected = value.includes(t.task_id);
|
||||
return (
|
||||
<li
|
||||
key={t.task_id}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => toggle(t.task_id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 text-sm cursor-pointer select-none',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
selected && 'bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'h-4 w-4 rounded border flex items-center justify-center shrink-0',
|
||||
selected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'border-input'
|
||||
)}>
|
||||
{selected && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
<ListChecks className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{t.name}</span>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Trigger button ───────────────────────────────────────────── */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => { setOpen((o) => !o); setSearch(''); }}
|
||||
className={cn(
|
||||
'w-full min-h-9 px-3 py-1.5 rounded-md border border-input bg-background text-sm',
|
||||
'flex items-center gap-1.5 text-left',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
open && 'ring-2 ring-ring ring-offset-1'
|
||||
)}
|
||||
>
|
||||
{selectedTasks.length === 0 ? (
|
||||
<span className="text-muted-foreground flex-1">{placeholder}</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{/* Always show only the first selected task */}
|
||||
<Badge variant="secondary" className="gap-1 text-xs pr-1 shrink-0 max-w-[180px]">
|
||||
<ListChecks className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{selectedTasks[0].name}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => remove(e, selectedTasks[0].task_id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && remove(e, selectedTasks[0].task_id)}
|
||||
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5 cursor-pointer shrink-0"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
{/* +N overflow chip */}
|
||||
{overflowCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 shrink-0">
|
||||
+{overflowCount}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground shrink-0 ml-auto" />
|
||||
</button>
|
||||
|
||||
{/* ── Portalled dropdown ────────────────────────────────────────── */}
|
||||
{dropdown}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -295,6 +295,32 @@ export function CoursesProvider({ children }) {
|
||||
[request],
|
||||
);
|
||||
|
||||
// Flat lists for the prerequisite picker's Course/Unit/Lesson selectors —
|
||||
// same endpoints AdminTaskContext uses for the Task requirement picker.
|
||||
const fetchCoursesFlat = useCallback(
|
||||
() => request(async () => {
|
||||
const { data } = await api.get(`${BASE}/flat`);
|
||||
return data?.data ?? [];
|
||||
}),
|
||||
[request],
|
||||
);
|
||||
|
||||
const fetchUnitsFlat = useCallback(
|
||||
() => request(async () => {
|
||||
const { data } = await api.get(`${BASE}/units-flat`);
|
||||
return data?.data ?? [];
|
||||
}),
|
||||
[request],
|
||||
);
|
||||
|
||||
const fetchLessonsFlat = useCallback(
|
||||
() => request(async () => {
|
||||
const { data } = await api.get(`${BASE}/lessons-flat`);
|
||||
return data?.data ?? [];
|
||||
}),
|
||||
[request],
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// UNITS
|
||||
// =========================================================================
|
||||
@@ -1273,6 +1299,9 @@ export function CoursesProvider({ children }) {
|
||||
// ── prerequisites ──────────────────────────────────────────────────────
|
||||
fetchPrerequisites,
|
||||
syncPrerequisites,
|
||||
fetchCoursesFlat,
|
||||
fetchUnitsFlat,
|
||||
fetchLessonsFlat,
|
||||
|
||||
// ── completion requirements ────────────────────────────────────────────
|
||||
fetchCourseRequirements,
|
||||
|
||||
@@ -276,6 +276,21 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// Replaces the full assigned-group set in one call — used when editing a task
|
||||
// list's groups so an add+remove edit only costs one sensitiveOpsLimiter hit.
|
||||
const syncGroups = useCallback(
|
||||
(taskListId, groupIds) =>
|
||||
request(async () => {
|
||||
const res = await api.put(`${BASE}/${taskListId}/groups`, {
|
||||
group_ids: groupIds,
|
||||
});
|
||||
const result = res.data?.data ?? {};
|
||||
toast('Assigned groups updated.');
|
||||
return result;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TASKS
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
@@ -298,6 +313,19 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// Flat, unpaginated sibling-task list — used by the prerequisite picker.
|
||||
// Deliberately does not touch tasks/pagination/attributes state (unlike
|
||||
// fetchTasks) so it can be called from a page that isn't the tasks table.
|
||||
const fetchTasksFlat = useCallback(
|
||||
(taskListId) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/${taskListId}/tasks`, { params: { limit: 1000 } });
|
||||
const { data } = res.data?.data ?? {};
|
||||
return data ?? [];
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
const fetchArchivedTasks = useCallback(
|
||||
(taskListId, { page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||
request(async () => {
|
||||
@@ -633,9 +661,10 @@ export function AdminTaskProvider({ children }) {
|
||||
fetchTaskListGroups,
|
||||
assignGroups,
|
||||
unassignGroups,
|
||||
syncGroups,
|
||||
|
||||
// ── Task actions ──────────────────────────────────────────────────
|
||||
fetchTasks, fetchTask, fetchArchivedTasks,
|
||||
fetchTasks, fetchTask, fetchTasksFlat, fetchArchivedTasks,
|
||||
createTask, updateTask,
|
||||
archiveTask, restoreTask,
|
||||
bulkArchiveTasks, bulkRestoreTasks,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useRef, useCallback, useMemo, useEffect } from "react";
|
||||
|
||||
// How many progress checkpoints we aim to spread across a clip's duration,
|
||||
// clamped between a floor (never chattier than this even for tiny clips) and
|
||||
// a ceiling (routine polling shouldn't exceed roughly once a minute).
|
||||
const MIN_INTERVAL_MS = 8000;
|
||||
const MAX_INTERVAL_MS = 60000;
|
||||
const CHECKPOINTS_TARGET = 4;
|
||||
|
||||
function computeThrottleMs(durationSeconds) {
|
||||
if (!durationSeconds) return MIN_INTERVAL_MS; // duration not known yet — stay conservative
|
||||
return Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, (durationSeconds * 1000) / CHECKPOINTS_TARGET));
|
||||
}
|
||||
|
||||
// Shared anti-skip guard for VideoBlock/AudioBlock's watch-progress tracking.
|
||||
//
|
||||
// Tracks the furthest point actually reached during normal forward playback
|
||||
// (never during a seek) so seek targets can be clamped to "rewind only, never
|
||||
// skip ahead" — a scrub/keyboard-seek/programmatic currentTime write past that
|
||||
// point snaps back instead of landing. This is a client-side UX deterrent only;
|
||||
// the server independently re-validates elapsed wall-clock time per sample
|
||||
// (see recordWatchProgress) since a direct API call bypasses this entirely.
|
||||
//
|
||||
// The seek-cap only applies when `antiSkipEnabled` is true — the caller passes
|
||||
// this based on whether the lesson actually has a watch_percent/watch_video/
|
||||
// listen_audio completion requirement configured. Without one there's nothing
|
||||
// being enforced, so seeking is left completely free (default: disabled).
|
||||
//
|
||||
// Routine progress reports are throttled at an interval scaled to the clip's
|
||||
// own duration (~4 checkpoints across it, capped at once a minute) rather than
|
||||
// a flat interval, so short clips still get meaningful checkpoints while long
|
||||
// ones don't spam the endpoint. Pausing or backgrounding the tab immediately
|
||||
// flushes the last-known percent regardless of the throttle, so genuine
|
||||
// partial progress is never silently lost just because a short clip ended
|
||||
// before its first scheduled checkpoint. This reporting (and the resume-seed
|
||||
// it feeds) stays unconditional regardless of antiSkipEnabled, since it also
|
||||
// backs the always-on resume-position feature.
|
||||
export function useMediaWatchGuard({ onWatchProgress, antiSkipEnabled = false } = {}) {
|
||||
const lastReportRef = useRef(0);
|
||||
const maxReachedRef = useRef(0);
|
||||
const lastPctRef = useRef(0);
|
||||
const durationRef = useRef(0);
|
||||
const seekingRef = useRef(false);
|
||||
const playSeededRef = useRef(false);
|
||||
|
||||
// Call on asset change (new src) to drop all guard state for the new media.
|
||||
const reset = useCallback(() => {
|
||||
lastReportRef.current = 0;
|
||||
maxReachedRef.current = 0;
|
||||
lastPctRef.current = 0;
|
||||
durationRef.current = 0;
|
||||
seekingRef.current = false;
|
||||
playSeededRef.current = false;
|
||||
}, []);
|
||||
|
||||
// A tiny epsilon tolerates scrubber rounding / re-clicking the current spot.
|
||||
const EPSILON = 0.75;
|
||||
const clampSeekTarget = useCallback((target) => (
|
||||
!antiSkipEnabled || target <= maxReachedRef.current + EPSILON ? target : maxReachedRef.current
|
||||
), [antiSkipEnabled]);
|
||||
|
||||
const markSeeking = useCallback(() => { seekingRef.current = true; }, []);
|
||||
const markSeeked = useCallback(() => { seekingRef.current = false; }, []);
|
||||
|
||||
// Call once metadata resolves — drives the adaptive throttle interval.
|
||||
const setDuration = useCallback((durationSeconds) => {
|
||||
durationRef.current = durationSeconds || 0;
|
||||
}, []);
|
||||
|
||||
// Wire into the timeupdate handler — only advances the furthest-reached
|
||||
// marker while actually playing forward, not mid-seek. Always records the
|
||||
// latest percent (regardless of seek state) so pause/hidden flushes have
|
||||
// an honest "last known position" to report even mid-seek.
|
||||
const trackTimeUpdate = useCallback((currentTime, duration) => {
|
||||
if (!seekingRef.current) maxReachedRef.current = Math.max(maxReachedRef.current, currentTime);
|
||||
if (duration) lastPctRef.current = (currentTime / duration) * 100;
|
||||
}, []);
|
||||
|
||||
// Unconditionally reports and resets the throttle window — used for
|
||||
// pause/tab-hidden flushes and the initial play-start baseline, where
|
||||
// waiting for the routine throttle would risk losing the sample entirely.
|
||||
const flush = useCallback((pct) => {
|
||||
if (!onWatchProgress) return;
|
||||
lastReportRef.current = Date.now();
|
||||
onWatchProgress(pct);
|
||||
}, [onWatchProgress]);
|
||||
|
||||
const maybeReport = useCallback((pct) => {
|
||||
if (!onWatchProgress) return;
|
||||
const now = Date.now();
|
||||
if (now - lastReportRef.current > computeThrottleMs(durationRef.current)) {
|
||||
lastReportRef.current = now;
|
||||
onWatchProgress(pct);
|
||||
}
|
||||
}, [onWatchProgress]);
|
||||
|
||||
// Seeds the server's wall-clock baseline the moment playback first starts,
|
||||
// so the first real throttled sample has an honest elapsed-time reference
|
||||
// rather than being compared against a progress row created lazily on
|
||||
// whatever percent happens to be reported first.
|
||||
const reportPlayStart = useCallback((pct) => {
|
||||
if (playSeededRef.current) return;
|
||||
playSeededRef.current = true;
|
||||
flush(pct);
|
||||
}, [flush]);
|
||||
|
||||
// Tab backgrounded (switched away, minimized, etc.) — flush the last known
|
||||
// position immediately rather than risk it never being reported at all,
|
||||
// since 'visibilitychange' → hidden reliably fires before actual unload
|
||||
// (unlike beforeunload/unload, which browsers may cancel requests around).
|
||||
useEffect(() => {
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "hidden") flush(lastPctRef.current);
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
}, [flush]);
|
||||
|
||||
// Memoized so the returned object itself is referentially stable (all of the
|
||||
// functions inside are already useCallback-stable) — lets callers safely list
|
||||
// `guard` in effect/useCallback dependency arrays without re-running on every render.
|
||||
return useMemo(() => (
|
||||
{ clampSeekTarget, trackTimeUpdate, markSeeking, markSeeked, maybeReport, flush, reportPlayStart, setDuration, reset }
|
||||
), [clampSeekTarget, trackTimeUpdate, markSeeking, markSeeked, maybeReport, flush, reportPlayStart, setDuration, reset]);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||
import RoadmapBuilder from "@/modules/admin/components/courses/RoadmapBuilder";
|
||||
import AchievementsBuilder from "@/modules/admin/components/courses/AchievementsBuilder";
|
||||
import CoursePrerequisiteBuilder from "@/modules/admin/components/courses/CoursePrerequisiteBuilder";
|
||||
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
@@ -40,6 +41,7 @@ const schema = z.object({
|
||||
status: z.enum(["draft", "published", "unpublished"]).default("draft"),
|
||||
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
|
||||
.min(1, "At least one learning objective is required."),
|
||||
roles: z.array(z.object({ text: z.string().min(1, "Role cannot be empty.") })).default([]),
|
||||
achievement_keys: z.array(z.string()).max(1).default([]),
|
||||
});
|
||||
|
||||
@@ -47,7 +49,7 @@ const schema = z.object({
|
||||
|
||||
const STEPS = [
|
||||
{ label: "Basic Info", description: "Title, level & objectives" },
|
||||
{ label: "Roadmap", description: "Units & lessons" },
|
||||
{ label: "Roadmap", description: "Units, lessons & prerequisites" },
|
||||
{ label: "Rewards", description: "Badge & achievements" },
|
||||
{ label: "Review", description: "Confirm & create" },
|
||||
];
|
||||
@@ -127,11 +129,12 @@ function StepIndicator({ steps, current, onStepClick }) {
|
||||
|
||||
export default function AddCourse() {
|
||||
const navigate = useNavigate();
|
||||
const { createCourseFull, loading } = useCourses();
|
||||
const { createCourseFull, loading, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [roadmapUnits, setRoadmapUnits] = useState([]);
|
||||
const [prerequisites, setPrerequisites] = useState([]);
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
@@ -139,6 +142,18 @@ export default function AddCourse() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const [flatCourses, setFlatCourses] = useState([]);
|
||||
const [flatUnits, setFlatUnits] = useState([]);
|
||||
const [flatLessons, setFlatLessons] = useState([]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [c, u, l] = await Promise.all([fetchCoursesFlat(), fetchUnitsFlat(), fetchLessonsFlat()]);
|
||||
setFlatCourses(c ?? []);
|
||||
setFlatUnits(u ?? []);
|
||||
setFlatLessons(l ?? []);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const [badgeColor, setBadgeColor] = useState("purple");
|
||||
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
||||
const [badgeAssetId, setBadgeAssetId] = useState(null);
|
||||
@@ -163,12 +178,15 @@ export default function AddCourse() {
|
||||
subscription: "free",
|
||||
status: "draft",
|
||||
objectives: [],
|
||||
roles: [],
|
||||
achievement_keys: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
|
||||
useFieldArray({ control, name: "objectives" });
|
||||
const { fields: roleFields, append: appendRole, remove: removeRole } =
|
||||
useFieldArray({ control, name: "roles" });
|
||||
|
||||
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
|
||||
const watchedTitle = useWatch({ control, name: "title" });
|
||||
@@ -179,6 +197,7 @@ export default function AddCourse() {
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
const watchedStatus = useWatch({ control, name: "status" });
|
||||
const watchedObjectives = useWatch({ control, name: "objectives" });
|
||||
const watchedRoles = useWatch({ control, name: "roles" });
|
||||
|
||||
const [achievementRegistry, setAchievementRegistry] = useState([]);
|
||||
useEffect(() => {
|
||||
@@ -198,6 +217,7 @@ export default function AddCourse() {
|
||||
const hasUnsavedChanges =
|
||||
isDirty ||
|
||||
roadmapUnits.length > 0 ||
|
||||
prerequisites.length > 0 ||
|
||||
currentAchKeys.length > 0 ||
|
||||
!!badgeImageUrl ||
|
||||
badgeColor !== "purple";
|
||||
@@ -237,6 +257,12 @@ export default function AddCourse() {
|
||||
subscription: values.subscription,
|
||||
status: values.status,
|
||||
objectives: values.objectives.map((o) => o.text),
|
||||
roles: values.roles.map((r) => r.text),
|
||||
// Drop rows where a type was picked but no item was actually selected —
|
||||
// sending an empty ref_id fails at the DB level.
|
||||
prerequisites: prerequisites
|
||||
.filter((p) => p.ref_id !== "" && p.ref_id != null)
|
||||
.map(({ ref_type, ref_id }) => ({ ref_type, ref_id })),
|
||||
achievement_keys: currentAchKeys,
|
||||
badge_color: badgeColor,
|
||||
badge_asset_id: badgeAssetId ?? null,
|
||||
@@ -426,12 +452,66 @@ export default function AddCourse() {
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Course Roles"
|
||||
description="Who is this course intended for? e.g. Sales Agent, Property Manager."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{roleFields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`Role ${index + 1}`}
|
||||
{...register(`roles.${index}.text`)}
|
||||
/>
|
||||
<FieldError message={errors.roles?.[index]?.text?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeRole(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-1"
|
||||
onClick={() => appendRole({ text: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Role
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Step 1: Roadmap ── */}
|
||||
{currentStep === 1 && (
|
||||
<RoadmapBuilder units={roadmapUnits} onUnitsChange={setRoadmapUnits} />
|
||||
<>
|
||||
<RoadmapBuilder units={roadmapUnits} onUnitsChange={setRoadmapUnits} />
|
||||
|
||||
<SectionCard
|
||||
title="Prerequisites"
|
||||
description="What a learner must complete before starting this course."
|
||||
>
|
||||
<CoursePrerequisiteBuilder
|
||||
value={prerequisites}
|
||||
onChange={setPrerequisites}
|
||||
courses={flatCourses}
|
||||
units={flatUnits}
|
||||
lessons={flatLessons}
|
||||
/>
|
||||
</SectionCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Rewards ── */}
|
||||
@@ -587,11 +667,26 @@ export default function AddCourse() {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Course Roles
|
||||
</p>
|
||||
{!watchedRoles?.length ? (
|
||||
<p className="text-sm text-muted-foreground">None added.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{watchedRoles.map((r, i) => (
|
||||
<Badge key={i} variant="secondary">{r.text}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Roadmap"
|
||||
description={`${roadmapUnits.length} unit${roadmapUnits.length === 1 ? "" : "s"} · ${totalLessons} lesson${totalLessons === 1 ? "" : "s"} added.`}
|
||||
description={`${roadmapUnits.length} unit${roadmapUnits.length === 1 ? "" : "s"} · ${totalLessons} lesson${totalLessons === 1 ? "" : "s"} added · ${prerequisites.length} prerequisite${prerequisites.length === 1 ? "" : "s"}.`}
|
||||
>
|
||||
{roadmapUnits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No units added.</p>
|
||||
@@ -614,6 +709,21 @@ export default function AddCourse() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prerequisites.length > 0 && (
|
||||
<div className="border-t pt-3 mt-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Prerequisites
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{prerequisites.map((p, i) => (
|
||||
<Badge key={i} variant="outline" className="capitalize gap-1">
|
||||
{p.ref_type}: {p.title || `#${p.ref_id}`}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Rewards">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
|
||||
import CoursePrerequisiteBuilder from "@/modules/admin/components/courses/CoursePrerequisiteBuilder";
|
||||
import { useCategories } from "@/contexts/AdminCategoriesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import api from "@/utils/api.util";
|
||||
@@ -51,6 +52,9 @@ const schema = z.object({
|
||||
objectives: z
|
||||
.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
|
||||
.default([]),
|
||||
roles: z
|
||||
.array(z.object({ text: z.string().min(1, "Role cannot be empty.") }))
|
||||
.default([]),
|
||||
});
|
||||
|
||||
// ─── Steps config ─────────────────────────────────────────────────────────────
|
||||
@@ -59,7 +63,7 @@ const STEPS = [
|
||||
{ label: "Basic Info", description: "Title, level & objectives" },
|
||||
{ label: "Categories", description: "Tags & instructors" },
|
||||
{ label: "Rewards", description: "Badge & achievements" },
|
||||
{ label: "Requirements", description: "What counts as complete" },
|
||||
{ label: "Requirements", description: "Prerequisites & completion" },
|
||||
{ label: "Pricing", description: "Product listing" },
|
||||
];
|
||||
|
||||
@@ -146,6 +150,8 @@ export default function EditCourse() {
|
||||
fetchInstructors, syncInstructors,
|
||||
fetchCourseAchievements, syncCourseAchievements,
|
||||
fetchCourseRequirements, syncCourseRequirements,
|
||||
fetchPrerequisites, syncPrerequisites,
|
||||
fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat,
|
||||
loading, course,
|
||||
} = useCourses();
|
||||
const { categories: allCategories, fetchCategories } = useCategories();
|
||||
@@ -172,6 +178,22 @@ export default function EditCourse() {
|
||||
const [instructorsDirty, setInstructorsDirty] = useState(false);
|
||||
const [instructorsLoading, setInstructorsLoading] = useState(false);
|
||||
|
||||
// ─── Prerequisites state ──────────────────────────────────────────────────
|
||||
const [prerequisites, setPrerequisites] = useState([]);
|
||||
const [prereqDirty, setPrereqDirty] = useState(false);
|
||||
const [prereqLoading, setPrereqLoading] = useState(false);
|
||||
const [flatCourses, setFlatCourses] = useState([]);
|
||||
const [flatUnits, setFlatUnits] = useState([]);
|
||||
const [flatLessons, setFlatLessons] = useState([]);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [c, u, l] = await Promise.all([fetchCoursesFlat(), fetchUnitsFlat(), fetchLessonsFlat()]);
|
||||
setFlatCourses(c ?? []);
|
||||
setFlatUnits(u ?? []);
|
||||
setFlatLessons(l ?? []);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// ─── Achievements state ───────────────────────────────────────────────────
|
||||
const [selectedAchievementKeys, setSelectedAchievementKeys] = useState([]);
|
||||
const [achievementsDirty, setAchievementsDirty] = useState(false);
|
||||
@@ -207,12 +229,14 @@ export default function EditCourse() {
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
title: "", description: "", course_code: "",
|
||||
order_index: 0, level: undefined, subscription: "free", status: "draft", objectives: [],
|
||||
order_index: 0, level: undefined, subscription: "free", status: "draft", objectives: [], roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
|
||||
useFieldArray({ control, name: "objectives" });
|
||||
const { fields: roleFields, append: appendRole, remove: removeRole } =
|
||||
useFieldArray({ control, name: "roles" });
|
||||
|
||||
// Each section (Categories, Instructors, Badge, Achievements, Product) tracks
|
||||
// its own dirty flag already (see handleDone below) — fold them in here too
|
||||
@@ -224,7 +248,8 @@ export default function EditCourse() {
|
||||
instructorsDirty ||
|
||||
badgeDirty ||
|
||||
achievementsDirty ||
|
||||
productDirty;
|
||||
productDirty ||
|
||||
prereqDirty;
|
||||
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(hasUnsavedChanges);
|
||||
|
||||
@@ -252,6 +277,10 @@ export default function EditCourse() {
|
||||
objective_id: o.objective_id ?? null,
|
||||
text: o.text ?? "",
|
||||
})),
|
||||
roles: (c.roles ?? []).map((r) => ({
|
||||
role_id: r.role_id ?? null,
|
||||
text: r.text ?? "",
|
||||
})),
|
||||
});
|
||||
setBadgeColor(c.badge_color ?? "purple");
|
||||
setBadgeAssetId(c.badge_asset_id ?? null);
|
||||
@@ -272,13 +301,15 @@ export default function EditCourse() {
|
||||
|
||||
(async () => {
|
||||
await fetchCategories();
|
||||
const [cats, prod, insts, achKeys] = await Promise.all([
|
||||
const [cats, prod, insts, achKeys, prereqRes] = await Promise.all([
|
||||
fetchCourseCategories(courseId),
|
||||
fetchCourseProduct(courseId),
|
||||
fetchInstructors(courseId),
|
||||
fetchCourseAchievements(courseId),
|
||||
fetchPrerequisites(courseId),
|
||||
]);
|
||||
setSelectedCategoryIds((cats ?? []).map((c) => String(c.id)));
|
||||
setPrerequisites(prereqRes?.data?.data ?? []);
|
||||
if (prod) {
|
||||
setProduct(prod);
|
||||
setProductForm({
|
||||
@@ -414,6 +445,24 @@ export default function EditCourse() {
|
||||
setAchievementsLoading(false);
|
||||
};
|
||||
|
||||
// ─── Prerequisite handlers ────────────────────────────────────────────────
|
||||
const handlePrerequisitesChange = useCallback((next) => {
|
||||
setPrerequisites(next);
|
||||
setPrereqDirty(true);
|
||||
}, []);
|
||||
|
||||
const handleSavePrerequisites = async () => {
|
||||
setPrereqLoading(true);
|
||||
// Drop rows where the admin picked a type but never actually selected an
|
||||
// item from the picker — sending an empty ref_id fails at the DB level.
|
||||
const complete = prerequisites
|
||||
.filter((p) => p.ref_id !== "" && p.ref_id != null)
|
||||
.map(({ ref_type, ref_id }) => ({ ref_type, ref_id }));
|
||||
await syncPrerequisites(courseId, complete);
|
||||
setPrereqDirty(false);
|
||||
setPrereqLoading(false);
|
||||
};
|
||||
|
||||
// ─── Form submit (step 0 → step 1) ───────────────────────────────────────
|
||||
const saveBasicInfo = async (values) => {
|
||||
const payload = {
|
||||
@@ -423,6 +472,11 @@ export default function EditCourse() {
|
||||
text: o.text,
|
||||
order_index: i,
|
||||
})) ?? [],
|
||||
roles: values.roles?.map((r, i) => ({
|
||||
role_id: r.role_id ?? null,
|
||||
text: r.text,
|
||||
order_index: i,
|
||||
})) ?? [],
|
||||
level: values.level || null,
|
||||
course_code: values.course_code || null,
|
||||
updatedBy: user?.user_id ?? null,
|
||||
@@ -462,6 +516,7 @@ export default function EditCourse() {
|
||||
if (badgeDirty) await handleSaveBadge();
|
||||
if (achievementsDirty) await handleSaveAchievements();
|
||||
if (productDirty && productForm.price) await handleSaveProduct();
|
||||
if (prereqDirty) await handleSavePrerequisites();
|
||||
|
||||
bypassOnce();
|
||||
navigate(`/admin/courses/${courseId}/view`);
|
||||
@@ -654,6 +709,45 @@ export default function EditCourse() {
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Course Roles"
|
||||
description="Who is this course intended for? e.g. Sales Agent, Property Manager."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{roleFields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`Role ${index + 1}`}
|
||||
{...register(`roles.${index}.text`)}
|
||||
/>
|
||||
<FieldError message={errors.roles?.[index]?.text?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeRole(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-1"
|
||||
onClick={() => appendRole({ text: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Role
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1015,19 +1109,48 @@ export default function EditCourse() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Completion Requirements ── */}
|
||||
{/* ── Step 3: Prerequisites & Completion Requirements ── */}
|
||||
{currentStep === 3 && (
|
||||
<SectionCard
|
||||
title="Completion Requirements"
|
||||
description="What a learner must do for this course to count as complete."
|
||||
>
|
||||
<CompletionRequirementBuilder
|
||||
entityType="course"
|
||||
fetchFn={fetchCourseRequirements}
|
||||
syncFn={syncCourseRequirements}
|
||||
args={[courseId]}
|
||||
/>
|
||||
</SectionCard>
|
||||
<>
|
||||
<SectionCard
|
||||
title="Prerequisites"
|
||||
description="What a learner must complete before starting this course."
|
||||
>
|
||||
<CoursePrerequisiteBuilder
|
||||
value={prerequisites}
|
||||
onChange={handlePrerequisitesChange}
|
||||
courses={flatCourses}
|
||||
units={flatUnits}
|
||||
lessons={flatLessons}
|
||||
excludeCourseId={courseId}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!prereqDirty || prereqLoading}
|
||||
onClick={handleSavePrerequisites}
|
||||
>
|
||||
{prereqLoading && <Spinner className="h-3 w-3 mr-1.5" />}
|
||||
<Save className="h-3 w-3 mr-1.5" />
|
||||
Save Prerequisites
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title="Completion Requirements"
|
||||
description="What a learner must do for this course to count as complete."
|
||||
>
|
||||
<CompletionRequirementBuilder
|
||||
entityType="course"
|
||||
fetchFn={fetchCourseRequirements}
|
||||
syncFn={syncCourseRequirements}
|
||||
args={[courseId]}
|
||||
/>
|
||||
</SectionCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Pricing ── */}
|
||||
|
||||
@@ -244,13 +244,23 @@ function CourseDetailsTab({ course, loading, instructors, achievementKeys, achie
|
||||
{course.prerequisites.map((p, i) => (
|
||||
<li key={p.prereq_id ?? i} className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline" className="capitalize text-xs">{p.ref_type}</Badge>
|
||||
<span className="text-muted-foreground">ID: {p.ref_id}</span>
|
||||
<span>{p.title ?? `Untitled (ID: ${p.ref_id})`}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{course.roles?.length > 0 && (
|
||||
<SectionCard icon={Users} title="Course Roles">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{course.roles.map((r, i) => (
|
||||
<Badge key={r.role_id ?? i} variant="secondary">{r.text}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{course.assessment && (
|
||||
<SectionCard icon={Lock} title="Final Assessment">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ArrowLeft } from 'lucide-react';
|
||||
export default function EditTaskList() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId } = useParams();
|
||||
const { fetchTaskList, updateTaskList, assignGroups, unassignGroups, loading } = useAdminTask();
|
||||
const { fetchTaskList, updateTaskList, syncGroups, loading } = useAdminTask();
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
const [errors, setErrors] = useState({});
|
||||
@@ -77,14 +77,14 @@ export default function EditTaskList() {
|
||||
|
||||
if (!updated) return;
|
||||
|
||||
// ── 2. Diff groups ────────────────────────────────────────────────────
|
||||
const toAssign = selectedGroupIds.filter((id) => !originalGroupIds.includes(id));
|
||||
const toUnassign = originalGroupIds.filter((id) => !selectedGroupIds.includes(id));
|
||||
// ── 2. Sync groups (single call — avoids burning two sensitive-op hits) ──
|
||||
const groupsChanged =
|
||||
selectedGroupIds.length !== originalGroupIds.length ||
|
||||
selectedGroupIds.some((id) => !originalGroupIds.includes(id));
|
||||
|
||||
await Promise.all([
|
||||
toAssign.length ? assignGroups(taskListId, toAssign) : Promise.resolve(),
|
||||
toUnassign.length ? unassignGroups(taskListId, toUnassign) : Promise.resolve(),
|
||||
]);
|
||||
if (groupsChanged) {
|
||||
await syncGroups(taskListId, selectedGroupIds);
|
||||
}
|
||||
|
||||
navigate(`/admin/taskList`);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import {
|
||||
ArrowLeft, Pencil, Users, ClipboardList, FileText,
|
||||
Link2, Upload, BookOpen, BookMarked, FileCheck2,
|
||||
CalendarClock, Equal, Tag, Globe, Copy, File, Bookmark,
|
||||
CalendarClock, Equal, Tag, Globe, Copy, File, Bookmark, GitBranch,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ─── All styling uses shadcn tokens — only label/icon differs per type
|
||||
@@ -281,6 +281,18 @@ export default function ViewTaskList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.prerequisites?.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<GitBranch className="size-4 shrink-0" />
|
||||
<span className="text-sm">
|
||||
Requires:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{task.prerequisites.map((p) => p.name).join(', ')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TaskRequirementsSection requirements={task.requirements} />
|
||||
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
||||
import RequirementBuilder from './RequirementBuilder';
|
||||
import { taskSchema, REQUIREMENT_TYPE_META, requirementSummaryText } from './task.schema';
|
||||
|
||||
import TaskMultiSelect from '@/components/generic/TaskMultiSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -39,7 +40,7 @@ function SummaryRow({ label, value }) {
|
||||
export default function CreateTask() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId } = useParams();
|
||||
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, loading } = useAdminTask();
|
||||
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, fetchTasksFlat, loading } = useAdminTask();
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [form, setForm] = useState({
|
||||
@@ -48,18 +49,21 @@ export default function CreateTask() {
|
||||
deadline: '',
|
||||
is_required: true,
|
||||
requirements: [],
|
||||
prerequisite_task_ids: [],
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [lessons, setLessons] = useState([]);
|
||||
const [quizzes, setQuizzes] = useState([]);
|
||||
const [siblingTasks, setSiblingTasks] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoursesFlat().then((d) => d && setCourses(d));
|
||||
fetchUnitsFlat().then((d) => d && setUnits(d));
|
||||
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||
fetchQuizzesFlat().then((d) => d && setQuizzes(d));
|
||||
fetchTasksFlat(taskListId).then((d) => d && setSiblingTasks(d));
|
||||
}, []);
|
||||
|
||||
const validateStep = (s) => {
|
||||
@@ -113,6 +117,7 @@ export default function CreateTask() {
|
||||
delete req.duration_seconds;
|
||||
return req;
|
||||
}),
|
||||
prerequisite_task_ids: form.prerequisite_task_ids,
|
||||
});
|
||||
|
||||
if (created) navigate(`/admin/taskList/${taskListId}/tasks`);
|
||||
@@ -221,6 +226,25 @@ export default function CreateTask() {
|
||||
onCheckedChange={(v) => setForm({ ...form, is_required: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
Prerequisite Tasks
|
||||
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</Label>
|
||||
<TaskMultiSelect
|
||||
value={form.prerequisite_task_ids}
|
||||
onChange={(ids) => setForm({ ...form, prerequisite_task_ids: ids })}
|
||||
tasks={siblingTasks}
|
||||
disabled={loading}
|
||||
placeholder="Select tasks that must be completed first…"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This task stays locked until every selected task is complete. Leave empty to use the default order-based lock instead.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -267,6 +291,15 @@ export default function CreateTask() {
|
||||
? <span className="inline-flex items-center gap-1"><Clock className="h-3.5 w-3.5" />{formattedDeadline}</span>
|
||||
: 'No deadline'}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Prerequisites"
|
||||
value={form.prerequisite_task_ids.length
|
||||
? siblingTasks
|
||||
.filter((t) => form.prerequisite_task_ids.includes(t.task_id))
|
||||
.map((t) => t.name)
|
||||
.join(', ')
|
||||
: null}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
import RequirementBuilder from './RequirementBuilder';
|
||||
import { taskSchema } from './task.schema';
|
||||
|
||||
import TaskMultiSelect from '@/components/generic/TaskMultiSelect';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -33,7 +34,7 @@ const STATUS_OPTIONS = [
|
||||
export default function EditTask() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId, taskId } = useParams();
|
||||
const { fetchTask, updateTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, loading } = useAdminTask();
|
||||
const { fetchTask, updateTask, fetchTasksFlat, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, loading } = useAdminTask();
|
||||
|
||||
const [form, setForm] = useState(null);
|
||||
const [errors, setErrors] = useState({});
|
||||
@@ -41,6 +42,7 @@ export default function EditTask() {
|
||||
const [units, setUnits] = useState([]);
|
||||
const [lessons, setLessons] = useState([]);
|
||||
const [quizzes, setQuizzes] = useState([]);
|
||||
const [siblingTasks, setSiblingTasks] = useState([]);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const initialRequirementsRef = useRef(null);
|
||||
|
||||
@@ -56,12 +58,14 @@ export default function EditTask() {
|
||||
status: data.status ?? 'pending',
|
||||
is_required: data.is_required ?? true,
|
||||
requirements: reqs,
|
||||
prerequisite_task_ids: (data.prerequisites ?? []).map((p) => p.task_id),
|
||||
});
|
||||
});
|
||||
fetchCoursesFlat().then((d) => d && setCourses(d));
|
||||
fetchUnitsFlat().then((d) => d && setUnits(d));
|
||||
fetchLessonsFlat().then((d) => d && setLessons(d));
|
||||
fetchQuizzesFlat().then((d) => d && setQuizzes(d));
|
||||
fetchTasksFlat(taskListId).then((d) => d && setSiblingTasks(d.filter((t) => t.task_id !== taskId)));
|
||||
}, [taskListId, taskId]);
|
||||
|
||||
const requirementsChanged = () =>
|
||||
@@ -100,6 +104,7 @@ export default function EditTask() {
|
||||
status: form.status,
|
||||
is_required: form.is_required,
|
||||
requirements,
|
||||
prerequisite_task_ids: form.prerequisite_task_ids ?? [],
|
||||
});
|
||||
if (updated) navigate(`/admin/taskList/${taskListId}/tasks`);
|
||||
};
|
||||
@@ -222,6 +227,25 @@ export default function EditTask() {
|
||||
onCheckedChange={(v) => setForm({ ...form, is_required: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>
|
||||
Prerequisite Tasks
|
||||
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</Label>
|
||||
<TaskMultiSelect
|
||||
value={form.prerequisite_task_ids ?? []}
|
||||
onChange={(ids) => setForm({ ...form, prerequisite_task_ids: ids })}
|
||||
tasks={siblingTasks}
|
||||
disabled={loading}
|
||||
placeholder="Select tasks that must be completed first…"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This task stays locked until every selected task is complete. Leave empty to use the default order-based lock instead.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -148,13 +148,24 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
|
||||
<div
|
||||
key={lesson.id}
|
||||
onClick={() => {
|
||||
if (isFetching || !info?.unit?.course?.course_id) return;
|
||||
navigate(`/course/${info.unit.course.course_id}/unit`, {
|
||||
state: {
|
||||
lessonId: info.lesson_id,
|
||||
unitId: info.unit.unit_id,
|
||||
...(taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {}),
|
||||
},
|
||||
if (isFetching || !info) return;
|
||||
// Course-attached lesson — jump straight to it within the
|
||||
// course's unit reader (existing behavior).
|
||||
if (info.unit?.course?.course_id) {
|
||||
navigate(`/course/${info.unit.course.course_id}/unit`, {
|
||||
state: {
|
||||
lessonId: info.lesson_id,
|
||||
unitId: info.unit.unit_id,
|
||||
...(taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {}),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Standalone lesson (junction revamp — no parent course, or no
|
||||
// parent unit at all) — the course-scoped route can't resolve,
|
||||
// so fall back to the standalone lesson page instead.
|
||||
navigate(`/lessons/${lesson.reference_id}`, {
|
||||
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
|
||||
});
|
||||
}}
|
||||
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-96 shrink-0 ${
|
||||
|
||||
@@ -194,9 +194,18 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
|
||||
<div>
|
||||
<h1
|
||||
onClick={(e) => {
|
||||
if (!info?.course?.course_id) return;
|
||||
if (!info) return;
|
||||
e.stopPropagation();
|
||||
navigate(`/course/${info.course.course_id}/unit`, {
|
||||
if (info.course?.course_id) {
|
||||
navigate(`/course/${info.course.course_id}/unit`, {
|
||||
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Standalone unit (junction revamp — no parent course) —
|
||||
// the course-scoped route can't resolve, fall back to
|
||||
// the standalone unit reader instead.
|
||||
navigate(`/units/${unit.reference_id}/read`, {
|
||||
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
|
||||
});
|
||||
}}
|
||||
@@ -248,8 +257,14 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
|
||||
<Button
|
||||
onClick={() => {
|
||||
const info = details[selected?.reference_id];
|
||||
if (!info?.course?.course_id) return;
|
||||
navigate(`/course/${info.course.course_id}/unit`, {
|
||||
if (!info) return;
|
||||
if (info.course?.course_id) {
|
||||
navigate(`/course/${info.course.course_id}/unit`, {
|
||||
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate(`/units/${selected.reference_id}/read`, {
|
||||
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgress
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { toast } from "sonner";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
||||
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
||||
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
||||
import { Tags } from "lucide-react";
|
||||
@@ -681,8 +682,8 @@ const CourseDetails = () => {
|
||||
)}
|
||||
</div>
|
||||
{progressSummary && progressSummary.lessons_total > 0 && (
|
||||
<div className="flex flex-col gap-1.5 max-w-md">
|
||||
<div className="flex items-center justify-between text-xs xs:text-white/80 lg:text-muted-foreground">
|
||||
<div className="flex flex-col gap-1.5 max-w-xl">
|
||||
<div className="flex items-center justify-between text-sm text-white mb-1">
|
||||
<span>{progressSummary.lessons_completed} of {progressSummary.lessons_total} lessons complete</span>
|
||||
<span>{progressSummary.percent}%</span>
|
||||
</div>
|
||||
@@ -725,7 +726,7 @@ const CourseDetails = () => {
|
||||
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
|
||||
<div className="flex flex-col xs:gap-12 lg:gap-12 flex-1 min-w-0">
|
||||
<div className="space-y-4">
|
||||
<div className="font-bold text-2xl">About this course</div>
|
||||
<div className="font-bold text-2xl">About</div>
|
||||
<div className="max-w-3xl space-y-4 text-muted-foreground lg:text-lg">
|
||||
<p>{course?.description ?? ""}</p>
|
||||
</div>
|
||||
@@ -736,7 +737,7 @@ const CourseDetails = () => {
|
||||
<div className="space-y-4">
|
||||
{course?.objectives?.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="font-bold text-2xl">What you will learn</div>
|
||||
<div className="font-bold text-2xl">Learning Outcomes</div>
|
||||
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
|
||||
{course.objectives.map((obj) => (
|
||||
<li key={obj.objective_id}>{obj.text}</li>
|
||||
@@ -746,6 +747,104 @@ const CourseDetails = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Roles + Prerequisites */}
|
||||
<div className="max-w-3xl space-y-6">
|
||||
{/* Roles */}
|
||||
<div className=" space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="font-bold text-xl">Roles</div>
|
||||
<p className="text-sm text-muted-foreground">Job roles this course qualifies you for</p>
|
||||
</div>
|
||||
{course?.roles?.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{course.roles.map((r) => (
|
||||
<Badge key={r.role_id} variant="secondary" className="text-sm">
|
||||
{r.text}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
No specific roles are required for this course.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prerequisites */}
|
||||
<div className="rounded-xl border bg-card p-6 space-y-4">
|
||||
<div>
|
||||
<div className="font-bold text-xl">Prerequisites</div>
|
||||
<p className="text-sm text-muted-foreground">Complete these courses first</p>
|
||||
</div>
|
||||
{course?.prerequisites?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{course.prerequisites.map((p) => {
|
||||
// Guarded = the prerequisite itself sits behind a paid tier —
|
||||
// paint the whole row with that tier's actual admin-configured
|
||||
// color (Tier Categories → Color) instead of a plain gray row.
|
||||
// Uses the swatch hex directly (inline style) rather than a
|
||||
// dynamic Tailwind class, since the color key is admin-defined
|
||||
// at runtime and arbitrary bg-{key}-500 classes aren't
|
||||
// guaranteed to survive Tailwind's build-time purge.
|
||||
const tierInfo = p.subscription ? tierMap[p.subscription] : null;
|
||||
const guarded = (tierInfo?.rank ?? 0) > 0 && !p.completed;
|
||||
const tierColor = guarded ? getTierColor(tierInfo.color) : null;
|
||||
const textColor = tierColor ? getContrastText(tierColor.swatch, tierInfo.color) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.prereq_id}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 py-3 px-3 rounded-lg",
|
||||
p.completed && "bg-emerald-50 dark:bg-emerald-950/20",
|
||||
!p.completed && !guarded && "bg-muted/40"
|
||||
)}
|
||||
style={guarded ? { backgroundColor: tierColor.swatch } : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{p.completed ? (
|
||||
<div className="w-6 h-6 rounded-full bg-emerald-500 flex items-center justify-center shrink-0">
|
||||
<Check className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="size-4 rounded-full border-2 shrink-0"
|
||||
style={{ borderColor: guarded ? textColor : undefined }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn("font-medium truncate", !p.completed && !guarded && "text-muted-foreground")}
|
||||
style={{ color: guarded ? textColor : undefined }}
|
||||
>
|
||||
{p.title ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"shrink-0 border-0 gap-1",
|
||||
p.completed
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
|
||||
: !guarded && "text-muted-foreground"
|
||||
)}
|
||||
style={guarded ? { backgroundColor: `${textColor}1a`, color: textColor } : undefined}
|
||||
>
|
||||
{p.completed
|
||||
? <><CheckCheck className="size-3.5" /> Completed</>
|
||||
: <><LockIcon className="size-3.5" /> Locked</>}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
No prerequisites — this is a great course to start with.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Units — while content isn't ready, only Rewards is shown */}
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
House, SendHorizonal, CheckCheck, Check, Hourglass, Clock, Video,
|
||||
House, SendHorizonal, CheckCheck, Check, Hourglass, Clock, Video, ListChecks,
|
||||
} from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
|
||||
import LessonBlock from "../components/LessonBlock.jsx";
|
||||
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -28,6 +29,20 @@ function formatDuration(seconds = 0) {
|
||||
const LessonDetails = () => {
|
||||
const { uuid } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Task context: state-first, endpoint fallback — same pattern as UnitList.jsx.
|
||||
// If navigated here from a task's requirement card, taskCtx is in location.state;
|
||||
// if the user opened this lesson directly (e.g. from /lessons), fetch it instead,
|
||||
// so the "Task mode" banner still shows up when this lesson is a task requirement.
|
||||
const [taskCtx, setTaskCtx] = useState(null);
|
||||
useEffect(() => {
|
||||
const stateCtx = location.state?.taskCtx;
|
||||
if (stateCtx) { setTaskCtx(stateCtx); return; }
|
||||
api.get(`/client/courses/lesson/uuid/${uuid}/task-context`)
|
||||
.then(({ data }) => { if (data?.data?.has_task) setTaskCtx(data.data); })
|
||||
.catch(() => {}); // non-critical — silently swallow
|
||||
}, [uuid]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const {
|
||||
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
|
||||
@@ -104,6 +119,20 @@ const LessonDetails = () => {
|
||||
<div className="flex flex-col gap-6 flex-1 min-w-0 xs:px-4 xs:py-8 lg:px-16 lg:py-10">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
{taskCtx?.has_task && (
|
||||
<div className={`flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 rounded-md shadow-sm w-fit transition-colors ${
|
||||
hasCompleted ? 'bg-green-600' : 'bg-blue-600'
|
||||
}`}>
|
||||
<ListChecks className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{hasCompleted
|
||||
? 'Lesson complete — tracking finished'
|
||||
: 'Task mode — progress is being tracked automatically'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 max-w-2xl">
|
||||
<h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
|
||||
<p className="text-muted-foreground">{lesson.description ?? ""}</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, Lock, CheckCircle2, Circle, Zap, ChevronsLeft, ChevronsRight, Hourglass } from "lucide-react";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, Lock, CheckCircle2, Circle, Zap, ChevronsLeft, ChevronsRight, Hourglass, ListChecks } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
@@ -12,6 +12,7 @@ import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── Sidebar (single unit — flat lessons + quiz, no unit-accordion nesting) ──
|
||||
|
||||
@@ -87,6 +88,19 @@ const UnitReader = () => {
|
||||
// Tracks which lessons have been marked completed this session to avoid duplicate calls
|
||||
const completedSessionRef = useRef(new Set());
|
||||
|
||||
// ── Task context: state-first, endpoint fallback — same pattern as UnitList.jsx.
|
||||
// If navigated here from a task's requirement card, taskCtx is in location.state;
|
||||
// if the user opened this unit directly (e.g. from /units), fetch it instead, so
|
||||
// the "Task mode" banner still shows up when this unit is a task requirement.
|
||||
const [taskCtx, setTaskCtx] = useState(null);
|
||||
useEffect(() => {
|
||||
const stateCtx = location.state?.taskCtx;
|
||||
if (stateCtx) { setTaskCtx(stateCtx); return; }
|
||||
api.get(`/client/courses/unit/uuid/${uuid}/task-context`)
|
||||
.then(({ data }) => { if (data?.data?.has_task) setTaskCtx(data.data); })
|
||||
.catch(() => {}); // non-critical — silently swallow
|
||||
}, [uuid]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Local UI state ──────────────────────────────────────────────────────
|
||||
const [selectedLessonId, setSelectedLessonId] = useState(null);
|
||||
const [selectedQuizId, setSelectedQuizId] = useState(null);
|
||||
@@ -440,8 +454,23 @@ const UnitReader = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Task-mode banner ─────────────────────────────────────────── */}
|
||||
{taskCtx?.has_task && (
|
||||
<div className={`fixed xs:top-[124px] lg:top-[112px] left-0 right-0 z-30 flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 shadow-sm transition-colors ${
|
||||
unitDetail?.is_completed ? 'bg-green-600' : 'bg-blue-600'
|
||||
}`}>
|
||||
<ListChecks className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{unitDetail?.is_completed
|
||||
? 'Unit complete — tracking finished'
|
||||
: 'Task mode — progress is being tracked automatically'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Desktop sidebar ── */}
|
||||
<div className="hidden lg:flex flex-row fixed top-[112px] bottom-0 left-0 z-30 bg-muted border-r">
|
||||
<div className={`hidden lg:flex flex-row fixed ${taskCtx?.has_task ? "top-[140px]" : "top-[112px]"} bottom-0 left-0 z-30 bg-muted border-r`}>
|
||||
<div className="w-14 shrink-0 flex flex-col items-center pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -471,7 +500,7 @@ const UnitReader = () => {
|
||||
</div>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className={`mt-32 ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className={`${taskCtx?.has_task ? "xs:mt-42 lg:mt-36" : "mt-32"} ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className="relative w-full h-full">
|
||||
{selectedQuizId ? (
|
||||
<QuizBlock
|
||||
|
||||
@@ -18,6 +18,9 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
House, Calendar, AlertTriangle, Check, ArrowRight, ListChecks, LayoutList,
|
||||
LaptopMinimal, Table, Lock,
|
||||
@@ -75,17 +78,17 @@ const TaskStatusBadge = ({ task }) => {
|
||||
};
|
||||
|
||||
// ─── Task card ────────────────────────────────────────────────────────────────
|
||||
const TaskCard = ({ task, onClick, locked }) => {
|
||||
const TaskCard = ({ task, onClick, locked, lockedBy, onLockedClick }) => {
|
||||
const reqCount = task.requirements?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => !locked && onClick()}
|
||||
onClick={() => (locked ? onLockedClick(lockedBy) : onClick())}
|
||||
className={cn(
|
||||
'border bg-card rounded-lg flex flex-col transition-colors',
|
||||
'border bg-card rounded-lg flex flex-col transition-colors cursor-pointer',
|
||||
locked
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-blue-400 dark:hover:border-blue-500',
|
||||
? 'opacity-60 hover:border-muted-foreground/30'
|
||||
: 'hover:border-blue-400 dark:hover:border-blue-500',
|
||||
task.has_completed && 'opacity-90',
|
||||
)}
|
||||
>
|
||||
@@ -101,17 +104,15 @@ const TaskCard = ({ task, onClick, locked }) => {
|
||||
: <TaskStatusBadge task={task} />
|
||||
}
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{task.description || 'No information details provided'}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-auto pt-1 [&_svg]:size-3.5">
|
||||
<Calendar />
|
||||
{task.deadline ? `Due ${formatDate(task.deadline)}` : 'No due date'}
|
||||
</div>
|
||||
{locked && (
|
||||
<p className="text-xs text-muted-foreground">Complete the earlier required tasks to unlock.</p>
|
||||
<p className="text-xs text-muted-foreground">Tap to see what's required to unlock this task.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-2.5 border-t flex items-center justify-between">
|
||||
@@ -167,9 +168,10 @@ const ViewTaskDetails = () => {
|
||||
const { taskList, loading, fetchTaskList } = useTask();
|
||||
const { group, fetchGroup } = useGroup();
|
||||
|
||||
const [view, setView] = useState('grid');
|
||||
const [activeTab, setActiveTab] = useState('tab-ongoing');
|
||||
const [allTasks, setAllTasks] = useState([]);
|
||||
const [view, setView] = useState('grid');
|
||||
const [activeTab, setActiveTab] = useState('tab-ongoing');
|
||||
const [allTasks, setAllTasks] = useState([]);
|
||||
const [lockedInfo, setLockedInfo] = useState(null); // task names blocking the last-clicked locked card
|
||||
|
||||
// ── Fetch group info once ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -193,12 +195,13 @@ const ViewTaskDetails = () => {
|
||||
.catch(() => {});
|
||||
}, [groupId, taskListId]);
|
||||
|
||||
// ── Sequencing lock — same pattern as UnitList.jsx's quiz lock ───────────
|
||||
// ── Sequencing/prerequisite lock — computed server-side (task.locked,
|
||||
// task.lockedBy), since an explicit prerequisite graph can't be expressed
|
||||
// as a one-line client-side scan the way the old order_index-only rule could.
|
||||
const lockedTaskIds = new Set(
|
||||
allTasks
|
||||
.filter((t, i, arr) => arr.slice(0, i).some((prev) => prev.is_required && !prev.has_completed))
|
||||
.map((t) => t.task_id)
|
||||
allTasks.filter((t) => t.locked).map((t) => t.task_id)
|
||||
);
|
||||
const lockedByById = new Map(allTasks.map((t) => [t.task_id, t.lockedBy ?? []]));
|
||||
|
||||
const completedCount = allTasks.filter((t) => t.has_completed).length;
|
||||
|
||||
@@ -250,6 +253,8 @@ const ViewTaskDetails = () => {
|
||||
key={task.task_id}
|
||||
task={task}
|
||||
locked={lockedTaskIds.has(task.task_id)}
|
||||
lockedBy={lockedByById.get(task.task_id) ?? []}
|
||||
onLockedClick={setLockedInfo}
|
||||
onClick={() => navigate(`task/${task.task_id}`)}
|
||||
/>
|
||||
))}
|
||||
@@ -351,6 +356,35 @@ const ViewTaskDetails = () => {
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Locked task requirement dialog ── */}
|
||||
<Dialog open={!!lockedInfo} onOpenChange={(open) => !open && setLockedInfo(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="size-4" /> Task locked
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{lockedInfo?.length
|
||||
? `Complete the following task${lockedInfo.length !== 1 ? 's' : ''} first to unlock this one:`
|
||||
: 'Complete the earlier required tasks first to unlock this one.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!!lockedInfo?.length && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{lockedInfo.map((t) => (
|
||||
<li
|
||||
key={t.task_id}
|
||||
className="flex items-center gap-2 text-sm bg-muted rounded-md px-3 py-2"
|
||||
>
|
||||
<ListChecks className="size-3.5 text-muted-foreground shrink-0" />
|
||||
{t.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user