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
|
||||
// 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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user