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 (
+ <>
+
+
+
+ >
+ );
+}
+
+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() {
+
+