Files
EpicNext-Cms/src/components/admin/studio/organize-imports-dialog.tsx
T
openhands 31a89c505d
CI / check (push) Successful in 2m25s
CI / deploy (push) Successful in 1m14s
CI / publish-container (push) Successful in 51s
fix(studio): count pages, not items, in organize-imports button
The "Create" CTA used totalSelected (the sum of furni items across
approved groups) as its plural count, so with many imported items it
claimed to create thousands of pages. One approved group creates exactly
one page, so the label now counts approved groups instead.
2026-09-12 18:08:10 +02:00

1078 lines
34 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 [onlyUnplaced, setOnlyUnplaced] = useState(false);
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],
);
// One approved group becomes exactly one page, so the "Create … pages"
// label must count groups, not the furni items inside them.
const selectedGroupsCount = useMemo(
() => groups.filter((g) => approved[g.name]).length,
[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 resetAllOverrides = () => {
setOverrides({});
setApproved(Object.fromEntries(groups.map((g) => [g.name, true])));
};
const filteredGroups = useMemo(() => {
const q = groupSearchQuery.trim().toLowerCase();
return groups.filter((g) => {
if (onlyUnplaced && g.added === 0 && g.moved === 0) return false;
if (!q) return true;
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, onlyUnplaced, 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>
<label className="flex cursor-pointer items-center gap-1.5 select-none rounded border bg-background/50 px-2 py-1.5 text-[10px] text-muted-foreground transition-colors hover:text-foreground">
<input
type="checkbox"
checked={onlyUnplaced}
onChange={(e) => setOnlyUnplaced(e.target.checked)}
className="h-3 w-3 rounded border-[var(--admin-border)]"
/>
<span>{t("onlyUnplaced")}</span>
</label>
<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>
{Object.keys(overrides).length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-[10px] text-muted-foreground hover:text-foreground"
onClick={resetAllOverrides}
>
<RefreshCw className="mr-1 h-3 w-3" />
{t("resetAll")}
</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: selectedGroupsCount })}
</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>
);
}