course,tasklist,task and completed validation

Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
rgrgogu
2026-07-17 13:04:40 +08:00
parent 5b9f174718
commit 2e9ab5786c
22 changed files with 1345 additions and 117 deletions
@@ -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 ────────────────────────────────────────────────────────────────
+241
View File
@@ -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}
</>
);
}
+29
View File
@@ -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,
+30 -1
View File
@@ -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,
+125
View File
@@ -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>
+114 -4
View File
@@ -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">
+139 -16
View File
@@ -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 ── */}
+11 -1
View File
@@ -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 } } : {},
});
}}
+103 -4
View File
@@ -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">
+32 -3
View File
@@ -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>
+32 -3
View File
@@ -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
+52 -18
View File
@@ -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>
);
};