From a37a4e9a97f5477d0e57a2e95f38307b38cef658 Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Wed, 5 Aug 2026 20:44:58 +0800 Subject: [PATCH] added things in admin --- .../generic/Blocks/Admin/VideoBlock.jsx | 65 +++++++--- src/components/generic/FileZoomViewer.jsx | 9 +- src/contexts/AdminTaskContext.jsx | 16 ++- .../components/assets/AssetPreviewDialog.jsx | 114 ------------------ .../admin/components/assets/AssetsTable.jsx | 11 -- .../admin/components/task_list/TasksTable.jsx | 1 + .../admin/config/assets/rowActions.config.jsx | 18 +-- .../task_list/task/rowActions.config.jsx | 4 +- .../admin/pages/assets/ViewAudioAsset.jsx | 6 +- .../admin/pages/assets/ViewDocumentAsset.jsx | 6 +- .../admin/pages/assets/ViewImageAsset.jsx | 6 +- .../admin/pages/assets/ViewVideoAsset.jsx | 6 +- .../admin/pages/task_list/CreateTaskList.jsx | 11 +- .../task_list/task/RequirementBuilder.jsx | 8 +- .../admin/pages/task_list/task/ViewTask.jsx | 65 +++++++++- src/modules/client/pages/UnitList.jsx | 2 +- src/modules/client/pages/UnitReader.jsx | 6 +- 17 files changed, 171 insertions(+), 183 deletions(-) delete mode 100644 src/modules/admin/components/assets/AssetPreviewDialog.jsx diff --git a/src/components/generic/Blocks/Admin/VideoBlock.jsx b/src/components/generic/Blocks/Admin/VideoBlock.jsx index 02cbd08..417a50a 100644 --- a/src/components/generic/Blocks/Admin/VideoBlock.jsx +++ b/src/components/generic/Blocks/Admin/VideoBlock.jsx @@ -24,11 +24,18 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) { // content.url is redacted (null) server-side for S3 assets — see // redactS3Url() in controllers/admin/assets.controller.js. Re-resolve // through media.util.js instead of trusting the persisted url/thumbnail. - const { src, thumbnailUrl, loading } = useAssetPreviewSrc( - { asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url, thumbnail_url: content?.thumbnail_url }, + // + // thumbnail_url is intentionally NOT passed here: resolveAssetSrc()'s fast + // path falls back to thumbnail_url when there's no stream_token, which for + // a video asset with a redacted (null) file_url meant `src` resolved to + // the poster JPEG instead of the video file, and the browser then refused + // to play it ("media resource ... was not suitable"). Omitting it forces + // the async mint-token path, which resolves the real video stream src. + const { src, loading } = useAssetPreviewSrc( + { asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url }, { scope: "admin" }, ); - const poster = thumbnailUrl ?? content?.thumbnail_url ?? undefined; + const poster = content?.thumbnail_url ?? undefined; const vidRef = useRef(null); const wrapRef = useRef(null); @@ -55,6 +62,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) { const [mediaLoading, setMediaLoading] = useState(true); const [hoverProgress, setHoverProgress] = useState(null); // { x, time } const previewVidRef = useRef(null); + // Fullscreen-only auto-hide for the controls bar, mirroring the client + // player: idle 3s while playing fades the bar out, any mouse movement (or + // a pause) brings it back. Outside fullscreen the bar stays put — it's a + // normal in-flow row there, not an overlay, so hiding it would just leave + // a blank gap. + const [controlsVisible, setControlsVisible] = useState(true); + const hideTimer = useRef(null); // Reset player when video changes useEffect(() => { @@ -104,6 +118,19 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) { // ── Controls ────────────────────────────────────────────────────────────── + const resetHideTimer = useCallback(() => { + setControlsVisible(true); + clearTimeout(hideTimer.current); + if (playing && isFullscreen) { + hideTimer.current = setTimeout(() => setControlsVisible(false), 3000); + } + }, [playing, isFullscreen]); + + useEffect(() => { + resetHideTimer(); + return () => clearTimeout(hideTimer.current); + }, [playing, isFullscreen, resetHideTimer]); + const togglePlay = useCallback(() => { const v = vidRef.current; if (!v) return; @@ -116,7 +143,8 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) { const handleAreaClick = useCallback(() => { wrapRef.current?.focus({ preventScroll: true }); togglePlay(); - }, [togglePlay]); + resetHideTimer(); + }, [togglePlay, resetHideTimer]); const restart = () => { const v = vidRef.current; @@ -284,6 +312,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) { ref={wrapRef} tabIndex={0} onKeyDown={handleKeyDown} + onMouseMove={isFullscreen ? resetHideTimer : undefined} className={`overflow-hidden bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring ${isFullscreen ? "fixed inset-0 z-[100] flex flex-col" : "rounded-lg border"}`} > @@ -342,12 +371,20 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) { )} - {/* ── Player controls ── */} -
+ {/* ── Player controls ── + Fullscreen: absolutely positioned over the bottom of the video + (wrap is `fixed inset-0` there, so it's the containing block) + and fades per controlsVisible. Non-fullscreen: normal in-flow + row below the video, always visible. */} +
{/* Progress bar */}
{ const rect = e.currentTarget.getBoundingClientRect(); const pct = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1); @@ -360,7 +397,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) { onMouseLeave={() => setHoverProgress(null)} >
- - - + {fmtTime(currentTime)} / {fmtTime(totalDuration)}
-
-
diff --git a/src/components/generic/FileZoomViewer.jsx b/src/components/generic/FileZoomViewer.jsx index b7fd7bb..5748846 100644 --- a/src/components/generic/FileZoomViewer.jsx +++ b/src/components/generic/FileZoomViewer.jsx @@ -3,9 +3,10 @@ * Type : Component (Generic) * Description : Zoom/pan/fit viewer for image and PDF files. Used by both the * client task-attachment preview (FilePreview.jsx, via blob: - * URLs) and the admin asset preview dialog (AssetPreviewDialog.jsx, - * via direct stream-token URLs) — `src` accepts either, this - * component doesn't care how the URL was produced. + * URLs) and the admin asset view pages (ViewImageAsset.jsx, + * ViewDocumentAsset.jsx, via direct stream-token URLs) — + * `src` accepts either, this component doesn't care how the + * URL was produced. * * Supported: * image/jpeg, image/png → with scroll-zoom + drag-pan @@ -214,7 +215,7 @@ const PdfViewer = ({ src, fileName }) => { pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); - const doc = await pdfjsLib.getDocument(src).promise; + const doc = await pdfjsLib.getDocument({ url: src }).promise; if (cancelled) return; setPdfDoc(doc); setNumPages(doc.numPages); diff --git a/src/contexts/AdminTaskContext.jsx b/src/contexts/AdminTaskContext.jsx index 01b0bb5..5e699fa 100644 --- a/src/contexts/AdminTaskContext.jsx +++ b/src/contexts/AdminTaskContext.jsx @@ -374,6 +374,20 @@ export function AdminTaskProvider({ children }) { [request] ); + // Batches Task + TaskRequirement creation into one request — used by the + // Create Task List wizard so queuing N tasks costs one request instead of N. + const createTasksBulk = useCallback( + (taskListId, tasks) => + request(async () => { + const res = await api.post(`${BASE}/${taskListId}/tasks/bulk`, { tasks }); + const data = res.data?.data ?? null; + toast(`${data?.tasks?.length ?? tasks.length} task(s) created.`); + warnPreCompleted(data?.warnings); + return data?.tasks ?? null; + }), + [request] + ); + const updateTask = useCallback( (taskListId, taskId, payload) => request(async () => { @@ -675,7 +689,7 @@ export function AdminTaskProvider({ children }) { // ── Task actions ────────────────────────────────────────────────── fetchTasks, fetchTask, fetchTasksFlat, fetchArchivedTasks, - createTask, updateTask, + createTask, createTasksBulk, updateTask, archiveTask, restoreTask, bulkArchiveTasks, bulkRestoreTasks, permanentlyDeleteTask, bulkPermanentlyDeleteTasks, diff --git a/src/modules/admin/components/assets/AssetPreviewDialog.jsx b/src/modules/admin/components/assets/AssetPreviewDialog.jsx deleted file mode 100644 index 2add3e9..0000000 --- a/src/modules/admin/components/assets/AssetPreviewDialog.jsx +++ /dev/null @@ -1,114 +0,0 @@ -// modules/admin/components/assets/AssetPreviewDialog.jsx -// -// Quick-look dialog for the Assets table's "Preview" row action. Images and -// PDFs render through the shared FileZoomViewer (zoom/pan). Video and audio -// reuse the existing admin VideoBlock/AudioBlock players (Blocks/Admin/) — -// same rich, custom-controls-only UI already used on ViewVideoAsset/ -// ViewAudioAsset and in the lesson block editors — in readOnly mode. No -// Download button here or anywhere else an asset can be previewed — -// protecting assets means the raw file is never handed to the browser as a -// downloadable blob, only streamed inline via short-lived token. - -import { - Dialog, DialogContent, DialogHeader, DialogTitle, -} from "@/components/ui/dialog"; -import { Badge } from "@/components/ui/badge"; -import { Image, Video, Music, FileText, File } from "lucide-react"; - -import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc"; -import FileZoomViewer from "@/components/generic/FileZoomViewer"; -import { VideoBlock } from "@/components/generic/Blocks/Admin/VideoBlock"; -import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock"; -import { TranscodeStatusBanner } from "@/components/generic/TranscodeStatusBanner"; -import { formatFileSize } from "@/utils/format.util"; - -const KIND_ICON = { image: Image, video: Video, audio: Music, document: FileText }; -const NOOP = () => {}; - -function PreviewBody({ asset }) { - const isZoomable = asset.file_type === "image" || asset.file_type === "document"; - const { src, loading } = useAssetPreviewSrc(isZoomable ? asset : null, { scope: "admin" }); - - if (asset.file_type === "video") { - return ( -
- -
- -
-
- ); - } - - if (asset.file_type === "audio") { - return ( -
- -
- ); - } - - return ( - - ); -} - -export function AssetPreviewDialog({ asset, open, onOpenChange }) { - if (!asset) return null; - - const Icon = KIND_ICON[asset.file_type] ?? File; - const fileName = asset.display_name ?? asset.original_name; - - return ( - - - - - {fileName} - - - - -
- {asset.mime_type && ( - - {asset.mime_type} - - )} - {formatFileSize(asset.file_size) && ( - - {formatFileSize(asset.file_size)} - - )} -
-
-
- ); -} diff --git a/src/modules/admin/components/assets/AssetsTable.jsx b/src/modules/admin/components/assets/AssetsTable.jsx index c1dca32..b764e38 100644 --- a/src/modules/admin/components/assets/AssetsTable.jsx +++ b/src/modules/admin/components/assets/AssetsTable.jsx @@ -9,7 +9,6 @@ import { useAuth } from "@/contexts/AuthContext"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; -import { AssetPreviewDialog } from "./AssetPreviewDialog"; import { buildDataColumns, columnPinning } from "../../config/assets/columns.config"; import { buildToolbarActions } from "../../config/assets/toolbar.config"; @@ -22,7 +21,6 @@ import { formatGeneratedBy } from "@/utils/generatedBy.util"; export default function AssetsTable() { const [archiveTarget, setArchiveTarget] = useState(null); const [archiveIds, setArchiveIds] = useState(null); - const [previewTarget, setPreviewTarget] = useState(null); const tableRefsRef = useRef({ getFilters: () => [], @@ -64,8 +62,6 @@ export default function AssetsTable() { const rowActions = buildRowActions({ onView: (row) => navigate(resolveViewPath(row)), - onPreview: (row) => setPreviewTarget(row), - onEdit: (row) => navigate(`edit/${row.asset_id}`), onArchive: (row) => setArchiveTarget(row), }); @@ -147,13 +143,6 @@ export default function AssetsTable() { loading={loading} onSuccess={handleArchiveSuccess} /> - - {/* ── Quick-look preview ── */} - !v && setPreviewTarget(null)} - /> ); } \ No newline at end of file diff --git a/src/modules/admin/components/task_list/TasksTable.jsx b/src/modules/admin/components/task_list/TasksTable.jsx index f738ce8..f3d7253 100644 --- a/src/modules/admin/components/task_list/TasksTable.jsx +++ b/src/modules/admin/components/task_list/TasksTable.jsx @@ -103,6 +103,7 @@ export default function TasksTable({ taskListId }) { const rowActions = buildRowActions({ navigate, + taskListId, onArchive: (row) => setArchiveTarget(row), onRestore: (row) => setRestoreTarget(row), onMoveUp: (row) => handleMove(row, -1), diff --git a/src/modules/admin/config/assets/rowActions.config.jsx b/src/modules/admin/config/assets/rowActions.config.jsx index 3fb1b32..5a90569 100644 --- a/src/modules/admin/config/assets/rowActions.config.jsx +++ b/src/modules/admin/config/assets/rowActions.config.jsx @@ -1,15 +1,13 @@ // modules/admin/config/assets/rowActions.config.jsx -import { Eye, ScanEye, Pencil, Archive } from "lucide-react"; +import { Eye, Archive } from "lucide-react"; /** * @param {Object} deps * @param {Function} deps.onView (row) → void — navigate to view page - * @param {Function} deps.onPreview (row) → void — open the quick-look preview dialog - * @param {Function} deps.onEdit (row) → void — navigate to edit page * @param {Function} deps.onArchive (row) → void — open archive dialog */ -export function buildRowActions({ onView, onPreview, onEdit, onArchive }) { +export function buildRowActions({ onView, onArchive }) { return [ { key: "view", @@ -17,18 +15,6 @@ export function buildRowActions({ onView, onPreview, onEdit, onArchive }) { icon: , onClick: (row) => onView(row), }, - { - key: "preview", - label: "Preview", - icon: , - onClick: (row) => onPreview(row), - }, - { - key: "edit", - label: "Edit Info", - icon: , - onClick: (row) => onEdit(row), - }, { key: "archive", label: "Archive", diff --git a/src/modules/admin/config/task_list/task/rowActions.config.jsx b/src/modules/admin/config/task_list/task/rowActions.config.jsx index 989e25f..ae0efe4 100644 --- a/src/modules/admin/config/task_list/task/rowActions.config.jsx +++ b/src/modules/admin/config/task_list/task/rowActions.config.jsx @@ -1,12 +1,12 @@ import { Eye, Archive, ArchiveRestore, Info, NotebookPen, ArrowUp, ArrowDown } from "lucide-react"; -export function buildRowActions({ navigate, onArchive, onRestore, onMoveUp, onMoveDown, showArchived, tasks = [] }) { +export function buildRowActions({ navigate, taskListId, onArchive, onRestore, onMoveUp, onMoveDown, showArchived, tasks = [] }) { return [ { key: "view", label: "View Info", icon: , - onClick: (row) => navigate(`${row.task_id}/view`), + onClick: (row) => navigate(`/admin/taskList/${taskListId}/tasks/${row.task_id}/view`), }, { key: "move-up", diff --git a/src/modules/admin/pages/assets/ViewAudioAsset.jsx b/src/modules/admin/pages/assets/ViewAudioAsset.jsx index f48b87e..9554d40 100644 --- a/src/modules/admin/pages/assets/ViewAudioAsset.jsx +++ b/src/modules/admin/pages/assets/ViewAudioAsset.jsx @@ -1,7 +1,7 @@ // modules/admin/pages/assets/ViewAudioAsset.jsx import { useParams, useNavigate } from "react-router-dom"; -import { ArrowLeft, Lock, Globe, Music2 } from "lucide-react"; +import { ArrowLeft, Lock, Globe, Music2, Pencil } from "lucide-react"; import { useDateFormat } from "@/hooks/useDateFormat"; import { useAssetFetchState } from "@/hooks/useAssetFetchState"; @@ -71,6 +71,10 @@ export default function ViewAudioAsset() {

{a.display_name ?? a.original_name}

{a.mime_type}

+
diff --git a/src/modules/admin/pages/assets/ViewDocumentAsset.jsx b/src/modules/admin/pages/assets/ViewDocumentAsset.jsx index 90e6dbe..d33c702 100644 --- a/src/modules/admin/pages/assets/ViewDocumentAsset.jsx +++ b/src/modules/admin/pages/assets/ViewDocumentAsset.jsx @@ -1,7 +1,7 @@ // modules/admin/pages/assets/ViewDocumentAsset.jsx import { useParams, useNavigate } from "react-router-dom"; -import { ArrowLeft, Lock, Globe, FileText } from "lucide-react"; +import { ArrowLeft, Lock, Globe, FileText, Pencil } from "lucide-react"; import { useDateFormat } from "@/hooks/useDateFormat"; import { useAssetFetchState } from "@/hooks/useAssetFetchState"; @@ -63,6 +63,10 @@ export default function ViewDocumentAsset() {

{a.display_name ?? a.original_name}

{a.mime_type}

+
diff --git a/src/modules/admin/pages/assets/ViewImageAsset.jsx b/src/modules/admin/pages/assets/ViewImageAsset.jsx index 85cbb83..b9e4ef1 100644 --- a/src/modules/admin/pages/assets/ViewImageAsset.jsx +++ b/src/modules/admin/pages/assets/ViewImageAsset.jsx @@ -1,7 +1,7 @@ // modules/admin/pages/assets/ViewImageAsset.jsx import { useParams, useNavigate } from "react-router-dom"; -import { ArrowLeft, Lock, Globe } from "lucide-react"; +import { ArrowLeft, Lock, Globe, Pencil } from "lucide-react"; import { useDateFormat } from "@/hooks/useDateFormat"; import { useAssetFetchState } from "@/hooks/useAssetFetchState"; @@ -59,6 +59,10 @@ export default function ViewImageAsset() {

{a.display_name ?? a.original_name}

{a.mime_type}

+
diff --git a/src/modules/admin/pages/assets/ViewVideoAsset.jsx b/src/modules/admin/pages/assets/ViewVideoAsset.jsx index f591570..1120d7b 100644 --- a/src/modules/admin/pages/assets/ViewVideoAsset.jsx +++ b/src/modules/admin/pages/assets/ViewVideoAsset.jsx @@ -1,7 +1,7 @@ // modules/admin/pages/assets/ViewVideoAsset.jsx import { useParams, useNavigate } from "react-router-dom"; -import { ArrowLeft, Lock, Globe } from "lucide-react"; +import { ArrowLeft, Lock, Globe, Pencil } from "lucide-react"; import { useDateFormat } from "@/hooks/useDateFormat"; import { useAssetFetchState } from "@/hooks/useAssetFetchState"; @@ -57,6 +57,10 @@ export default function ViewVideoAsset() {

{a.display_name ?? a.original_name}

{a.mime_type}

+
diff --git a/src/modules/admin/pages/task_list/CreateTaskList.jsx b/src/modules/admin/pages/task_list/CreateTaskList.jsx index 8344846..5a0564b 100644 --- a/src/modules/admin/pages/task_list/CreateTaskList.jsx +++ b/src/modules/admin/pages/task_list/CreateTaskList.jsx @@ -36,7 +36,7 @@ function SummaryRow({ label, value }) { export default function CreateTaskList() { const navigate = useNavigate(); const { - createTaskList, assignGroups, createTask, + createTaskList, assignGroups, createTasksBulk, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading, } = useAdminTask(); @@ -97,9 +97,10 @@ export default function CreateTaskList() { await assignGroups(created.task_list_id, selectedGroupIds); } - // Create any queued tasks under the new task list — non-blocking: navigate regardless - for (const t of queuedTasks) { - await createTask(created.task_list_id, { + // Create any queued tasks under the new task list in one batched request — + // non-blocking: navigate regardless + if (queuedTasks.length > 0) { + await createTasksBulk(created.task_list_id, queuedTasks.map((t) => ({ name: t.name.trim(), description: t.description?.trim() || null, deadline: t.deadline || null, @@ -109,7 +110,7 @@ export default function CreateTaskList() { delete req.duration_seconds; return req; }), - }); + }))); } navigate(`/admin/taskList/${created.task_list_id}/view`); diff --git a/src/modules/admin/pages/task_list/task/RequirementBuilder.jsx b/src/modules/admin/pages/task_list/task/RequirementBuilder.jsx index fc2c723..7fb131b 100644 --- a/src/modules/admin/pages/task_list/task/RequirementBuilder.jsx +++ b/src/modules/admin/pages/task_list/task/RequirementBuilder.jsx @@ -94,16 +94,16 @@ function BindingChip({ courses = [] }) { function BindingLine({ courses = [] }) { if (!courses.length) { return ( - + - Standalone — not attached to any course + Standalone — not attached to any course ); } return ( - + - {courses.map((c) => c.title).join(', ')} + {courses.map((c) => c.title).join(', ')} ); } diff --git a/src/modules/admin/pages/task_list/task/ViewTask.jsx b/src/modules/admin/pages/task_list/task/ViewTask.jsx index 31abfc8..053317a 100644 --- a/src/modules/admin/pages/task_list/task/ViewTask.jsx +++ b/src/modules/admin/pages/task_list/task/ViewTask.jsx @@ -4,6 +4,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useAdminTask } from '@/contexts/AdminTaskContext'; import { useAuth } from '@/contexts/AuthContext'; import { useDateFormat } from '@/hooks/useDateFormat'; +import api from '@/utils/api.util'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -26,6 +27,7 @@ import { Link2, Upload, BookOpen, BookMarked, FileCheck2, Info, GitPullRequest, Tag, Globe, Copy, File, Bookmark, ClipboardCheck, NotebookPen, Users, Paperclip, Check, X, + Layers, Files, ListChecks, } from 'lucide-react'; import { formatDate } from '@/utils/table.util'; @@ -62,7 +64,7 @@ function MetaRow({ icon: Icon, label, children }) { } // ─── Requirement card ───────────────────────────────────────────────────────── -function RequirementCard({ req }) { +function RequirementCard({ req, courseCounts }) { const cfg = REQUIREMENT_CONFIG[req.type] ?? { label: req.type, badgeLabel: req.type, Icon: FileText, }; @@ -127,18 +129,54 @@ function RequirementCard({ req }) { {req.reference_label} )} + + {req.type === 'read_course' && req.reference_id && ( + <> + + {courseCounts === undefined ? ( + + ) : courseCounts ? ( + {courseCounts.unitCount} + ) : ( + — + )} + + + {courseCounts === undefined ? ( + + ) : courseCounts ? ( + {courseCounts.lessonCount} + ) : ( + — + )} + + + {courseCounts === undefined ? ( + + ) : courseCounts ? ( + {courseCounts.quizCount} + ) : ( + — + )} + + + )}
); } // ─── Requirements section ───────────────────────────────────────────────────── -function TaskRequirementsSection({ requirements = [] }) { +function TaskRequirementsSection({ requirements = [], courseCountsById = {} }) { const sorted = [...requirements].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); return (
{sorted.length > 0 ? ( sorted.map((req) => ( - + )) ) : (

No other requirements.

@@ -195,6 +233,7 @@ export default function ViewTask() { const [bulkRestoreIds, setBulkRestoreIds] = useState(null); const [reviewTarget, setReviewTarget] = useState(null); const [reviewNote, setReviewNote] = useState(''); + const [courseCountsById, setCourseCountsById] = useState({}); const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null, @@ -204,6 +243,24 @@ export default function ViewTask() { fetchTask(taskListId, taskId); }, [taskListId, taskId]); + // ── Structure counts for "Read a Course" requirements — how many units, + // lessons, and quizzes the referenced course actually contains ────────── + useEffect(() => { + if (!task) return; + const courseReqs = (task.requirements ?? []).filter( + (r) => r.type === 'read_course' && r.reference_id + ); + courseReqs.forEach(({ reference_id }) => { + api.get(`/admin/courses/uuid/${reference_id}/structure-counts`) + .then(({ data }) => { + setCourseCountsById((prev) => ({ ...prev, [reference_id]: data?.data ?? null })); + }) + .catch(() => { + setCourseCountsById((prev) => ({ ...prev, [reference_id]: null })); + }); + }); + }, [task?.task_id]); + const handleRefsReady = (refs) => { tableRefsRef.current = refs; }; // ── Toggle accepting submissions ───────────────────────────────────────── @@ -437,7 +494,7 @@ export default function ViewTask() {
Requirements
- +
diff --git a/src/modules/client/pages/UnitList.jsx b/src/modules/client/pages/UnitList.jsx index 4f3ba7e..60f437d 100644 --- a/src/modules/client/pages/UnitList.jsx +++ b/src/modules/client/pages/UnitList.jsx @@ -1129,7 +1129,7 @@ const UnitList = () => { {/* ── Main content ── */}
-
+
{selectedCompletion ? ( ) : selectedAssessment ? ( diff --git a/src/modules/client/pages/UnitReader.jsx b/src/modules/client/pages/UnitReader.jsx index 51b4ff2..84a7919 100644 --- a/src/modules/client/pages/UnitReader.jsx +++ b/src/modules/client/pages/UnitReader.jsx @@ -573,7 +573,7 @@ const UnitReader = () => { {/* ── Task-mode banner ─────────────────────────────────────────── */} {taskCtx?.has_task && ( -
@@ -587,7 +587,7 @@ const UnitReader = () => { )} {/* ── Desktop sidebar ── */} -
+