change things
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user