mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
change things
This commit is contained in:
@@ -259,6 +259,27 @@ export function LibraryProvider({ children }) {
|
|||||||
[request],
|
[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(
|
const reorderUnitLessons = useCallback(
|
||||||
(unitId, lessonIds) =>
|
(unitId, lessonIds) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -477,7 +498,7 @@ export function LibraryProvider({ children }) {
|
|||||||
permanentlyDeleteUnit, permanentlyDeleteUnits,
|
permanentlyDeleteUnit, permanentlyDeleteUnits,
|
||||||
fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact,
|
fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact,
|
||||||
fetchUnitFieldValues,
|
fetchUnitFieldValues,
|
||||||
attachLessonsToUnit, detachLessonFromUnit, reorderUnitLessons,
|
attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons,
|
||||||
fetchUnitProduct, saveUnitProduct, removeUnitProduct,
|
fetchUnitProduct, saveUnitProduct, removeUnitProduct,
|
||||||
|
|
||||||
// lesson library
|
// 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
|
// modules/admin/pages/assets/ViewImageAsset.jsx
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||||
import { formatFileSize } from "@/utils/format.util";
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
|
import { downloadAsset } from "@/utils/media.util";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -15,109 +17,123 @@ import AssetPageLoader from "@/components/generic/AssetLoader";
|
|||||||
|
|
||||||
|
|
||||||
function MetaRow({ label, value }) {
|
function MetaRow({ label, value }) {
|
||||||
if (!value && value !== 0) return null;
|
if (!value && value !== 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 py-2">
|
<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-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
||||||
<span className="text-sm font-medium break-all">{String(value)}</span>
|
<span className="text-sm font-medium break-all">{String(value)}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ViewImageAsset() {
|
export default function ViewImageAsset() {
|
||||||
const { assetId } = useParams();
|
const { assetId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
|
|
||||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||||
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
if (loading) {
|
async function handleDownload() {
|
||||||
return <AssetPageLoader />;
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
await downloadAsset(selectedAsset, { scope: "admin" });
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (notFound) {
|
if (loading) {
|
||||||
return (
|
return <AssetPageLoader />;
|
||||||
<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 (notFound) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||||
|
<p>Asset not found.</p>
|
||||||
{/* ── Header ── */}
|
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
|
||||||
<div className="flex items-center gap-3">
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import AttachLessonsDialog from "../../../components/library/AttachLessonsDialog";
|
import AttachLessonsDialog from "../../../components/library/AttachLessonsDialog";
|
||||||
|
import AttachCoursesDialog from "../../../components/library/AttachCoursesDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
@@ -20,11 +21,12 @@ export default function ViewLibraryUnit() {
|
|||||||
const { unitId } = useParams();
|
const { unitId } = useParams();
|
||||||
const {
|
const {
|
||||||
fetchUnit, unit, loading,
|
fetchUnit, unit, loading,
|
||||||
attachLessonsToUnit, detachLessonFromUnit, reorderUnitLessons,
|
attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons,
|
||||||
} = useLibrary();
|
} = useLibrary();
|
||||||
|
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
const [attachOpen, setAttachOpen] = useState(false);
|
const [attachOpen, setAttachOpen] = useState(false);
|
||||||
|
const [attachCourseOpen, setAttachCourseOpen] = useState(false);
|
||||||
const [descExpanded, setDescExpanded] = useState(false);
|
const [descExpanded, setDescExpanded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,6 +59,16 @@ export default function ViewLibraryUnit() {
|
|||||||
fetchUnit(unitId);
|
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) => {
|
const handleAttach = async (lessonIds) => {
|
||||||
await attachLessonsToUnit(unitId, lessonIds);
|
await attachLessonsToUnit(unitId, lessonIds);
|
||||||
fetchUnit(unitId);
|
fetchUnit(unitId);
|
||||||
@@ -101,6 +113,21 @@ export default function ViewLibraryUnit() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 shrink-0">
|
<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`)}>
|
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/units/${unitId}/edit`)}>
|
||||||
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
|
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -249,6 +276,14 @@ export default function ViewLibraryUnit() {
|
|||||||
onAttach={handleAttach}
|
onAttach={handleAttach}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AttachCoursesDialog
|
||||||
|
open={attachCourseOpen}
|
||||||
|
onOpenChange={setAttachCourseOpen}
|
||||||
|
attachedCourseIds={courses.map((c) => c.course_id)}
|
||||||
|
onAttach={handleAttachCourses}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user