change things

This commit is contained in:
rgrgogu
2026-08-09 13:00:42 +08:00
parent 2be8547a52
commit 08a0d122ea
4 changed files with 296 additions and 99 deletions
+22 -1
View File
@@ -259,6 +259,27 @@ export function LibraryProvider({ children }) {
[request],
);
const detachUnitFromCourse = useCallback(
(unitId, courseId) =>
request(async () => {
const { data } = await api.delete(`/admin/courses/${courseId}/units/${unitId}`);
toast("Detached from course.");
return data;
}),
[request],
);
const attachUnitToCourses = useCallback(
(unitId, courseIds) =>
request(async () => {
await Promise.all(
courseIds.map((courseId) => api.post(`/admin/courses/${courseId}/units/attach`, { unit_ids: [unitId] }))
);
toast(`Attached to ${courseIds.length} course${courseIds.length === 1 ? "" : "s"}.`);
}),
[request],
);
const reorderUnitLessons = useCallback(
(unitId, lessonIds) =>
request(async () => {
@@ -477,7 +498,7 @@ export function LibraryProvider({ children }) {
permanentlyDeleteUnit, permanentlyDeleteUnits,
fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact,
fetchUnitFieldValues,
attachLessonsToUnit, detachLessonFromUnit, reorderUnitLessons,
attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons,
fetchUnitProduct, saveUnitProduct, removeUnitProduct,
// lesson library
@@ -0,0 +1,125 @@
// AttachCoursesDialog — pick existing Courses and attach this Unit to them.
// Mirror of AttachUnitsDialog, run in the opposite direction: a unit can be
// attached to more than one course, so selection is multi-select here too.
import { useEffect, useMemo, useState } from "react";
import { Search, Link2 } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { formatDuration } from "@/utils/timestamp.util";
export default function AttachCoursesDialog({ open, onOpenChange, attachedCourseIds = [], onAttach, loading }) {
const { fetchCoursesFlat, loading: coursesLoading } = useCourses();
const [courses, setCourses] = useState([]);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState([]);
useEffect(() => {
if (open) {
setSelected([]);
setQuery("");
fetchCoursesFlat().then((c) => setCourses(c ?? []));
}
}, [open, fetchCoursesFlat]);
const attachedSet = useMemo(
() => new Set(attachedCourseIds.map(String)),
[attachedCourseIds]
);
const candidates = useMemo(() => {
const q = query.trim().toLowerCase();
return (courses ?? [])
.filter((c) => !attachedSet.has(String(c.course_id)))
.filter((c) => !q || c.title?.toLowerCase().includes(q));
}, [courses, attachedSet, query]);
const toggle = (courseId) =>
setSelected((prev) =>
prev.includes(courseId) ? prev.filter((id) => id !== courseId) : [...prev, courseId]
);
const handleAttach = async () => {
if (!selected.length) return;
await onAttach(selected);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" /> Select Courses
</DialogTitle>
<DialogDescription>
Attach this unit to one or more existing courses without copying it.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search courses..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<ScrollArea className="h-64 rounded-md border">
{coursesLoading ? (
<div className="flex items-center justify-center h-full py-10">
<Spinner className="h-5 w-5" />
</div>
) : candidates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
{query ? "No courses match your search." : "This unit is already attached to every course."}
</p>
) : (
<div className="divide-y">
{candidates.map((c) => (
<label
key={c.course_id}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
>
<Checkbox
checked={selected.includes(c.course_id)}
onCheckedChange={() => toggle(c.course_id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium w-64 truncate" title={c.title}>
{c.title}
</p>
<p className="text-xs text-muted-foreground truncate">
{formatDuration(c.duration_seconds ?? 0)}
</p>
</div>
</label>
))}
</div>
)}
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleAttach} disabled={loading || !selected.length}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Attach {selected.length > 0 ? `(${selected.length})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+112 -96
View File
@@ -1,12 +1,14 @@
// modules/admin/pages/assets/ViewImageAsset.jsx
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Pencil } from "lucide-react";
import { ArrowLeft, Lock, Globe, Pencil, Download } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { formatFileSize } from "@/utils/format.util";
import { downloadAsset } from "@/utils/media.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -15,109 +17,123 @@ import AssetPageLoader from "@/components/generic/AssetLoader";
function MetaRow({ label, value }) {
if (!value && value !== 0) return null;
return (
<div className="flex items-start gap-3 py-2">
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
<span className="text-sm font-medium break-all">{String(value)}</span>
</div>
);
if (!value && value !== 0) return null;
return (
<div className="flex items-start gap-3 py-2">
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
<span className="text-sm font-medium break-all">{String(value)}</span>
</div>
);
}
export default function ViewImageAsset() {
const { assetId } = useParams();
const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const { assetId } = useParams();
const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
const [downloading, setDownloading] = useState(false);
if (loading) {
return <AssetPageLoader />;
async function handleDownload() {
setDownloading(true);
try {
await downloadAsset(selectedAsset, { scope: "admin" });
} finally {
setDownloading(false);
}
}
if (notFound) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
}
const a = selectedAsset;
if (loading) {
return <AssetPageLoader />;
}
if (notFound) {
return (
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<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">
{/* ── Image preview ── */}
<div className="lg:col-span-3 rounded-lg border overflow-hidden">
<FileZoomViewer
src={streamUrl}
mimeType={a.mime_type}
fileName={a.display_name ?? a.original_name}
loading={previewLoading}
/>
</div>
{/* ── Metadata panel ── */}
<div className="lg:col-span-2 space-y-4">
<div className="rounded-lg border bg-card p-4 space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
<MetaRow label="Original Name" value={a.original_name} />
<MetaRow label="Extension" value={a.extension} />
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
<MetaRow label="Resolution" value={a.resolution} />
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
<MetaRow label="Provider" value={a.storage_provider} />
<MetaRow label="Bucket" value={a.storage_bucket} />
<MetaRow label="Key" value={a.storage_key} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
<div className="flex items-center gap-2 py-1">
{a.is_public
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
}
</div>
<MetaRow label="Access Level" value={a.access_level} />
<MetaRow label="Owner Type" value={a.owner_type} />
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
{a.description && (
<div className="rounded-lg border bg-card p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
<p className="text-sm text-foreground">{a.description}</p>
</div>
)}
</div>
</div>
</div>
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
}
const a = selectedAsset;
return (
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<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" className="hidden" onClick={handleDownload} disabled={downloading}>
<Download className="h-3.5 w-3.5" />
{downloading ? "Downloading…" : "Download"}
</Button>
<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">
{/* ── Image preview ── */}
<div className="lg:col-span-3 rounded-lg border overflow-hidden">
<FileZoomViewer
src={streamUrl}
mimeType={a.mime_type}
fileName={a.display_name ?? a.original_name}
loading={previewLoading}
/>
</div>
{/* ── Metadata panel ── */}
<div className="lg:col-span-2 space-y-4">
<div className="rounded-lg border bg-card p-4 space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
<MetaRow label="Original Name" value={a.original_name} />
<MetaRow label="Extension" value={a.extension} />
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
<MetaRow label="Resolution" value={a.resolution} />
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
<MetaRow label="Provider" value={a.storage_provider} />
<MetaRow label="Bucket" value={a.storage_bucket} />
<MetaRow label="Key" value={a.storage_key} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
<div className="flex items-center gap-2 py-1">
{a.is_public
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
}
</div>
<MetaRow label="Access Level" value={a.access_level} />
<MetaRow label="Owner Type" value={a.owner_type} />
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
{a.description && (
<div className="rounded-lg border bg-card p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
<p className="text-sm text-foreground">{a.description}</p>
</div>
)}
</div>
</div>
</div>
);
}
@@ -10,6 +10,7 @@ import { useLibrary } from "@/contexts/AdminLibraryContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import AttachLessonsDialog from "../../../components/library/AttachLessonsDialog";
import AttachCoursesDialog from "../../../components/library/AttachCoursesDialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
@@ -20,11 +21,12 @@ export default function ViewLibraryUnit() {
const { unitId } = useParams();
const {
fetchUnit, unit, loading,
attachLessonsToUnit, detachLessonFromUnit, reorderUnitLessons,
attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons,
} = useLibrary();
const [initializing, setInitializing] = useState(true);
const [attachOpen, setAttachOpen] = useState(false);
const [attachCourseOpen, setAttachCourseOpen] = useState(false);
const [descExpanded, setDescExpanded] = useState(false);
useEffect(() => {
@@ -57,6 +59,16 @@ export default function ViewLibraryUnit() {
fetchUnit(unitId);
};
const handleDetachCourse = async (courseId) => {
await detachUnitFromCourse(unitId, courseId);
fetchUnit(unitId);
};
const handleAttachCourses = async (courseIds) => {
await attachUnitToCourses(unitId, courseIds);
fetchUnit(unitId);
};
const handleAttach = async (lessonIds) => {
await attachLessonsToUnit(unitId, lessonIds);
fetchUnit(unitId);
@@ -101,6 +113,21 @@ export default function ViewLibraryUnit() {
)}
</div>
<div className="flex gap-2 shrink-0">
<Button size="sm" variant="outline" onClick={() => setAttachCourseOpen(true)}>
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach to Course
</Button>
{courses.map((c) => (
<Button
key={c.course_id}
size="sm"
variant="destructive"
onClick={() => handleDetachCourse(c.course_id)}
disabled={loading}
title={`Detach from ${c.title} (unit stays in library)`}
>
<Unlink className="h-3.5 w-3.5 mr-1.5" /> Detach from Course
</Button>
))}
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/units/${unitId}/edit`)}>
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
</Button>
@@ -249,6 +276,14 @@ export default function ViewLibraryUnit() {
onAttach={handleAttach}
loading={loading}
/>
<AttachCoursesDialog
open={attachCourseOpen}
onOpenChange={setAttachCourseOpen}
attachedCourseIds={courses.map((c) => c.course_id)}
onAttach={handleAttachCourses}
loading={loading}
/>
</section>
);
}