mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added things in admin
This commit is contained in:
@@ -24,11 +24,18 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
// content.url is redacted (null) server-side for S3 assets — see
|
// content.url is redacted (null) server-side for S3 assets — see
|
||||||
// redactS3Url() in controllers/admin/assets.controller.js. Re-resolve
|
// redactS3Url() in controllers/admin/assets.controller.js. Re-resolve
|
||||||
// through media.util.js instead of trusting the persisted url/thumbnail.
|
// 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" },
|
{ scope: "admin" },
|
||||||
);
|
);
|
||||||
const poster = thumbnailUrl ?? content?.thumbnail_url ?? undefined;
|
const poster = content?.thumbnail_url ?? undefined;
|
||||||
|
|
||||||
const vidRef = useRef(null);
|
const vidRef = useRef(null);
|
||||||
const wrapRef = useRef(null);
|
const wrapRef = useRef(null);
|
||||||
@@ -55,6 +62,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
const [mediaLoading, setMediaLoading] = useState(true);
|
const [mediaLoading, setMediaLoading] = useState(true);
|
||||||
const [hoverProgress, setHoverProgress] = useState(null); // { x, time }
|
const [hoverProgress, setHoverProgress] = useState(null); // { x, time }
|
||||||
const previewVidRef = useRef(null);
|
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
|
// Reset player when video changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,6 +118,19 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
|
|
||||||
// ── Controls ──────────────────────────────────────────────────────────────
|
// ── 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 togglePlay = useCallback(() => {
|
||||||
const v = vidRef.current;
|
const v = vidRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
@@ -116,7 +143,8 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
const handleAreaClick = useCallback(() => {
|
const handleAreaClick = useCallback(() => {
|
||||||
wrapRef.current?.focus({ preventScroll: true });
|
wrapRef.current?.focus({ preventScroll: true });
|
||||||
togglePlay();
|
togglePlay();
|
||||||
}, [togglePlay]);
|
resetHideTimer();
|
||||||
|
}, [togglePlay, resetHideTimer]);
|
||||||
|
|
||||||
const restart = () => {
|
const restart = () => {
|
||||||
const v = vidRef.current;
|
const v = vidRef.current;
|
||||||
@@ -284,6 +312,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
ref={wrapRef}
|
ref={wrapRef}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={handleKeyDown}
|
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"}`}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* ── Player controls ── */}
|
{/* ── Player controls ──
|
||||||
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-2">
|
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 */}
|
{/* Progress bar */}
|
||||||
<div
|
<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) => {
|
onMouseMove={(e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const pct = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1);
|
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)}
|
onMouseLeave={() => setHoverProgress(null)}
|
||||||
>
|
>
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -399,18 +436,18 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
|
|
||||||
{/* Button row */}
|
{/* Button row */}
|
||||||
<div className="flex items-center gap-2">
|
<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" />}
|
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
|
||||||
</button>
|
</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" />
|
<SkipBack className="size-4" />
|
||||||
</button>
|
</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)}
|
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 ml-auto">
|
<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" />}
|
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
@@ -418,11 +455,11 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
value={muted ? 0 : volume}
|
value={muted ? 0 : volume}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolumeChange}
|
||||||
aria-label="Volume"
|
aria-label="Volume"
|
||||||
className="w-16 accent-foreground"
|
className={`w-16 ${isFullscreen ? "accent-white" : "accent-foreground"}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />}
|
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
* Type : Component (Generic)
|
* Type : Component (Generic)
|
||||||
* Description : Zoom/pan/fit viewer for image and PDF files. Used by both the
|
* Description : Zoom/pan/fit viewer for image and PDF files. Used by both the
|
||||||
* client task-attachment preview (FilePreview.jsx, via blob:
|
* client task-attachment preview (FilePreview.jsx, via blob:
|
||||||
* URLs) and the admin asset preview dialog (AssetPreviewDialog.jsx,
|
* URLs) and the admin asset view pages (ViewImageAsset.jsx,
|
||||||
* via direct stream-token URLs) — `src` accepts either, this
|
* ViewDocumentAsset.jsx, via direct stream-token URLs) —
|
||||||
* component doesn't care how the URL was produced.
|
* `src` accepts either, this component doesn't care how the
|
||||||
|
* URL was produced.
|
||||||
*
|
*
|
||||||
* Supported:
|
* Supported:
|
||||||
* image/jpeg, image/png → <img> with scroll-zoom + drag-pan
|
* image/jpeg, image/png → <img> with scroll-zoom + drag-pan
|
||||||
@@ -214,7 +215,7 @@ const PdfViewer = ({ src, fileName }) => {
|
|||||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||||
new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
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;
|
if (cancelled) return;
|
||||||
setPdfDoc(doc);
|
setPdfDoc(doc);
|
||||||
setNumPages(doc.numPages);
|
setNumPages(doc.numPages);
|
||||||
|
|||||||
@@ -374,6 +374,20 @@ export function AdminTaskProvider({ children }) {
|
|||||||
[request]
|
[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(
|
const updateTask = useCallback(
|
||||||
(taskListId, taskId, payload) =>
|
(taskListId, taskId, payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -675,7 +689,7 @@ export function AdminTaskProvider({ children }) {
|
|||||||
|
|
||||||
// ── Task actions ──────────────────────────────────────────────────
|
// ── Task actions ──────────────────────────────────────────────────
|
||||||
fetchTasks, fetchTask, fetchTasksFlat, fetchArchivedTasks,
|
fetchTasks, fetchTask, fetchTasksFlat, fetchArchivedTasks,
|
||||||
createTask, updateTask,
|
createTask, createTasksBulk, updateTask,
|
||||||
archiveTask, restoreTask,
|
archiveTask, restoreTask,
|
||||||
bulkArchiveTasks, bulkRestoreTasks,
|
bulkArchiveTasks, bulkRestoreTasks,
|
||||||
permanentlyDeleteTask, bulkPermanentlyDeleteTasks,
|
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 DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||||
import { AssetPreviewDialog } from "./AssetPreviewDialog";
|
|
||||||
|
|
||||||
import { buildDataColumns, columnPinning } from "../../config/assets/columns.config";
|
import { buildDataColumns, columnPinning } from "../../config/assets/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/assets/toolbar.config";
|
import { buildToolbarActions } from "../../config/assets/toolbar.config";
|
||||||
@@ -22,7 +21,6 @@ import { formatGeneratedBy } from "@/utils/generatedBy.util";
|
|||||||
export default function AssetsTable() {
|
export default function AssetsTable() {
|
||||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||||
const [archiveIds, setArchiveIds] = useState(null);
|
const [archiveIds, setArchiveIds] = useState(null);
|
||||||
const [previewTarget, setPreviewTarget] = useState(null);
|
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
@@ -64,8 +62,6 @@ export default function AssetsTable() {
|
|||||||
|
|
||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
onView: (row) => navigate(resolveViewPath(row)),
|
onView: (row) => navigate(resolveViewPath(row)),
|
||||||
onPreview: (row) => setPreviewTarget(row),
|
|
||||||
onEdit: (row) => navigate(`edit/${row.asset_id}`),
|
|
||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,13 +143,6 @@ export default function AssetsTable() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleArchiveSuccess}
|
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({
|
const rowActions = buildRowActions({
|
||||||
navigate,
|
navigate,
|
||||||
|
taskListId,
|
||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
onRestore: (row) => setRestoreTarget(row),
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
onMoveUp: (row) => handleMove(row, -1),
|
onMoveUp: (row) => handleMove(row, -1),
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
// modules/admin/config/assets/rowActions.config.jsx
|
// 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 {Object} deps
|
||||||
* @param {Function} deps.onView (row) → void — navigate to view page
|
* @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
|
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
||||||
*/
|
*/
|
||||||
export function buildRowActions({ onView, onPreview, onEdit, onArchive }) {
|
export function buildRowActions({ onView, onArchive }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
@@ -17,18 +15,6 @@ export function buildRowActions({ onView, onPreview, onEdit, onArchive }) {
|
|||||||
icon: <Eye className="h-3.5 w-3.5" />,
|
icon: <Eye className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => onView(row),
|
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",
|
key: "archive",
|
||||||
label: "Archive",
|
label: "Archive",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Eye, Archive, ArchiveRestore, Info, NotebookPen, ArrowUp, ArrowDown } from "lucide-react";
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
label: "View Info",
|
label: "View Info",
|
||||||
icon: <Eye className="size-4" />,
|
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",
|
key: "move-up",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// modules/admin/pages/assets/ViewAudioAsset.jsx
|
// modules/admin/pages/assets/ViewAudioAsset.jsx
|
||||||
|
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
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>
|
<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>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// modules/admin/pages/assets/ViewDocumentAsset.jsx
|
// modules/admin/pages/assets/ViewDocumentAsset.jsx
|
||||||
|
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
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>
|
<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>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// modules/admin/pages/assets/ViewImageAsset.jsx
|
// modules/admin/pages/assets/ViewImageAsset.jsx
|
||||||
|
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
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>
|
<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>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
||||||
|
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
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>
|
<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>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function SummaryRow({ label, value }) {
|
|||||||
export default function CreateTaskList() {
|
export default function CreateTaskList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const {
|
||||||
createTaskList, assignGroups, createTask,
|
createTaskList, assignGroups, createTasksBulk,
|
||||||
fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat,
|
fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat,
|
||||||
loading,
|
loading,
|
||||||
} = useAdminTask();
|
} = useAdminTask();
|
||||||
@@ -97,9 +97,10 @@ export default function CreateTaskList() {
|
|||||||
await assignGroups(created.task_list_id, selectedGroupIds);
|
await assignGroups(created.task_list_id, selectedGroupIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create any queued tasks under the new task list — non-blocking: navigate regardless
|
// Create any queued tasks under the new task list in one batched request —
|
||||||
for (const t of queuedTasks) {
|
// non-blocking: navigate regardless
|
||||||
await createTask(created.task_list_id, {
|
if (queuedTasks.length > 0) {
|
||||||
|
await createTasksBulk(created.task_list_id, queuedTasks.map((t) => ({
|
||||||
name: t.name.trim(),
|
name: t.name.trim(),
|
||||||
description: t.description?.trim() || null,
|
description: t.description?.trim() || null,
|
||||||
deadline: t.deadline || null,
|
deadline: t.deadline || null,
|
||||||
@@ -109,7 +110,7 @@ export default function CreateTaskList() {
|
|||||||
delete req.duration_seconds;
|
delete req.duration_seconds;
|
||||||
return req;
|
return req;
|
||||||
}),
|
}),
|
||||||
});
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(`/admin/taskList/${created.task_list_id}/view`);
|
navigate(`/admin/taskList/${created.task_list_id}/view`);
|
||||||
|
|||||||
@@ -94,16 +94,16 @@ function BindingChip({ courses = [] }) {
|
|||||||
function BindingLine({ courses = [] }) {
|
function BindingLine({ courses = [] }) {
|
||||||
if (!courses.length) {
|
if (!courses.length) {
|
||||||
return (
|
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" />
|
<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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
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" />
|
<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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|||||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { useDateFormat } from '@/hooks/useDateFormat';
|
import { useDateFormat } from '@/hooks/useDateFormat';
|
||||||
|
import api from '@/utils/api.util';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
Link2, Upload, BookOpen, BookMarked, FileCheck2,
|
Link2, Upload, BookOpen, BookMarked, FileCheck2,
|
||||||
Info, GitPullRequest, Tag, Globe, Copy, File, Bookmark,
|
Info, GitPullRequest, Tag, Globe, Copy, File, Bookmark,
|
||||||
ClipboardCheck, NotebookPen, Users, Paperclip, Check, X,
|
ClipboardCheck, NotebookPen, Users, Paperclip, Check, X,
|
||||||
|
Layers, Files, ListChecks,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { formatDate } from '@/utils/table.util';
|
import { formatDate } from '@/utils/table.util';
|
||||||
@@ -62,7 +64,7 @@ function MetaRow({ icon: Icon, label, children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Requirement card ─────────────────────────────────────────────────────────
|
// ─── Requirement card ─────────────────────────────────────────────────────────
|
||||||
function RequirementCard({ req }) {
|
function RequirementCard({ req, courseCounts }) {
|
||||||
const cfg = REQUIREMENT_CONFIG[req.type] ?? {
|
const cfg = REQUIREMENT_CONFIG[req.type] ?? {
|
||||||
label: req.type, badgeLabel: req.type, Icon: FileText,
|
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>
|
<span className="truncate block max-w-[220px]">{req.reference_label}</span>
|
||||||
</MetaRow>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Requirements section ─────────────────────────────────────────────────────
|
// ─── Requirements section ─────────────────────────────────────────────────────
|
||||||
function TaskRequirementsSection({ requirements = [] }) {
|
function TaskRequirementsSection({ requirements = [], courseCountsById = {} }) {
|
||||||
const sorted = [...requirements].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
const sorted = [...requirements].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sorted.length > 0 ? (
|
{sorted.length > 0 ? (
|
||||||
sorted.map((req) => (
|
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>
|
<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 [bulkRestoreIds, setBulkRestoreIds] = useState(null);
|
||||||
const [reviewTarget, setReviewTarget] = useState(null);
|
const [reviewTarget, setReviewTarget] = useState(null);
|
||||||
const [reviewNote, setReviewNote] = useState('');
|
const [reviewNote, setReviewNote] = useState('');
|
||||||
|
const [courseCountsById, setCourseCountsById] = useState({});
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null,
|
getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null,
|
||||||
@@ -204,6 +243,24 @@ export default function ViewTask() {
|
|||||||
fetchTask(taskListId, taskId);
|
fetchTask(taskListId, taskId);
|
||||||
}, [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; };
|
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
|
||||||
|
|
||||||
// ── Toggle accepting submissions ─────────────────────────────────────────
|
// ── Toggle accepting submissions ─────────────────────────────────────────
|
||||||
@@ -437,7 +494,7 @@ export default function ViewTask() {
|
|||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<GitPullRequest className="size-4" /> Requirements
|
<GitPullRequest className="size-4" /> Requirements
|
||||||
</div>
|
</div>
|
||||||
<TaskRequirementsSection requirements={requirements} />
|
<TaskRequirementsSection requirements={requirements} courseCountsById={courseCountsById} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1129,7 +1129,7 @@ const UnitList = () => {
|
|||||||
|
|
||||||
{/* ── Main content ── */}
|
{/* ── 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={`${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 ? (
|
{selectedCompletion ? (
|
||||||
<CourseCompleteBlock course={course} />
|
<CourseCompleteBlock course={course} />
|
||||||
) : selectedAssessment ? (
|
) : selectedAssessment ? (
|
||||||
|
|||||||
@@ -573,7 +573,7 @@ const UnitReader = () => {
|
|||||||
|
|
||||||
{/* ── Task-mode banner ─────────────────────────────────────────── */}
|
{/* ── Task-mode banner ─────────────────────────────────────────── */}
|
||||||
{taskCtx?.has_task && (
|
{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'
|
unitDetail?.is_completed ? 'bg-green-600' : 'bg-blue-600'
|
||||||
}`}>
|
}`}>
|
||||||
<ListChecks className="size-3.5 shrink-0" />
|
<ListChecks className="size-3.5 shrink-0" />
|
||||||
@@ -587,7 +587,7 @@ const UnitReader = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Desktop sidebar ── */}
|
{/* ── 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">
|
<div className="w-14 shrink-0 flex flex-col items-center pt-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -618,7 +618,7 @@ const UnitReader = () => {
|
|||||||
|
|
||||||
{/* ── Main content ── */}
|
{/* ── 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={`${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 ? (
|
{selectedQuizId ? (
|
||||||
<QuizBlock
|
<QuizBlock
|
||||||
quiz={quiz}
|
quiz={quiz}
|
||||||
|
|||||||
Reference in New Issue
Block a user