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.
1078 lines
34 KiB
TypeScript
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>
|
|
);
|
|
}
|