diff --git a/src/components/generic/Blocks/Client/AudioBlock.jsx b/src/components/generic/Blocks/Client/AudioBlock.jsx index e364e17..575a9a4 100644 --- a/src/components/generic/Blocks/Client/AudioBlock.jsx +++ b/src/components/generic/Blocks/Client/AudioBlock.jsx @@ -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" /> diff --git a/src/components/generic/Blocks/Client/TextVideoBlock.jsx b/src/components/generic/Blocks/Client/TextVideoBlock.jsx index d6aa9f7..0c2a98d 100644 --- a/src/components/generic/Blocks/Client/TextVideoBlock.jsx +++ b/src/components/generic/Blocks/Client/TextVideoBlock.jsx @@ -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 (
- {vidLeft && } + {vidLeft && }
Empty text

", }} /> - {!vidLeft && } + {!vidLeft && }
); -} \ No newline at end of file +} diff --git a/src/components/generic/Blocks/Client/VideoBlock.jsx b/src/components/generic/Blocks/Client/VideoBlock.jsx index 019e242..4f9bd3d 100644 --- a/src/components/generic/Blocks/Client/VideoBlock.jsx +++ b/src/components/generic/Blocks/Client/VideoBlock.jsx @@ -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(, "+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 ──────────────────────────────────────────────────────────────── diff --git a/src/components/generic/TaskMultiSelect.jsx b/src/components/generic/TaskMultiSelect.jsx new file mode 100644 index 0000000..f2bae6a --- /dev/null +++ b/src/components/generic/TaskMultiSelect.jsx @@ -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( +
+ {/* Search row */} +
+ setSearch(e.target.value)} + placeholder="Search tasks…" + className="h-8 text-sm" + /> +
+ + {/* Select all / Clear row */} + {tasks.length > 0 && ( +
+ + {value.length > 0 && ( + + )} +
+ )} + + {/* Options */} +
    + {filtered.length === 0 ? ( +
  • + No tasks found. +
  • + ) : ( + filtered.map((t) => { + const selected = value.includes(t.task_id); + return ( +
  • 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' + )} + > +
    + {selected && } +
    + + {t.name} +
  • + ); + }) + )} +
+
, + document.body + ); + + return ( + <> + {/* ── Trigger button ───────────────────────────────────────────── */} + + + {/* ── Portalled dropdown ────────────────────────────────────────── */} + {dropdown} + + ); +} diff --git a/src/contexts/AdminCoursesContext.jsx b/src/contexts/AdminCoursesContext.jsx index 177c822..dae6d22 100644 --- a/src/contexts/AdminCoursesContext.jsx +++ b/src/contexts/AdminCoursesContext.jsx @@ -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, diff --git a/src/contexts/AdminTaskContext.jsx b/src/contexts/AdminTaskContext.jsx index 7f2ea59..1753584 100644 --- a/src/contexts/AdminTaskContext.jsx +++ b/src/contexts/AdminTaskContext.jsx @@ -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, diff --git a/src/hooks/useMediaWatchGuard.js b/src/hooks/useMediaWatchGuard.js new file mode 100644 index 0000000..715ad29 --- /dev/null +++ b/src/hooks/useMediaWatchGuard.js @@ -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]); +} diff --git a/src/modules/admin/components/courses/CoursePrerequisiteBuilder.jsx b/src/modules/admin/components/courses/CoursePrerequisiteBuilder.jsx new file mode 100644 index 0000000..089ae06 --- /dev/null +++ b/src/modules/admin/components/courses/CoursePrerequisiteBuilder.jsx @@ -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 ( + + + Standalone + + ); + } + return ( + + + {courses.map((c) => c.title).join(', ')} + + ); +} + +// ─── 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 ( + <> + + + + + + {dialogTitle ?? placeholder} + + + + + No results found. + + {options.map((o) => ( + { onSelect(o); setOpen(false); }} + className="p-0" + > + {renderItem(o)} + + ))} + + + + + + + ); +} + +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 ( +
+ {items.length === 0 && ( +

+ No prerequisites added. Learners can start this course freely. +

+ )} + + {items.map((item, idx) => { + const typeDef = TYPE_MAP[item.ref_type]; + const Icon = typeDef?.icon ?? BookOpen; + const options = optionsFor[item.ref_type] ?? []; + + return ( + + +
+ + + + {idx + 1} + + + + +
+ updateItem(item._key, { ref_id: o[typeDef.idKey], title: o.title })} + renderTrigger={(o) => ( +
+ {item.ref_type !== 'course' && ( +
+ + {o.title} +
+ )} + {item.ref_type === 'course' && ( + {o.title} + )} +
+ )} + renderItem={(o) => ( +
+
+ {o.title} + {item.ref_type !== 'course' && } +
+
+ )} + /> +
+ + +
+
+
+ ); + })} + + +
+ ); +} diff --git a/src/modules/admin/components/courses/LessonsPreview.jsx b/src/modules/admin/components/courses/LessonsPreview.jsx index ca967c5..587e22b 100644 --- a/src/modules/admin/components/courses/LessonsPreview.jsx +++ b/src/modules/admin/components/courses/LessonsPreview.jsx @@ -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 ; case "video": - return ; + return ; case "text-video": - return ; + return ; case "audio": - return ; + return ; case "code": return ; 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 ( 300} @@ -194,7 +201,7 @@ export function PreviewContent({ lesson, blocks, empty = "No content yet.", show
{blocks.map((block) => (
- +
))}
diff --git a/src/modules/admin/pages/courses/AddCourse.jsx b/src/modules/admin/pages/courses/AddCourse.jsx index 69f58ff..2a7893e 100644 --- a/src/modules/admin/pages/courses/AddCourse.jsx +++ b/src/modules/admin/pages/courses/AddCourse.jsx @@ -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() {
+ + +
+ {roleFields.map((field, index) => ( +
+
+ + +
+ +
+ ))} + + +
+
)} {/* ── Step 1: Roadmap ── */} {currentStep === 1 && ( - + <> + + + + + + )} {/* ── Step 2: Rewards ── */} @@ -587,11 +667,26 @@ export default function AddCourse() { )} + +
+

+ Course Roles +

+ {!watchedRoles?.length ? ( +

None added.

+ ) : ( +
+ {watchedRoles.map((r, i) => ( + {r.text} + ))} +
+ )} +
{roadmapUnits.length === 0 ? (

No units added.

@@ -614,6 +709,21 @@ export default function AddCourse() { })} )} + + {prerequisites.length > 0 && ( +
+

+ Prerequisites +

+
+ {prerequisites.map((p, i) => ( + + {p.ref_type}: {p.title || `#${p.ref_id}`} + + ))} +
+
+ )}
diff --git a/src/modules/admin/pages/courses/EditCourse.jsx b/src/modules/admin/pages/courses/EditCourse.jsx index e23faae..0bfb37f 100644 --- a/src/modules/admin/pages/courses/EditCourse.jsx +++ b/src/modules/admin/pages/courses/EditCourse.jsx @@ -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() { + + +
+ {roleFields.map((field, index) => ( +
+
+ + +
+ +
+ ))} + + +
+
)} @@ -1015,19 +1109,48 @@ export default function EditCourse() { )} - {/* ── Step 3: Completion Requirements ── */} + {/* ── Step 3: Prerequisites & Completion Requirements ── */} {currentStep === 3 && ( - - - + <> + + + +
+ +
+
+ + + + + )} {/* ── Step 4: Pricing ── */} diff --git a/src/modules/admin/pages/courses/ViewCourse.jsx b/src/modules/admin/pages/courses/ViewCourse.jsx index 9d3150d..c6e2f1f 100644 --- a/src/modules/admin/pages/courses/ViewCourse.jsx +++ b/src/modules/admin/pages/courses/ViewCourse.jsx @@ -244,13 +244,23 @@ function CourseDetailsTab({ course, loading, instructors, achievementKeys, achie {course.prerequisites.map((p, i) => (
  • {p.ref_type} - ID: {p.ref_id} + {p.title ?? `Untitled (ID: ${p.ref_id})`}
  • ))} )} + {course.roles?.length > 0 && ( + +
    + {course.roles.map((r, i) => ( + {r.text} + ))} +
    +
    + )} + {course.assessment && (
    diff --git a/src/modules/admin/pages/task_list/EditTaskList.jsx b/src/modules/admin/pages/task_list/EditTaskList.jsx index 2360c1a..5942a89 100644 --- a/src/modules/admin/pages/task_list/EditTaskList.jsx +++ b/src/modules/admin/pages/task_list/EditTaskList.jsx @@ -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`); }; diff --git a/src/modules/admin/pages/task_list/ViewTaskList.jsx b/src/modules/admin/pages/task_list/ViewTaskList.jsx index 6a5dcc3..1759f99 100644 --- a/src/modules/admin/pages/task_list/ViewTaskList.jsx +++ b/src/modules/admin/pages/task_list/ViewTaskList.jsx @@ -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() {
    )} + {task.prerequisites?.length > 0 && ( +
    + + + Requires:{' '} + + {task.prerequisites.map((p) => p.name).join(', ')} + + +
    + )} + diff --git a/src/modules/admin/pages/task_list/task/CreateTask.jsx b/src/modules/admin/pages/task_list/task/CreateTask.jsx index 2b1ff83..ed9c336 100644 --- a/src/modules/admin/pages/task_list/task/CreateTask.jsx +++ b/src/modules/admin/pages/task_list/task/CreateTask.jsx @@ -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 })} /> + +
    + + setForm({ ...form, prerequisite_task_ids: ids })} + tasks={siblingTasks} + disabled={loading} + placeholder="Select tasks that must be completed first…" + /> +

    + This task stays locked until every selected task is complete. Leave empty to use the default order-based lock instead. +

    +
    )} @@ -267,6 +291,15 @@ export default function CreateTask() { ? {formattedDeadline} : 'No deadline'} /> + form.prerequisite_task_ids.includes(t.task_id)) + .map((t) => t.name) + .join(', ') + : null} + /> diff --git a/src/modules/admin/pages/task_list/task/EditTask.jsx b/src/modules/admin/pages/task_list/task/EditTask.jsx index 71d2caa..d450acf 100644 --- a/src/modules/admin/pages/task_list/task/EditTask.jsx +++ b/src/modules/admin/pages/task_list/task/EditTask.jsx @@ -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 })} /> + +
    + + setForm({ ...form, prerequisite_task_ids: ids })} + tasks={siblingTasks} + disabled={loading} + placeholder="Select tasks that must be completed first…" + /> +

    + This task stays locked until every selected task is complete. Leave empty to use the default order-based lock instead. +

    +
    diff --git a/src/modules/client/components/blocks/ReadLesson.jsx b/src/modules/client/components/blocks/ReadLesson.jsx index fe84f3b..ab98708 100644 --- a/src/modules/client/components/blocks/ReadLesson.jsx +++ b/src/modules/client/components/blocks/ReadLesson.jsx @@ -148,13 +148,24 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
    { - 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 ${ diff --git a/src/modules/client/components/blocks/ReadUnit.jsx b/src/modules/client/components/blocks/ReadUnit.jsx index 2c92866..593742e 100644 --- a/src/modules/client/components/blocks/ReadUnit.jsx +++ b/src/modules/client/components/blocks/ReadUnit.jsx @@ -194,9 +194,18 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI

    { - 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

    {progressSummary && progressSummary.lessons_total > 0 && ( -
    -
    +
    +
    {progressSummary.lessons_completed} of {progressSummary.lessons_total} lessons complete {progressSummary.percent}%
    @@ -725,7 +726,7 @@ const CourseDetails = () => {
    -
    About this course
    +
    About

    {course?.description ?? ""}

    @@ -736,7 +737,7 @@ const CourseDetails = () => {
    {course?.objectives?.length > 0 && (
    -
    What you will learn
    +
    Learning Outcomes
      {course.objectives.map((obj) => (
    • {obj.text}
    • @@ -746,6 +747,104 @@ const CourseDetails = () => { )}
    + {/* Roles + Prerequisites */} +
    + {/* Roles */} +
    +
    +
    Roles
    +

    Job roles this course qualifies you for

    +
    + {course?.roles?.length > 0 ? ( +
    + {course.roles.map((r) => ( + + {r.text} + + ))} +
    + ) : ( +
    + No specific roles are required for this course. +
    + )} +
    + + {/* Prerequisites */} +
    +
    +
    Prerequisites
    +

    Complete these courses first

    +
    + {course?.prerequisites?.length > 0 ? ( +
    + {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 ( +
    +
    + {p.completed ? ( +
    + +
    + ) : ( +
    + )} + + {p.title ?? "—"} + +
    + + {p.completed + ? <> Completed + : <> Locked} + +
    + ); + })} +
    + ) : ( +
    + No prerequisites — this is a great course to start with. +
    + )} +
    +
    + {/* Units — while content isn't ready, only Rewards is shown */}
    diff --git a/src/modules/client/pages/LessonDetails.jsx b/src/modules/client/pages/LessonDetails.jsx index 2bc6bd8..b315e4c 100644 --- a/src/modules/client/pages/LessonDetails.jsx +++ b/src/modules/client/pages/LessonDetails.jsx @@ -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 = () => {
    + {taskCtx?.has_task && ( +
    + + + {hasCompleted + ? 'Lesson complete — tracking finished' + : 'Task mode — progress is being tracked automatically' + } + +
    + )} +

    {lesson.title}

    {lesson.description ?? ""}

    diff --git a/src/modules/client/pages/UnitReader.jsx b/src/modules/client/pages/UnitReader.jsx index 7a87f7a..853f270 100644 --- a/src/modules/client/pages/UnitReader.jsx +++ b/src/modules/client/pages/UnitReader.jsx @@ -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 = () => { )}
    + {/* ── Task-mode banner ─────────────────────────────────────────── */} + {taskCtx?.has_task && ( +
    + + + {unitDetail?.is_completed + ? 'Unit complete — tracking finished' + : 'Task mode — progress is being tracked automatically' + } + +
    + )} + {/* ── Desktop sidebar ── */} -
    +
    {/* ── Main content ── */} -
    +
    {selectedQuizId ? ( { }; // ─── Task card ──────────────────────────────────────────────────────────────── -const TaskCard = ({ task, onClick, locked }) => { +const TaskCard = ({ task, onClick, locked, lockedBy, onLockedClick }) => { const reqCount = task.requirements?.length ?? 0; return (
    !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 }) => { : }
    - {task.description && ( -

    - {task.description} -

    - )} +

    + {task.description || 'No information details provided'} +

    {task.deadline ? `Due ${formatDate(task.deadline)}` : 'No due date'}
    {locked && ( -

    Complete the earlier required tasks to unlock.

    +

    Tap to see what's required to unlock this task.

    )}
    @@ -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 = () => {
    + + {/* ── Locked task requirement dialog ── */} + !open && setLockedInfo(null)}> + + + + Task locked + + + {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.'} + + + {!!lockedInfo?.length && ( +
      + {lockedInfo.map((t) => ( +
    • + + {t.name} +
    • + ))} +
    + )} +
    +
    ); };