215 lines
7.5 KiB
TypeScript
215 lines
7.5 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { useTranslation } from "react-i18next";
|
||
import { Library, Loader2 } from "lucide-react";
|
||
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
|
||
import { resourcesService } from "@/services/resources.service";
|
||
import type { Resource } from "@/types";
|
||
|
||
/**
|
||
* Generic, reusable picker over `/api/resources`.
|
||
*
|
||
* Two callers wire this up today:
|
||
*
|
||
* 1. **AdminCoursePlanDetail** — for an *existing* plan, so it forwards
|
||
* the picked resources to `coursePlanService.attachResources` in the
|
||
* `onConfirm` handler and invalidates the sources query.
|
||
*
|
||
* 2. **CoursePlanWizard** — the plan does not exist yet at pick time,
|
||
* so the wizard simply pushes the picks into its draft state and
|
||
* attaches them in one batch after the plan is created.
|
||
*
|
||
* The dialog itself is purposefully agnostic: it only reports the
|
||
* selected `Resource` rows; the parent decides what to do.
|
||
*/
|
||
export function LibraryPickerDialog({
|
||
open,
|
||
onOpenChange,
|
||
alreadyLinkedIds,
|
||
onConfirm,
|
||
isPending,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
/** Resources whose ids are in this set render disabled + checked. */
|
||
alreadyLinkedIds?: Set<number>;
|
||
/** Called when the admin clicks "Attach selected". */
|
||
onConfirm: (resources: Resource[]) => void | Promise<void>;
|
||
/** Optional spinner state from the parent's mutation. */
|
||
isPending?: boolean;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const [search, setSearch] = useState("");
|
||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||
|
||
// Reset every time the dialog re-opens so a previous session's
|
||
// selection doesn't bleed into the next attach.
|
||
useEffect(() => {
|
||
if (open) {
|
||
setSelected(new Set());
|
||
setSearch("");
|
||
setTypeFilter("all");
|
||
}
|
||
}, [open]);
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ["library-resources", { search, type: typeFilter }],
|
||
queryFn: () =>
|
||
resourcesService.list({
|
||
search: search || undefined,
|
||
resource_type: typeFilter === "all" ? undefined : typeFilter,
|
||
review_status: "approved",
|
||
}),
|
||
enabled: open,
|
||
});
|
||
const resources: Resource[] = data?.items ?? [];
|
||
|
||
const toggle = (id: number) => {
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const handleConfirm = async () => {
|
||
const picked = resources.filter((r) => selected.has(r.id));
|
||
if (!picked.length) return;
|
||
await onConfirm(picked);
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-hidden flex flex-col">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Library className="h-5 w-5 text-primary" />
|
||
{t("coursePlan.sources.libraryTitle")}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
{t("coursePlan.sources.libraryDescription")}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="flex gap-2 pt-2">
|
||
<Input
|
||
placeholder={t("coursePlan.sources.librarySearchPlaceholder")}
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="flex-1"
|
||
/>
|
||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||
<SelectTrigger className="w-[140px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">
|
||
{t("coursePlan.sources.libraryTypeAll")}
|
||
</SelectItem>
|
||
<SelectItem value="pdf">PDF</SelectItem>
|
||
<SelectItem value="document">DOCX</SelectItem>
|
||
<SelectItem value="link">URL</SelectItem>
|
||
<SelectItem value="article">Article</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-1">
|
||
{isLoading && <Skeleton className="h-32 w-full" />}
|
||
{!isLoading && resources.length === 0 && (
|
||
<p className="text-center text-sm text-muted-foreground py-8">
|
||
{t("coursePlan.sources.libraryEmpty")}
|
||
</p>
|
||
)}
|
||
{!isLoading &&
|
||
resources.map((r) => {
|
||
const isLinked = alreadyLinkedIds?.has(r.id) ?? false;
|
||
const isSelected = selected.has(r.id);
|
||
return (
|
||
<label
|
||
key={r.id}
|
||
className={`flex items-start gap-3 rounded-md border p-2 text-sm transition-colors ${
|
||
isLinked
|
||
? "opacity-60 cursor-not-allowed bg-muted/40"
|
||
: "cursor-pointer hover:bg-accent/50"
|
||
}`}
|
||
>
|
||
<Checkbox
|
||
checked={isLinked || isSelected}
|
||
disabled={isLinked}
|
||
onCheckedChange={() => !isLinked && toggle(r.id)}
|
||
className="mt-0.5"
|
||
/>
|
||
<div className="flex-1 min-w-0 space-y-0.5">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-medium truncate">{r.name}</span>
|
||
<Badge
|
||
variant="outline"
|
||
className="text-[10px] capitalize"
|
||
>
|
||
{r.resource_type || r.type || "resource"}
|
||
</Badge>
|
||
{isLinked && (
|
||
<Badge variant="secondary" className="text-[10px]">
|
||
{t("coursePlan.sources.libraryAlreadyLinked")}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
{(r.subject_name || (r.topic_names ?? []).length > 0) && (
|
||
<p className="text-xs text-muted-foreground truncate">
|
||
{[r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])]
|
||
.filter(Boolean)
|
||
.join(" › ")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<DialogFooter className="border-t pt-3">
|
||
<span className="mr-auto text-xs text-muted-foreground self-center">
|
||
{t("coursePlan.sources.librarySelectedCount", {
|
||
count: selected.size,
|
||
})}
|
||
</span>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||
{t("common.cancel", "Cancel")}
|
||
</Button>
|
||
<Button
|
||
onClick={handleConfirm}
|
||
disabled={!selected.size || isPending}
|
||
>
|
||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{t("coursePlan.sources.libraryAttach")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|