666 lines
22 KiB
TypeScript
666 lines
22 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import type { Library, PageSectionConfig } from "@/api/types";
|
|
import {
|
|
useAdminSections,
|
|
useBulkCreateSections,
|
|
useCreateSection,
|
|
useUpdateSection,
|
|
useDeleteSection,
|
|
useReorderSections,
|
|
useRestoreDefaultSections,
|
|
} from "@/hooks/queries/sections";
|
|
import RecipeGalleryModal from "@/components/RecipeGallery/RecipeGalleryModal";
|
|
import RecipeConfigDrawer from "@/components/RecipeGallery/RecipeConfigDrawer";
|
|
import type { AddPayload } from "@/components/RecipeGallery/RecipeConfigDrawer";
|
|
import type { RecipeDefinition, GalleryPreset } from "@/lib/recipes";
|
|
import { fetchRecipeCatalog } from "@/lib/recipes";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useAdminCollections, useImportTraktCollection } from "@/hooks/queries/admin/collections";
|
|
import { useAdminLibraries } from "@/hooks/queries/admin/libraries";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Plus, RotateCcw } from "lucide-react";
|
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|
import { toast } from "sonner";
|
|
import { buildSectionReorderEntries } from "./adminSectionOrder";
|
|
import SectionEditorDrawer from "@/components/sections/SectionEditorDrawer";
|
|
import {
|
|
SectionDragOverlay,
|
|
SortableSectionTableRow,
|
|
type EditableSectionViewModel,
|
|
} from "@/components/sections/EditableSectionRows";
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
PointerSensor,
|
|
KeyboardSensor,
|
|
closestCenter,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import type { DragStartEvent, DragEndEvent } from "@dnd-kit/core";
|
|
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
|
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
|
import type { LibraryCollection } from "@/api/types";
|
|
|
|
function formatCollectionOptionLabel(
|
|
collection: { title: string; library_id: number },
|
|
libraries: Library[],
|
|
): string {
|
|
const library = libraries.find((entry) => entry.id === collection.library_id);
|
|
return library ? `${collection.title} (${library.name})` : collection.title;
|
|
}
|
|
|
|
function LibraryPicker({
|
|
libraries,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
libraries: Library[];
|
|
value: number | null;
|
|
onChange: (libraryId: number) => void;
|
|
}) {
|
|
return (
|
|
<Select
|
|
value={value ? String(value) : undefined}
|
|
onValueChange={(next) => onChange(Number(next))}
|
|
>
|
|
<SelectTrigger className="w-[220px]">
|
|
<SelectValue placeholder="Choose library" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{libraries.map((library) => (
|
|
<SelectItem key={library.id} value={String(library.id)}>
|
|
{library.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
function toEditableSection(section: PageSectionConfig): EditableSectionViewModel {
|
|
return {
|
|
id: section.id,
|
|
title: section.title,
|
|
sectionType: section.section_type,
|
|
itemLimit: section.item_limit,
|
|
featured: section.featured,
|
|
enabled: section.enabled,
|
|
isCustom: true,
|
|
config: section.config,
|
|
};
|
|
}
|
|
|
|
interface TraktPublicRecipeConfig {
|
|
preset: "trending" | "popular";
|
|
mediaType: "movie" | "tv";
|
|
}
|
|
|
|
function getTraktRecipeConfig(config: Record<string, unknown>): TraktPublicRecipeConfig | null {
|
|
if (config.source_provider !== "trakt") return null;
|
|
const preset = config.source_preset;
|
|
const mediaType = config.media_type;
|
|
if (
|
|
(preset !== "trending" && preset !== "popular") ||
|
|
(mediaType !== "movie" && mediaType !== "tv")
|
|
) {
|
|
return null;
|
|
}
|
|
return { preset, mediaType };
|
|
}
|
|
|
|
function isMatchingTraktCollection(
|
|
collection: LibraryCollection,
|
|
preset: string,
|
|
mediaType: string,
|
|
libraryID: number,
|
|
) {
|
|
return (
|
|
collection.management_mode === "section" &&
|
|
collection.management_key ===
|
|
buildTraktSectionManagedCollectionKey(preset, mediaType, libraryID) &&
|
|
collection.collection_type === "trakt" &&
|
|
collection.library_id === libraryID &&
|
|
collection.source_config?.preset === preset &&
|
|
collection.source_config?.media_type === mediaType
|
|
);
|
|
}
|
|
|
|
function buildTraktSectionManagedCollectionKey(
|
|
preset: string,
|
|
mediaType: string,
|
|
libraryID: number,
|
|
) {
|
|
return `trakt:${preset}:${mediaType}:library:${libraryID}`;
|
|
}
|
|
|
|
function findDefaultTraktLibrary(libraries: Library[], mediaType: string): number | null {
|
|
const wantedType = mediaType === "tv" ? "series" : "movies";
|
|
return libraries.find((library) => library.type === wantedType)?.id ?? libraries[0]?.id ?? null;
|
|
}
|
|
|
|
export default function AdminSections() {
|
|
const [scope, setScope] = useState("home");
|
|
const { data: librariesData } = useAdminLibraries();
|
|
const librariesList = useMemo(() => librariesData ?? [], [librariesData]);
|
|
const [selectedLibraryId, setSelectedLibraryId] = useState<number | null>(null);
|
|
const activeLibraryId = scope === "library" ? selectedLibraryId : null;
|
|
const { data, isLoading } = useAdminSections(scope, activeLibraryId ?? undefined);
|
|
const { data: collectionsData = [] } = useAdminCollections();
|
|
const { data: recipeCatalog } = useQuery({
|
|
queryKey: ["recipe-catalog"],
|
|
queryFn: fetchRecipeCatalog,
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
const collectionLabels = new Map(
|
|
collectionsData.map((collection) => [
|
|
collection.id,
|
|
formatCollectionOptionLabel(collection, librariesList),
|
|
]),
|
|
);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingSection, setEditingSection] = useState<PageSectionConfig | null>(null);
|
|
const [orderedSections, setOrderedSections] = useState<PageSectionConfig[]>([]);
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
const [confirmDeleteSection, setConfirmDeleteSection] = useState<PageSectionConfig | null>(null);
|
|
const deleteMutation = useDeleteSection();
|
|
const reorderMutation = useReorderSections();
|
|
const restoreDefaultsMutation = useRestoreDefaultSections();
|
|
const [confirmRestoreOpen, setConfirmRestoreOpen] = useState(false);
|
|
const [resetProfiles, setResetProfiles] = useState(false);
|
|
const [galleryOpen, setGalleryOpen] = useState(false);
|
|
const [pickedRecipe, setPickedRecipe] = useState<{
|
|
def: RecipeDefinition;
|
|
preset: GalleryPreset;
|
|
} | null>(null);
|
|
const createFromGalleryMutation = useCreateSection();
|
|
const importTraktMutation = useImportTraktCollection();
|
|
const createMutation = useCreateSection();
|
|
const bulkCreateMutation = useBulkCreateSections();
|
|
const updateMutation = useUpdateSection();
|
|
|
|
const sections = useMemo(() => data?.sections ?? [], [data?.sections]);
|
|
const isHomeScope = scope === "home";
|
|
const canManageLibrarySections = librariesList.length > 0 && selectedLibraryId !== null;
|
|
const canDrag = !reorderMutation.isPending && (isHomeScope || canManageLibrarySections);
|
|
|
|
useEffect(() => {
|
|
if (librariesList.length === 0) {
|
|
setSelectedLibraryId(null);
|
|
return;
|
|
}
|
|
|
|
setSelectedLibraryId((current) => {
|
|
if (current && librariesList.some((library) => library.id === current)) {
|
|
return current;
|
|
}
|
|
return librariesList[0]?.id ?? null;
|
|
});
|
|
}, [librariesList]);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
);
|
|
|
|
useEffect(() => {
|
|
setOrderedSections(sections);
|
|
}, [sections]);
|
|
|
|
function handleDelete(section: PageSectionConfig) {
|
|
setConfirmDeleteSection(section);
|
|
}
|
|
|
|
function handleEdit(section: PageSectionConfig) {
|
|
setEditingSection(section);
|
|
setDialogOpen(true);
|
|
}
|
|
|
|
function handleDragStart(event: DragStartEvent) {
|
|
setActiveId(event.active.id as string);
|
|
}
|
|
|
|
function handleDragEnd(event: DragEndEvent) {
|
|
setActiveId(null);
|
|
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const oldIndex = orderedSections.findIndex((s) => s.id === active.id);
|
|
const newIndex = orderedSections.findIndex((s) => s.id === over.id);
|
|
if (oldIndex === -1 || newIndex === -1) return;
|
|
|
|
const nextSections = arrayMove(orderedSections, oldIndex, newIndex);
|
|
setOrderedSections(nextSections);
|
|
reorderMutation.mutate(buildSectionReorderEntries(nextSections), {
|
|
onError: () => {
|
|
setOrderedSections(sections);
|
|
},
|
|
});
|
|
}
|
|
|
|
function handleDragCancel() {
|
|
setActiveId(null);
|
|
}
|
|
|
|
const activeSection = activeId ? (orderedSections.find((s) => s.id === activeId) ?? null) : null;
|
|
|
|
function normalizeLibraryIDs(ids: number[] | undefined): number[] {
|
|
if (!ids || ids.length === 0) return [];
|
|
return Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
|
}
|
|
|
|
async function ensureTraktSectionCollection(
|
|
payload: AddPayload,
|
|
traktRecipe: TraktPublicRecipeConfig,
|
|
libraryID: number,
|
|
): Promise<LibraryCollection> {
|
|
const existing = collectionsData.find((collection) =>
|
|
isMatchingTraktCollection(collection, traktRecipe.preset, traktRecipe.mediaType, libraryID),
|
|
);
|
|
if (existing) return existing;
|
|
|
|
const managementKey = buildTraktSectionManagedCollectionKey(
|
|
traktRecipe.preset,
|
|
traktRecipe.mediaType,
|
|
libraryID,
|
|
);
|
|
const imported = await importTraktMutation.mutateAsync({
|
|
body: {
|
|
library_id: libraryID,
|
|
title: payload.title,
|
|
description: "",
|
|
preset: traktRecipe.preset,
|
|
media_type: traktRecipe.mediaType,
|
|
limit: payload.item_limit,
|
|
featured: payload.featured,
|
|
management_mode: "section",
|
|
management_source: "recipe_gallery",
|
|
management_key: managementKey,
|
|
},
|
|
});
|
|
return imported.collection;
|
|
}
|
|
|
|
async function createBulkSectionsFromGallery(
|
|
payload: AddPayload,
|
|
libraryIDs: number[],
|
|
): Promise<void> {
|
|
if (libraryIDs.length === 0) {
|
|
throw new Error("Choose at least one library before applying this section");
|
|
}
|
|
|
|
const config = payload.config;
|
|
const traktRecipe = getTraktRecipeConfig(config);
|
|
const selectedCollectionID =
|
|
typeof config.library_collection_id === "string" ? config.library_collection_id.trim() : "";
|
|
if (traktRecipe && selectedCollectionID === "") {
|
|
for (const libraryID of libraryIDs) {
|
|
const collection = await ensureTraktSectionCollection(payload, traktRecipe, libraryID);
|
|
await createMutation.mutateAsync({
|
|
scope: "library",
|
|
library_id: libraryID,
|
|
section_type: payload.section_type,
|
|
title: payload.title,
|
|
item_limit: payload.item_limit,
|
|
featured: payload.featured,
|
|
enabled: payload.enabled,
|
|
config: { ...config, library_collection_id: collection.id },
|
|
});
|
|
}
|
|
toast.success(`Created ${libraryIDs.length} section${libraryIDs.length === 1 ? "" : "s"}`);
|
|
return;
|
|
}
|
|
|
|
const result = await bulkCreateMutation.mutateAsync({
|
|
scope: "library",
|
|
library_ids: libraryIDs,
|
|
section_type: payload.section_type,
|
|
title: payload.title,
|
|
item_limit: payload.item_limit,
|
|
featured: payload.featured,
|
|
enabled: payload.enabled,
|
|
config,
|
|
});
|
|
toast.success(`Created ${result.created} section${result.created === 1 ? "" : "s"}`);
|
|
}
|
|
|
|
async function createSectionFromGallery(payload: AddPayload) {
|
|
const bulkLibraryIDs = normalizeLibraryIDs(payload.library_ids);
|
|
if (payload.apply_to_all_libraries || bulkLibraryIDs.length > 0) {
|
|
await createBulkSectionsFromGallery(payload, bulkLibraryIDs);
|
|
return;
|
|
}
|
|
|
|
let config = payload.config;
|
|
const traktRecipe = getTraktRecipeConfig(config);
|
|
const selectedCollectionID =
|
|
typeof config.library_collection_id === "string" ? config.library_collection_id.trim() : "";
|
|
if (traktRecipe && selectedCollectionID === "") {
|
|
const targetLibraryID =
|
|
activeLibraryId ?? findDefaultTraktLibrary(librariesList, traktRecipe.mediaType);
|
|
if (!targetLibraryID) {
|
|
throw new Error("Choose a library before adding this Trakt section");
|
|
}
|
|
const collection = await ensureTraktSectionCollection(payload, traktRecipe, targetLibraryID);
|
|
config = { ...config, library_collection_id: collection.id };
|
|
}
|
|
|
|
const data: Partial<PageSectionConfig> = {
|
|
scope,
|
|
...(scope === "library" && activeLibraryId != null ? { library_id: activeLibraryId } : {}),
|
|
section_type: payload.section_type,
|
|
title: payload.title,
|
|
item_limit: payload.item_limit,
|
|
featured: payload.featured,
|
|
enabled: payload.enabled,
|
|
config,
|
|
};
|
|
await createFromGalleryMutation.mutateAsync(data);
|
|
}
|
|
|
|
if (isLoading) return <div className="p-4">Loading sections...</div>;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<ConfirmDialog
|
|
open={confirmDeleteSection !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setConfirmDeleteSection(null);
|
|
}}
|
|
title="Delete section"
|
|
description={`Delete section "${confirmDeleteSection?.title}"? This action cannot be undone.`}
|
|
confirmLabel="Delete"
|
|
variant="destructive"
|
|
onConfirm={() => {
|
|
if (confirmDeleteSection) deleteMutation.mutate(confirmDeleteSection.id);
|
|
setConfirmDeleteSection(null);
|
|
}}
|
|
/>
|
|
<Dialog
|
|
open={confirmRestoreOpen}
|
|
onOpenChange={(open) => {
|
|
setConfirmRestoreOpen(open);
|
|
if (!open) setResetProfiles(false);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Restore Default Sections</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<p className="text-muted-foreground text-sm">
|
|
This will replace all {scope === "home" ? "home" : "library"} sections with the
|
|
defaults. Any custom sections will be removed.
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
id="resetProfiles"
|
|
size="sm"
|
|
checked={resetProfiles}
|
|
onCheckedChange={(checked) => setResetProfiles(checked === true)}
|
|
/>
|
|
<Label htmlFor="resetProfiles" className="text-sm font-normal">
|
|
Also reset all user customizations for this scope
|
|
</Label>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setConfirmRestoreOpen(false);
|
|
setResetProfiles(false);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={restoreDefaultsMutation.isPending}
|
|
onClick={() => {
|
|
restoreDefaultsMutation.mutate(
|
|
{
|
|
scope,
|
|
...(activeLibraryId != null ? { library_id: activeLibraryId } : {}),
|
|
reset_profiles: resetProfiles,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
toast.success("Sections restored to defaults");
|
|
setConfirmRestoreOpen(false);
|
|
setResetProfiles(false);
|
|
},
|
|
onError: () => {
|
|
toast.error("Failed to restore defaults");
|
|
},
|
|
},
|
|
);
|
|
}}
|
|
>
|
|
Restore Defaults
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<div className="page-header gap-5">
|
|
<div className="space-y-3">
|
|
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Sections</h1>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={
|
|
(!isHomeScope && !canManageLibrarySections) || restoreDefaultsMutation.isPending
|
|
}
|
|
onClick={() => setConfirmRestoreOpen(true)}
|
|
>
|
|
<RotateCcw className="mr-1 h-4 w-4" /> Restore Defaults
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={!isHomeScope && !canManageLibrarySections}
|
|
onClick={() => setGalleryOpen(true)}
|
|
>
|
|
<Plus className="mr-1 h-4 w-4" /> Add from Gallery
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={!isHomeScope && !canManageLibrarySections}
|
|
onClick={() => {
|
|
setEditingSection(null);
|
|
setDialogOpen(true);
|
|
}}
|
|
>
|
|
<Plus className="mr-1 h-4 w-4" /> Add Section
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs value={scope} onValueChange={setScope}>
|
|
<TabsList>
|
|
<TabsTrigger value="home">Home</TabsTrigger>
|
|
<TabsTrigger value="library">Library</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
{!isHomeScope && (
|
|
<div className="flex items-center gap-3">
|
|
<Label>Library</Label>
|
|
{librariesList.length > 0 ? (
|
|
<LibraryPicker
|
|
libraries={librariesList}
|
|
value={selectedLibraryId}
|
|
onChange={setSelectedLibraryId}
|
|
/>
|
|
) : (
|
|
<p className="text-muted-foreground text-sm">
|
|
Create a library before configuring Library sections.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{canDrag && (
|
|
<p className="text-muted-foreground text-sm">
|
|
Drag and drop sections to change their order.
|
|
</p>
|
|
)}
|
|
|
|
<DndContext
|
|
sensors={canDrag ? sensors : []}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
onDragCancel={handleDragCancel}
|
|
>
|
|
<div className="surface-panel overflow-x-auto rounded-2xl border-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-8"></TableHead>
|
|
<TableHead>Title</TableHead>
|
|
<TableHead>Type</TableHead>
|
|
<TableHead className="w-20">Items</TableHead>
|
|
<TableHead className="w-20">Featured</TableHead>
|
|
<TableHead className="w-20">Enabled</TableHead>
|
|
<TableHead className="w-24">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<SortableContext
|
|
items={orderedSections.map((s) => s.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<TableBody>
|
|
{orderedSections.map((section) => (
|
|
<SortableSectionTableRow
|
|
key={section.id}
|
|
section={toEditableSection(section)}
|
|
canReorder={canDrag}
|
|
libraries={librariesList}
|
|
collectionLabels={collectionLabels}
|
|
catalog={recipeCatalog}
|
|
onEdit={() => handleEdit(section)}
|
|
onDelete={() => handleDelete(section)}
|
|
/>
|
|
))}
|
|
{orderedSections.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-muted-foreground py-8 text-center">
|
|
No sections configured for {scope} scope.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</SortableContext>
|
|
</Table>
|
|
</div>
|
|
<DragOverlay>
|
|
{activeSection ? (
|
|
<SectionDragOverlay
|
|
section={toEditableSection(activeSection)}
|
|
catalog={recipeCatalog}
|
|
/>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
|
|
<SectionEditorDrawer
|
|
mode="admin"
|
|
open={dialogOpen}
|
|
onOpenChange={(open) => {
|
|
setDialogOpen(open);
|
|
if (!open) setEditingSection(null);
|
|
}}
|
|
section={editingSection}
|
|
scope={scope}
|
|
currentLibraryId={activeLibraryId}
|
|
libraries={librariesList}
|
|
recipeCatalog={recipeCatalog}
|
|
isSubmitting={
|
|
createMutation.isPending || updateMutation.isPending || bulkCreateMutation.isPending
|
|
}
|
|
onSave={(section) => {
|
|
if (section.id) {
|
|
updateMutation.mutate(
|
|
{ id: section.id, ...section },
|
|
{
|
|
onSuccess: () => {
|
|
setDialogOpen(false);
|
|
setEditingSection(null);
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error instanceof Error ? error.message : "Failed to update section");
|
|
},
|
|
},
|
|
);
|
|
} else {
|
|
createMutation.mutate(section, {
|
|
onSuccess: () => {
|
|
setDialogOpen(false);
|
|
setEditingSection(null);
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error instanceof Error ? error.message : "Failed to create section");
|
|
},
|
|
});
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<RecipeGalleryModal
|
|
open={galleryOpen}
|
|
onClose={() => setGalleryOpen(false)}
|
|
onPick={(def, preset) => {
|
|
setGalleryOpen(false);
|
|
setPickedRecipe({ def, preset });
|
|
}}
|
|
/>
|
|
|
|
{pickedRecipe && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
|
<RecipeConfigDrawer
|
|
def={pickedRecipe.def}
|
|
preset={pickedRecipe.preset}
|
|
onCancel={() => setPickedRecipe(null)}
|
|
onBackToGallery={() => {
|
|
setPickedRecipe(null);
|
|
setGalleryOpen(true);
|
|
}}
|
|
onAdd={async (payload) => {
|
|
try {
|
|
await createSectionFromGallery(payload);
|
|
setPickedRecipe(null);
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Failed to create section");
|
|
throw error;
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|