added things in admin

This commit is contained in:
rgrgogu
2026-08-05 20:44:58 +08:00
parent 882dfbe67a
commit a37a4e9a97
17 changed files with 171 additions and 183 deletions
@@ -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 }) {
)}
</div>
{/* ── Player controls ── */}
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-2">
{/* ── 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. */}
<div className={
isFullscreen
? `absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pt-8 pb-4 flex flex-col gap-2 transition-opacity duration-300 ${controlsVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`
: "px-3 pt-2.5 pb-3 flex flex-col gap-2"
}>
{/* Progress bar */}
<div
className="relative h-1 bg-border rounded-full cursor-pointer"
className={`relative h-1 rounded-full cursor-pointer ${isFullscreen ? "bg-white/30" : "bg-border"}`}
onMouseMove={(e) => {
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)}
>
<div
className="h-full bg-foreground rounded-full transition-[width] duration-100"
className={`h-full rounded-full transition-[width] duration-100 ${isFullscreen ? "bg-white" : "bg-foreground"}`}
style={{ width: `${progress}%` }}
/>
<input
@@ -399,18 +436,18 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
{/* Button row */}
<div className="flex items-center gap-2">
<button onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} className="text-muted-foreground hover:text-foreground transition-colors">
<button onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} className={`transition-colors ${isFullscreen ? "text-white/80 hover:text-white" : "text-muted-foreground hover:text-foreground"}`}>
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
</button>
<button onClick={restart} aria-label="Restart" className="text-muted-foreground hover:text-foreground transition-colors">
<button onClick={restart} aria-label="Restart" className={`transition-colors ${isFullscreen ? "text-white/80 hover:text-white" : "text-muted-foreground hover:text-foreground"}`}>
<SkipBack className="size-4" />
</button>
<span className="text-xs text-muted-foreground tabular-nums">
<span className={`text-xs tabular-nums ${isFullscreen ? "text-white/70" : "text-muted-foreground"}`}>
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
</span>
<div className="flex items-center gap-1.5 ml-auto">
<button onClick={toggleMute} aria-label="Toggle mute" className="text-muted-foreground hover:text-foreground transition-colors">
<button onClick={toggleMute} aria-label="Toggle mute" className={`transition-colors ${isFullscreen ? "text-white/80 hover:text-white" : "text-muted-foreground hover:text-foreground"}`}>
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
</button>
<input
@@ -418,11 +455,11 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
value={muted ? 0 : volume}
onChange={handleVolumeChange}
aria-label="Volume"
className="w-16 accent-foreground"
className={`w-16 ${isFullscreen ? "accent-white" : "accent-foreground"}`}
/>
</div>
<button onClick={toggleFullscreen} aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen"} className="text-muted-foreground hover:text-foreground transition-colors ml-1">
<button onClick={toggleFullscreen} aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen"} className={`transition-colors ml-1 ${isFullscreen ? "text-white/80 hover:text-white" : "text-muted-foreground hover:text-foreground"}`}>
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
</div>
+5 -4
View File
@@ -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 → <img> 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);
+15 -1
View File
@@ -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,
@@ -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 (
<div>
<TranscodeStatusBanner status={asset.transcode_status} />
<div className="p-4">
<VideoBlock
readOnly
onUpdate={NOOP}
content={{
asset_id: asset.asset_id,
storage_provider: asset.storage_provider,
url: asset.file_url,
thumbnail_url: asset.thumbnail_url,
title: asset.display_name ?? asset.original_name,
tag: asset.extension?.toUpperCase() ?? "",
}}
/>
</div>
</div>
);
}
if (asset.file_type === "audio") {
return (
<div className="p-4 flex justify-center">
<AudioBlock
readOnly
onUpdate={NOOP}
content={{
asset_id: asset.asset_id,
storage_provider: asset.storage_provider,
url: asset.file_url,
thumbnail: asset.thumbnail_url,
title: asset.display_name ?? asset.original_name,
tag: asset.extension?.toUpperCase() ?? "",
}}
/>
</div>
);
}
return (
<FileZoomViewer
src={src}
mimeType={asset.mime_type}
fileName={asset.display_name ?? asset.original_name}
loading={loading}
/>
);
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg lg:max-w-4xl p-0 gap-0 overflow-hidden">
<DialogHeader className="px-4 py-3 pr-12 border-b flex-row items-center gap-2 space-y-0">
<Icon className="size-4.5 text-muted-foreground shrink-0" />
<DialogTitle className="text-sm font-medium truncate">{fileName}</DialogTitle>
</DialogHeader>
<PreviewBody asset={asset} />
<div className="px-4 py-2.5 border-t flex items-center gap-2 text-xs text-muted-foreground">
{asset.mime_type && (
<Badge variant="secondary" className="text-xs font-normal select-none">
{asset.mime_type}
</Badge>
)}
{formatFileSize(asset.file_size) && (
<Badge variant="secondary" className="text-xs font-normal select-none">
{formatFileSize(asset.file_size)}
</Badge>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -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 ── */}
<AssetPreviewDialog
asset={previewTarget}
open={!!previewTarget}
onOpenChange={(v) => !v && setPreviewTarget(null)}
/>
</>
);
}
@@ -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),
@@ -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: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row),
},
{
key: "preview",
label: "Preview",
icon: <ScanEye className="h-3.5 w-3.5" />,
onClick: (row) => onPreview(row),
},
{
key: "edit",
label: "Edit Info",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => onEdit(row),
},
{
key: "archive",
label: "Archive",
@@ -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: <Eye className="size-4" />,
onClick: (row) => navigate(`${row.task_id}/view`),
onClick: (row) => navigate(`/admin/taskList/${taskListId}/tasks/${row.task_id}/view`),
},
{
key: "move-up",
@@ -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() {
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
<Pencil className="h-3.5 w-3.5" />
Edit Info
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
@@ -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() {
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
<Pencil className="h-3.5 w-3.5" />
Edit Info
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
@@ -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() {
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
<Pencil className="h-3.5 w-3.5" />
Edit Info
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
@@ -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() {
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
<Button variant="outline" onClick={() => navigate(`/admin/assets/edit/${a.asset_id}`)}>
<Pencil className="h-3.5 w-3.5" />
Edit Info
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
@@ -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`);
@@ -94,16 +94,16 @@ function BindingChip({ courses = [] }) {
function BindingLine({ courses = [] }) {
if (!courses.length) {
return (
<span className="flex items-center gap-1 text-xs italic text-muted-foreground truncate">
<span className="flex items-center gap-1 text-xs italic text-muted-foreground min-w-0">
<Unlink className="size-3 shrink-0" />
Standalone — not attached to any course
<span className="flex-1 min-w-0 truncate">Standalone — not attached to any course</span>
</span>
);
}
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground truncate">
<span className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
<Link2 className="size-3 shrink-0" />
<span className="truncate">{courses.map((c) => c.title).join(', ')}</span>
<span className="flex-1 min-w-0 truncate">{courses.map((c) => c.title).join(', ')}</span>
</span>
);
}
@@ -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 }) {
<span className="truncate block max-w-[220px]">{req.reference_label}</span>
</MetaRow>
)}
{req.type === 'read_course' && req.reference_id && (
<>
<MetaRow icon={Layers} label="Units">
{courseCounts === undefined ? (
<Skeleton className="h-3.5 w-6 inline-block ml-auto" />
) : courseCounts ? (
<span className="font-medium">{courseCounts.unitCount}</span>
) : (
<span className="text-muted-foreground italic">—</span>
)}
</MetaRow>
<MetaRow icon={Files} label="Lessons">
{courseCounts === undefined ? (
<Skeleton className="h-3.5 w-6 inline-block ml-auto" />
) : courseCounts ? (
<span className="font-medium">{courseCounts.lessonCount}</span>
) : (
<span className="text-muted-foreground italic">—</span>
)}
</MetaRow>
<MetaRow icon={ListChecks} label="Quizzes">
{courseCounts === undefined ? (
<Skeleton className="h-3.5 w-6 inline-block ml-auto" />
) : courseCounts ? (
<span className="font-medium">{courseCounts.quizCount}</span>
) : (
<span className="text-muted-foreground italic">—</span>
)}
</MetaRow>
</>
)}
</div>
);
}
// ─── Requirements section ─────────────────────────────────────────────────────
function TaskRequirementsSection({ requirements = [] }) {
function TaskRequirementsSection({ requirements = [], courseCountsById = {} }) {
const sorted = [...requirements].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
return (
<div className="space-y-2">
{sorted.length > 0 ? (
sorted.map((req) => (
<RequirementCard key={req.requirement_id} req={req} />
<RequirementCard
key={req.requirement_id}
req={req}
courseCounts={courseCountsById[req.reference_id]}
/>
))
) : (
<p className="text-sm text-muted-foreground italic">No other requirements.</p>
@@ -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() {
<div className="flex items-center gap-2 text-sm font-medium">
<GitPullRequest className="size-4" /> Requirements
</div>
<TaskRequirementsSection requirements={requirements} />
<TaskRequirementsSection requirements={requirements} courseCountsById={courseCountsById} />
</div>
</div>
+1 -1
View File
@@ -1129,7 +1129,7 @@ const UnitList = () => {
{/* ── Main content ── */}
<div className={`${taskCtx?.has_task ? "xs:mt-42 lg:mt-36" : "mt-32"} ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
<div className={`relative w-full h-full ${showSidebarContent ? "" : "mx-auto max-w-xl"}`}>
<div className="relative w-full h-full mx-auto max-w-2xl">
{selectedCompletion ? (
<CourseCompleteBlock course={course} />
) : selectedAssessment ? (
+3 -3
View File
@@ -573,7 +573,7 @@ const UnitReader = () => {
{/* ── Task-mode banner ─────────────────────────────────────────── */}
{taskCtx?.has_task && (
<div className={`fixed xs:top-[124px] lg:top-[112px] left-0 right-0 z-30 flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 shadow-sm transition-colors ${
<div className={`fixed xs:top-[124px] lg:top-[116px] left-0 right-0 z-30 flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 shadow-sm transition-colors ${
unitDetail?.is_completed ? 'bg-green-600' : 'bg-blue-600'
}`}>
<ListChecks className="size-3.5 shrink-0" />
@@ -587,7 +587,7 @@ const UnitReader = () => {
)}
{/* ── Desktop sidebar ── */}
<div className={`hidden lg:flex flex-row fixed ${taskCtx?.has_task ? "top-[140px]" : "top-[112px]"} bottom-0 left-0 z-30 bg-muted border-r`}>
<div className={`hidden lg:flex flex-row fixed ${taskCtx?.has_task ? "top-[145px]" : "top-[112px]"} bottom-0 left-0 z-30 bg-muted border-r`}>
<div className="w-14 shrink-0 flex flex-col items-center pt-3">
<Button
variant="ghost"
@@ -618,7 +618,7 @@ const UnitReader = () => {
{/* ── Main content ── */}
<div className={`${taskCtx?.has_task ? "xs:mt-42 lg:mt-36" : "mt-32"} ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
<div className={`relative w-full h-full ${showSidebarContent ? "" : "mx-auto max-w-xl"}`}>
<div className="relative w-full h-full mx-auto max-w-2xl">
{selectedQuizId ? (
<QuizBlock
quiz={quiz}