From 08a0d122ea8c09a45cd68dde9b4a99b3e167d24e Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Sun, 9 Aug 2026 13:00:42 +0800 Subject: [PATCH] change things --- src/contexts/AdminLibraryContext.jsx | 23 +- .../library/AttachCoursesDialog.jsx | 125 +++++++++++ .../admin/pages/assets/ViewImageAsset.jsx | 210 ++++++++++-------- .../pages/library/units/ViewLibraryUnit.jsx | 37 ++- 4 files changed, 296 insertions(+), 99 deletions(-) create mode 100644 src/modules/admin/components/library/AttachCoursesDialog.jsx 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 ( + + + + + Select Courses + + + Attach this unit to one or more existing courses without copying it. + + + +
+ + setQuery(e.target.value)} + /> +
+ + + {coursesLoading ? ( +
+ +
+ ) : candidates.length === 0 ? ( +

+ {query ? "No courses match your search." : "This unit is already attached to every course."} +

+ ) : ( +
+ {candidates.map((c) => ( + + ))} +
+ )} +
+ + + + + +
+
+ ); +} 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} + /> ); }