diff --git a/src/contexts/AdminLibraryContext.jsx b/src/contexts/AdminLibraryContext.jsx
index 2e9e097..6a1c8f9 100644
--- a/src/contexts/AdminLibraryContext.jsx
+++ b/src/contexts/AdminLibraryContext.jsx
@@ -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
diff --git a/src/modules/admin/components/library/AttachCoursesDialog.jsx b/src/modules/admin/components/library/AttachCoursesDialog.jsx
new file mode 100644
index 0000000..94e1ed2
--- /dev/null
+++ b/src/modules/admin/components/library/AttachCoursesDialog.jsx
@@ -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 (
+
+ );
+}
diff --git a/src/modules/admin/pages/assets/ViewImageAsset.jsx b/src/modules/admin/pages/assets/ViewImageAsset.jsx
index b9e4ef1..d769e97 100644
--- a/src/modules/admin/pages/assets/ViewImageAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewImageAsset.jsx
@@ -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 (
-
- {label}
- {String(value)}
-
- );
+ if (!value && value !== 0) return null;
+ return (
+
+ {label}
+ {String(value)}
+
+ );
}
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 ;
+ async function handleDownload() {
+ setDownloading(true);
+ try {
+ await downloadAsset(selectedAsset, { scope: "admin" });
+ } finally {
+ setDownloading(false);
}
+ }
- if (notFound) {
- return (
-
-
Asset not found.
-
-
- );
- }
-
- const a = selectedAsset;
+ if (loading) {
+ return ;
+ }
+ if (notFound) {
return (
-
-
- {/* ── Header ── */}
-
-
-
-
{a.display_name ?? a.original_name}
-
{a.mime_type}
-
-
-
-
-
-
- {/* ── Image preview ── */}
-
-
-
-
- {/* ── Metadata panel ── */}
-
-
-
File Info
-
-
-
-
-
-
-
Storage
-
-
-
-
-
Access
-
- {a.is_public
- ? <>Public>
- : <>Private>
- }
-
-
-
-
-
-
Timestamps
-
-
-
-
-
-
- {a.description && (
-
-
Description
-
{a.description}
-
- )}
-
-
-
+
+
Asset not found.
+
+
);
-}
\ No newline at end of file
+ }
+
+ const a = selectedAsset;
+
+ return (
+
+
+ {/* ── Header ── */}
+
+
+
+
{a.display_name ?? a.original_name}
+
{a.mime_type}
+
+
+
+
+
+
+
+ {/* ── Image preview ── */}
+
+
+
+
+ {/* ── Metadata panel ── */}
+
+
+
File Info
+
+
+
+
+
+
+
Storage
+
+
+
+
+
Access
+
+ {a.is_public
+ ? <>Public>
+ : <>Private>
+ }
+
+
+
+
+
+
Timestamps
+
+
+
+
+
+
+ {a.description && (
+
+
Description
+
{a.description}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/library/units/ViewLibraryUnit.jsx b/src/modules/admin/pages/library/units/ViewLibraryUnit.jsx
index ee26c9b..61e9783 100644
--- a/src/modules/admin/pages/library/units/ViewLibraryUnit.jsx
+++ b/src/modules/admin/pages/library/units/ViewLibraryUnit.jsx
@@ -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() {
)}
+
+ {courses.map((c) => (
+
+ ))}
@@ -249,6 +276,14 @@ export default function ViewLibraryUnit() {
onAttach={handleAttach}
loading={loading}
/>
+
+
c.course_id)}
+ onAttach={handleAttachCourses}
+ loading={loading}
+ />
);
}