Files
EpicNext-Cms/src/components/admin/studio/organize-imports-dialog.tsx
T
openhands e47bee16bb
CI / check (push) Successful in 2m31s
CI / deploy (push) Successful in 1m23s
CI / publish-container (push) Successful in 57s
feat(studio): add visual layout previews, automated unit tests and polish organize imports
2026-09-11 23:41:23 +02:00

1042 lines
32 KiB
TypeScript

"use client";
import {
Eye,
Folder,
FolderTree,
Home,
Loader2,
Plus,
RefreshCw,
Search,
Wand2,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { organizeImportFurni } from "@/actions/catalog";
import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
CATALOG_LAYOUTS,
type CatalogLayout,
isValidCatalogLayout,
LAYOUT_DESCRIPTIONS,
} from "@/lib/catalog-layouts";
// import { CATALOG_LAYOUTS } from "@/lib/catalog-layouts";
import { getLocalIconUrl } from "@/lib/furni/classname";
import { cn } from "@/lib/utils";
interface OrganizeGroupOverride {
caption?: string;
icon?: number;
layout?: CatalogLayout;
}
interface RootPage {
id: number;
caption: string;
}
interface OrganizeImportGroup {
name: string;
icon: number;
layout: CatalogLayout | null;
total: number;
moved: number;
added: number;
thumbs: {
itemName: string;
publicName: string;
spriteId: number;
alreadyPlaced: boolean;
sourcePageCaption: string | null;
}[];
items: {
itemId: number;
catalogItemId: number | null;
alreadyPlaced: boolean;
}[];
}
interface OrganizeImportsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function OrganizeImportsDialog({
open,
onOpenChange,
}: OrganizeImportsDialogProps) {
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
const router = useRouter();
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
const [groups, setGroups] = useState<OrganizeImportGroup[]>([]);
const [rootPages, setRootPages] = useState<RootPage[]>([]);
const [importRootPageId, setImportRootPageId] = useState<number | null>(null);
const [importRootCaption, setImportRootCaption] = useState<string | null>(
null,
);
const [parentId, setParentId] = useState(-1);
const [moveMode, setMoveMode] = useState(false);
const [destinationPageId, setDestinationPageId] = useState<number | null>(
null,
);
const [destinationCaption, setDestinationCaption] = useState<string | null>(
null,
);
const [destQuery, setDestQuery] = useState("");
const [destDebounced, setDestDebounced] = useState("");
const [destResults, setDestResults] = useState<RootPage[]>([]);
const [destSearchLoading, setDestSearchLoading] = useState(false);
const [approved, setApproved] = useState<Record<string, boolean>>({});
const [overrides, setOverrides] = useState<
Record<string, OrganizeGroupOverride>
>({});
const [previewGroup, setPreviewGroup] = useState<OrganizeImportGroup | null>(
null,
);
const [isCreating, setIsCreating] = useState(false);
const [freeMode, setFreeMode] = useState(true);
const [credits, setCredits] = useState(0);
const [points, setPoints] = useState(0);
const [pointsType, setPointsType] = useState(0);
const [groupSearchQuery, setGroupSearchQuery] = useState("");
const [batchLayout, setBatchLayout] = useState<CatalogLayout>("default_3x3");
const [batchPrefix, setBatchPrefix] = useState("");
const load = useCallback(async () => {
setLoading(true);
setLoadError(false);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 45000);
try {
const [data, tree] = await Promise.all([
fetch("/api/admin/import/organize", { signal: controller.signal }).then(
(r) => r.json(),
),
fetch("/api/admin/catalog/tree?parentId=-1", {
signal: controller.signal,
}).then((r) => r.json()),
]);
setGroups(Array.isArray(data?.groups) ? data.groups : []);
setImportRootPageId(data?.importRootPageId ?? null);
setImportRootCaption(data?.importRootCaption ?? null);
const pages = Array.isArray(tree?.pages) ? tree.pages : [];
setRootPages(
pages.map((p: { id: number; caption: string }) => ({
id: Number(p.id),
caption: String(p.caption ?? p.id),
})),
);
} catch (error) {
console.error("[OrganizeImports] load failed:", error);
setLoadError(true);
setGroups([]);
} finally {
clearTimeout(timeout);
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
setGroups([]);
setApproved({});
setOverrides({});
setParentId(-1);
setMoveMode(false);
setDestinationPageId(null);
setDestinationCaption(null);
setDestQuery("");
setDestDebounced("");
setDestResults([]);
setFreeMode(true);
setCredits(0);
setPoints(0);
setPointsType(0);
void load();
}, [open, load]);
useEffect(() => {
const t = setTimeout(() => setDestDebounced(destQuery.trim()), 300);
return () => clearTimeout(t);
}, [destQuery]);
useEffect(() => {
if (!moveMode || destDebounced.length < 2) {
if (!moveMode) setDestResults([]);
return;
}
setDestSearchLoading(true);
const ac = new AbortController();
fetch(
`/api/admin/catalog/tree?search=${encodeURIComponent(destDebounced)}`,
{
signal: ac.signal,
},
)
.then((r) => r.json())
.then((data) => {
const pages = Array.isArray(data?.pages) ? data.pages : [];
setDestResults(
pages
.filter((p: { id: number }) => Number(p.id) > 0)
.map((p: { id: number; caption: string }) => ({
id: Number(p.id),
caption: String(p.caption ?? p.id),
})),
);
})
.catch((error) =>
console.error("[OrganizeImports] page search failed:", error),
)
.finally(() => setDestSearchLoading(false));
return () => ac.abort();
}, [moveMode, destDebounced]);
const allApproved =
groups.length > 0 && groups.every((g) => approved[g.name]);
const totalSelected = useMemo(
() =>
groups.reduce(
(sum, g) => sum + (approved[g.name] ? g.items.length : 0),
0,
),
[groups, approved],
);
const parentOptions = useMemo(() => {
const options: RootPage[] = [];
for (const p of rootPages) {
if (importRootPageId != null && p.id === importRootPageId) continue;
options.push(p);
}
return options;
}, [rootPages, importRootPageId]);
const toggleAll = () => {
const next = !allApproved;
setApproved(Object.fromEntries(groups.map((g) => [g.name, next])));
};
const updateGroup = (name: string, patch: OrganizeGroupOverride) => {
setOverrides((prev) => ({ ...prev, [name]: { ...prev[name], ...patch } }));
};
const applyBatchLayout = (layout: CatalogLayout) => {
setOverrides((prev) => {
const next = { ...prev };
for (const g of groups) {
if (approved[g.name] !== false) {
next[g.name] = { ...next[g.name], layout };
}
}
return next;
});
};
const applyBatchPrefix = () => {
if (!batchPrefix.trim()) return;
setOverrides((prev) => {
const next = { ...prev };
for (const g of groups) {
if (approved[g.name] !== false) {
const currentCaption = next[g.name]?.caption ?? g.name;
if (!currentCaption.startsWith(batchPrefix)) {
next[g.name] = {
...next[g.name],
caption: `${batchPrefix}${currentCaption}`,
};
}
}
}
return next;
});
};
const filteredGroups = useMemo(() => {
const q = groupSearchQuery.trim().toLowerCase();
if (!q) return groups;
return groups.filter((g) => {
if (g.name.toLowerCase().includes(q)) return true;
const caption = overrides[g.name]?.caption?.toLowerCase();
if (caption?.includes(q)) return true;
return g.thumbs.some((t) => t.itemName.toLowerCase().includes(q));
});
}, [groups, groupSearchQuery, overrides]);
const handleCreate = async () => {
if (isCreating) return;
if (moveMode && destinationPageId == null) {
toast.error(t("chooseDestination"));
return;
}
const payload = groups
.filter((g) => approved[g.name])
.map((g) => {
const o = overrides[g.name] ?? {};
return {
caption: moveMode
? (destinationCaption ?? "Imported")
: (o.caption ?? g.name).trim(),
pageLayout: o.layout ?? g.layout ?? "default_3x3",
iconImage: o.icon ?? g.icon,
destinationPageId: moveMode ? destinationPageId : null,
items: g.items.map((item) => ({
itemId: item.itemId,
catalogItemId: item.catalogItemId,
credits: freeMode ? 0 : credits,
points: freeMode ? 0 : points,
pointsType: freeMode ? 0 : pointsType,
})),
};
});
if (payload.length === 0 || payload.some((g) => !g.caption)) {
toast.error("Every approved page needs a name");
return;
}
setIsCreating(true);
try {
const result = await organizeImportFurni({ parentId, groups: payload });
if (!result.ok) throw new Error(result.error ?? "Failed");
const created = result.data?.created ?? [];
if (moveMode) {
const moved = created.reduce((sum, c) => sum + c.moved, 0);
const added = created.reduce((sum, c) => sum + c.added, 0);
toast.success(
`Moved ${moved} offer${moved === 1 ? "" : "s"}${
added > 0 ? `, added ${added} new` : ""
} to "${destinationCaption ?? "page"}"`,
);
} else {
toast.success(
`Created ${created.length} page${created.length === 1 ? "" : "s"}`,
);
}
onOpenChange(false);
router.refresh();
} catch (error) {
console.error("[OrganizeImports] create failed:", error);
toast.error("Failed to organize imports");
} finally {
setIsCreating(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[90vh] max-h-[90vh] w-[800px] max-w-[95vw] flex-col overflow-hidden p-0">
<DialogHeader className="flex-none border-b px-4 py-3">
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
<FolderTree className="h-4 w-4" />
{t("title")}
{totalSelected > 0 && !loading && (
<Badge variant="secondary" className="text-[10px]">
{t("itemsBadge", { count: totalSelected })}
</Badge>
)}
</DialogTitle>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
{/* Price (new offers only) */}
{!loading && groups.length > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-[var(--admin-surface)] p-3 text-xs">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("price")}
</span>
<Button
type="button"
variant={freeMode ? "default" : "outline"}
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => setFreeMode(true)}
>
{t("free")}
</Button>
<Button
type="button"
variant={!freeMode ? "default" : "outline"}
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => setFreeMode(false)}
>
{t("setPrice")}
</Button>
{!freeMode && (
<>
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
{t("credits")}
<input
type="number"
className="w-16 rounded border bg-[var(--admin-canvas)] px-1.5 py-1 text-xs"
value={credits}
onChange={(e) => setCredits(Number(e.target.value))}
min={0}
/>
</label>
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
{t("points")}
<input
type="number"
className="w-16 rounded border bg-[var(--admin-canvas)] px-1.5 py-1 text-xs"
value={points}
onChange={(e) => setPoints(Number(e.target.value))}
min={0}
/>
</label>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
{t("pointsType")}
<Select
value={String(pointsType)}
onValueChange={(v) => setPointsType(Number(v))}
>
<SelectTrigger className="h-6 w-24 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0" className="text-xs">
Duckets
</SelectItem>
<SelectItem value="5" className="text-xs">
Diamonds
</SelectItem>
</SelectContent>
</Select>
</span>
</>
)}
<p className="w-full text-[10px] text-muted-foreground">
{t("newPriceHint")}
</p>
</div>
)}
{/* Mode: create new pages vs move into an existing page */}
{!loading && groups.length > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-[var(--admin-surface)] p-3 text-xs">
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
{t("mode")}
</span>
<Button
type="button"
variant={!moveMode ? "default" : "outline"}
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => {
setMoveMode(false);
setDestinationPageId(null);
setDestinationCaption(null);
}}
>
{t("modeCreate")}
</Button>
<Button
type="button"
variant={moveMode ? "default" : "outline"}
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => setMoveMode(true)}
>
{t("modeMove")}
</Button>
</div>
)}
{/* Destination page (move mode) */}
{moveMode && !loading && groups.length > 0 && (
<div className="flex flex-col gap-1 rounded-md border bg-[var(--admin-surface)] p-3 text-xs font-medium text-[var(--admin-text-muted)]">
<span className="text-[10px]">{t("destination")}</span>
{destinationPageId != null ? (
<div className="flex items-center gap-2 rounded border border-[var(--admin-accent)]/40 bg-[var(--admin-accent-subtle)] px-2 py-1.5">
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
{destinationCaption}
</span>
<Badge variant="secondary" className="h-4 text-[9px]">
{t("destinationChosen")}
</Badge>
<button
type="button"
onClick={() => {
setDestinationPageId(null);
setDestinationCaption(null);
setDestQuery("");
}}
className="text-muted-foreground hover:text-destructive"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 pl-8 text-xs"
placeholder={t("destinationSearchPlaceholder")}
value={destQuery}
onChange={(e) => setDestQuery(e.target.value)}
/>
{destSearchLoading && (
<Loader2 className="absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 animate-spin text-muted-foreground" />
)}
</div>
)}
{destinationPageId == null && destDebounced.length >= 2 && (
<div className="max-h-[160px] divide-y overflow-y-auto rounded border">
{destResults.length === 0 && !destSearchLoading ? (
<p className="py-3 text-center text-[10px] text-muted-foreground">
{t("noDestResults")}
</p>
) : (
destResults.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setDestinationPageId(p.id);
setDestinationCaption(p.caption);
}}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-accent"
>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-xs">
{p.caption}
</span>
<span className="font-mono text-[9px] text-muted-foreground">
#{p.id}
</span>
</button>
))
)}
</div>
)}
<p className="text-[10px] text-muted-foreground">
{t("destinationHint")}
</p>
</div>
)}
{/* Parent */}
{!moveMode && !loading && groups.length > 0 && (
<div className="flex flex-col gap-1 rounded-md border bg-[var(--admin-surface)] p-3 text-xs font-medium text-[var(--admin-text-muted)]">
{t("parent")}
<Select
value={String(parentId)}
onValueChange={(v) => setParentId(Number(v))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="-1" className="text-xs">
<span className="flex items-center gap-1.5">
<Home className="h-3 w-3 shrink-0" />
{t("root")}
</span>
</SelectItem>
{importRootPageId != null && (
<SelectItem
value={String(importRootPageId)}
className="text-xs"
>
<span className="flex items-center gap-1.5">
<Folder className="h-3 w-3 shrink-0" />
{importRootCaption ?? t("importedRoot")}
</span>
</SelectItem>
)}
{parentOptions.map((p) => (
<SelectItem
key={p.id}
value={String(p.id)}
className="text-xs"
>
<span className="flex items-center gap-1.5">
<Folder className="h-3 w-3 shrink-0" />
{p.caption}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Loading / empty / groups */}
{loading ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("loading")}
</div>
) : groups.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
{loadError ? t("loadError") : t("empty")}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void load()}
>
<RefreshCw className="mr-1 h-3 w-3" />
{t("refresh")}
</Button>
</div>
) : (
<>
<div className="flex flex-col gap-2 rounded-md border bg-[var(--admin-surface)] p-3 text-xs">
<div className="flex items-center justify-between gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 pl-8 text-xs"
placeholder={t("searchGroups")}
value={groupSearchQuery}
onChange={(e) => setGroupSearchQuery(e.target.value)}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2.5 text-[10px]"
onClick={toggleAll}
>
{allApproved ? t("deselectAll") : t("selectAll")}
</Button>
</div>
{/* Batch Actions Bar */}
<div className="flex flex-wrap items-center gap-2 border-t pt-2.5">
<span className="text-[10px] font-medium text-muted-foreground">
Batch:
</span>
<Select
value={batchLayout}
onValueChange={(v) => {
if (isValidCatalogLayout(v)) {
setBatchLayout(v);
applyBatchLayout(v);
}
}}
>
<SelectTrigger className="h-7 w-[140px] text-[10px]">
<SelectValue placeholder={t("batchLayout")} />
</SelectTrigger>
<SelectContent>
{CATALOG_LAYOUTS.map((l) => (
<SelectItem key={l} value={l} className="text-xs">
{l}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1">
<Input
className="h-7 w-[120px] text-[10px]"
placeholder={t("batchPrefix")}
value={batchPrefix}
onChange={(e) => setBatchPrefix(e.target.value)}
/>
<Button
type="button"
variant="secondary"
size="sm"
className="h-7 px-2 text-[10px]"
onClick={applyBatchPrefix}
>
{t("applyBatch")}
</Button>
</div>
</div>
</div>
{filteredGroups.map((group) => {
const o = overrides[group.name] ?? {};
const caption = o.caption ?? group.name;
const icon = o.icon ?? group.icon;
const layout = o.layout ?? group.layout ?? "default_3x3";
const isApproved = approved[group.name] !== false;
const moved = group.moved;
const added = group.added;
return (
<label
key={group.name}
className={cn(
"block rounded-md border bg-[var(--admin-surface)] p-3 transition-colors",
isApproved
? "border-[var(--admin-accent)]/40"
: "opacity-60",
)}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isApproved}
onChange={(e) =>
setApproved((prev) => ({
...prev,
[group.name]: e.target.checked,
}))
}
/>
<CatalogIcon iconImage={group.icon} size={20} />
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
{group.name}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 gap-1 px-1.5 text-[10px]"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setPreviewGroup(group);
}}
>
<Eye className="h-3 w-3" />
{t("visualPreview")}
</Button>
<Badge variant="secondary" className="h-4 text-[9px]">
{group.total}
</Badge>
{moved > 0 && (
<Badge
variant="outline"
className="h-4 gap-1 text-[9px] text-muted-foreground"
>
{t("movedLabel", { count: moved })}
</Badge>
)}
{added > 0 && (
<Badge
variant="outline"
className="h-4 gap-1 text-[9px] text-muted-foreground"
>
{t("newLabel", { count: added })}
</Badge>
)}
</div>
{!moveMode && (
<div className="mt-3 space-y-2 border-t pt-2 text-xs font-medium text-[var(--admin-text-muted)]">
<div className="flex items-center gap-1">
<span className="w-12 shrink-0 text-[10px]">
{t("name")}
</span>
<Input
className="h-7 text-xs"
value={caption}
onClick={(e) => e.stopPropagation()}
onChange={(e) =>
updateGroup(group.name, {
caption: e.target.value,
})
}
/>
{caption !== group.name && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0 px-2 text-[10px]"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
updateGroup(group.name, {
caption: group.name,
});
}}
>
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
{t("resetSuggestion")}
</Button>
)}
</div>
<div className="flex items-center gap-1">
<span className="w-12 shrink-0 text-[10px]">
{t("icon")}
</span>
<IconPicker
value={icon}
onChange={(id) =>
updateGroup(group.name, { icon: id })
}
>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2"
onClick={(e) => e.stopPropagation()}
>
<CatalogIcon iconImage={icon} size={16} />
<span className="font-mono text-[10px] text-muted-foreground">
#{icon}
</span>
</Button>
</IconPicker>
{icon !== group.icon && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2 text-[10px]"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
updateGroup(group.name, { icon: group.icon });
}}
>
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
{group.icon}
</Button>
)}
</div>
<div className="flex items-center gap-1">
<span className="w-12 shrink-0 text-[10px]">
{t("layout")}
</span>
<div className="flex flex-wrap gap-1">
{CATALOG_LAYOUTS.map((l) => {
const color =
LAYOUT_COLORS[l] ||
"bg-muted text-muted-foreground";
return (
<button
key={l}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
updateGroup(group.name, { layout: l });
}}
className={cn(
"rounded border px-1.5 py-0.5 font-mono text-[10px] transition-colors",
layout === l
? cn(
color,
"border-current ring-1 ring-current/20",
)
: "border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text-muted)] hover:border-current",
)}
>
{l.replace("default_3x3", "3x3")}
</button>
);
})}
</div>
{group.layout &&
layout !== group.layout &&
group.layout !== "default_3x3" && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-fit border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-2 text-[10px] text-[var(--admin-warning)]"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
updateGroup(group.name, {
layout:
group.layout === null
? undefined
: isValidCatalogLayout(group.layout)
? group.layout
: undefined,
});
}}
>
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
{t("suggestedLayout", {
layout: group.layout,
})}
</Button>
)}
</div>
</div>
)}
<div className="mt-2 flex flex-wrap gap-1.5 border-t pt-2">
{group.thumbs.slice(0, 12).map((thumb) => (
<div
key={thumb.itemName}
className="group flex flex-col items-center gap-0.5"
>
<div
className="relative"
title={thumb.publicName || thumb.itemName}
>
<FurniThumb item={{ itemName: thumb.itemName }} />
</div>
{thumb.alreadyPlaced ? (
<span className="max-w-14 truncate text-[8px] text-muted-foreground">
{thumb.sourcePageCaption
? thumb.sourcePageCaption.replace(/^Imp /, "")
: "moved"}
</span>
) : (
<span className="max-w-14 truncate text-[8px] text-muted-foreground">
{t("isNew")}
</span>
)}
</div>
))}
{group.total > 12 && (
<p className="self-center text-[10px] text-muted-foreground">
+{group.total - 12}
</p>
)}
</div>
</label>
);
})}
</>
)}
</div>
<div className="flex flex-none justify-end gap-2 border-t px-4 py-2.5">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
{t("cancel")}
</Button>
<Button
type="button"
size="sm"
className="gap-1"
disabled={
loading ||
isCreating ||
totalSelected === 0 ||
(moveMode && destinationPageId == null)
}
onClick={() => void handleCreate()}
>
{isCreating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Plus className="h-3 w-3" />
)}
{moveMode
? t("move", { count: totalSelected })
: t("create", { count: totalSelected })}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Visual Preview Sub-Dialog */}
<Dialog
open={previewGroup != null}
onOpenChange={(open) => !open && setPreviewGroup(null)}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<CatalogIcon iconImage={previewGroup?.icon ?? 1} size={24} />
<span>{previewGroup?.name}</span>
</DialogTitle>
</DialogHeader>
{previewGroup && (
<div className="flex flex-col gap-3 py-2 text-xs">
<div className="flex items-center justify-between rounded border bg-muted/30 p-2.5">
<div>
<span className="font-semibold">{t("layout")}:</span>{" "}
<span className="font-mono text-[11px]">
{previewGroup.layout ?? "default_3x3"}
</span>
</div>
<Badge variant="outline" className="text-[10px]">
{previewGroup.total} items
</Badge>
</div>
{previewGroup.layout &&
LAYOUT_DESCRIPTIONS[previewGroup.layout] && (
<p className="text-[11px] text-muted-foreground italic">
{LAYOUT_DESCRIPTIONS[previewGroup.layout]}
</p>
)}
<div className="max-h-[220px] overflow-y-auto rounded border p-2">
<div className="grid grid-cols-4 gap-1.5">
{previewGroup.thumbs.map((thumb) => (
<div
key={thumb.itemName}
className="flex flex-col items-center gap-1 rounded border bg-background p-1.5"
>
<FurniThumb item={thumb} size={32} />
<span className="w-full truncate text-center text-[9px] text-muted-foreground">
{thumb.itemName}
</span>
</div>
))}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
function FurniThumb({
item,
size = 24,
}: {
item: { itemName: string };
size?: number;
}) {
const primary = getLocalIconUrl(item.itemName);
return (
<div
className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={item.itemName}
className="max-h-full max-w-full object-contain"
style={{ imageRendering: "pixelated" }}
onError={(e) => {
const img = e.target as HTMLImageElement;
img.style.opacity = "0";
}}
loading="lazy"
/>
</div>
);
}