feat(studio): add organize-imports tool with suggestion review and approval
Adds a Studio 'Organize imports' dialog that groups recently imported furniture and furniture already sitting in the auto-created import pages into suggested catalog categories. Each group is presented with its suggested name, icon, and layout which can be overridden before approval; approved groups are turned into real catalog pages in a single atomic export run. Offers already inside the import subtree are moved to the new pages; brand-new furniture gets a fresh offer. - groupSuggestedCategories: generic pure helper reusing the same per-item label heuristic that drives suggestCategoryName; items with no label land in a 'Other Furni' remainder bucket - GET /api/admin/import/organize: returns items from the imported furniture tree (catalog_items JOIN items_base via page id set from the imported-furniture root) union audit-logged but not-yet-placed recent imports; marks alreadyPlaced vs new - organizeImportFurni server action: validates import-page membership before moving any offer, creates pages + inserts/moves items in one withCatalogExport snapshot, logs activity - OrganizeImportsDialog: full-featured Studio dialog with price panel (applies to new offers only), parent select, per-group approval, editable name/icon/layout with suggestion reset chips, source tags - import-pages.ts server helper: locates the imported-furniture tree - Studio nav: 'Organize imports' button with FolderTree icon, gated on CATALOG_EDIT, wired next to the catalog manager - en + nl translations for organizeImports.* keys with ICU plurals - groupSuggestedCategories unit tests (deterministic grouping, remainder handling, size+alpha ordering, label consistency)
This commit is contained in:
1 parent
f5c2c05f6e
commit
01a85ebcd0
9 files changed
+1230
-15
No files matched your search
@@ -0,0 +1,657 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Folder,
|
||||
FolderTree,
|
||||
Home,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Wand2,
|
||||
} 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 { groupSuggestedCategories } from "@/features/catalog/domain/organize";
|
||||
import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts";
|
||||
import { getLocalIconUrl } from "@/lib/furni/classname";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OrganizeItem {
|
||||
id: number;
|
||||
itemId: number;
|
||||
itemName: string;
|
||||
publicName: string;
|
||||
type: string;
|
||||
spriteId: number;
|
||||
interactionType: string;
|
||||
catalogItemId: number | null;
|
||||
sourcePageId: number | null;
|
||||
sourcePageCaption: string | null;
|
||||
alreadyPlaced: boolean;
|
||||
}
|
||||
|
||||
interface RootPage {
|
||||
id: number;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
interface OrganizeGroupOverride {
|
||||
caption?: string;
|
||||
icon?: number;
|
||||
layout?: CatalogLayout;
|
||||
}
|
||||
|
||||
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 [items, setItems] = useState<OrganizeItem[]>([]);
|
||||
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 [approved, setApproved] = useState<Record<string, boolean>>({});
|
||||
const [overrides, setOverrides] = useState<
|
||||
Record<string, OrganizeGroupOverride>
|
||||
>({});
|
||||
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 load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [data, tree] = await Promise.all([
|
||||
fetch("/api/admin/import/organize").then((r) => r.json()),
|
||||
fetch("/api/admin/catalog/tree?parentId=-1").then((r) => r.json()),
|
||||
]);
|
||||
setItems(Array.isArray(data?.items) ? data.items : []);
|
||||
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);
|
||||
toast.error("Failed to load imports");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setItems([]);
|
||||
setApproved({});
|
||||
setOverrides({});
|
||||
setParentId(-1);
|
||||
setFreeMode(true);
|
||||
setCredits(0);
|
||||
setPoints(0);
|
||||
setPointsType(0);
|
||||
void load();
|
||||
}, [open, load]);
|
||||
|
||||
const groups = useMemo(() => groupSuggestedCategories(items), [items]);
|
||||
|
||||
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 handleCreate = async () => {
|
||||
if (isCreating) return;
|
||||
const payload = groups
|
||||
.filter((g) => approved[g.name])
|
||||
.map((g) => {
|
||||
const o = overrides[g.name] ?? {};
|
||||
return {
|
||||
caption: (o.caption ?? g.name).trim(),
|
||||
pageLayout: o.layout ?? g.layout?.layout ?? "default_3x3",
|
||||
iconImage: o.icon ?? g.icon,
|
||||
items: g.items.map((item) => ({
|
||||
itemId: item.id,
|
||||
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 ?? [];
|
||||
toast.success(
|
||||
`Organized ${created.length} import 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 && items.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>
|
||||
)}
|
||||
|
||||
{/* Parent */}
|
||||
{!loading && items.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>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
{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 items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">{t("desc")}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[10px]"
|
||||
onClick={toggleAll}
|
||||
>
|
||||
{allApproved ? t("deselectAll") : t("selectAll")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groups.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?.layout ?? "default_3x3";
|
||||
const isApproved = approved[group.name] !== false;
|
||||
const moved = group.items.filter((i) => i.alreadyPlaced).length;
|
||||
const added = group.items.length - moved;
|
||||
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>
|
||||
<Badge variant="secondary" className="h-4 text-[9px]">
|
||||
{group.items.length}
|
||||
</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>
|
||||
|
||||
<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.layout &&
|
||||
group.layout.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?.layout,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
|
||||
{t("suggestedLayout", {
|
||||
layout: group.layout.layout,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 border-t pt-2">
|
||||
{group.items.slice(0, 12).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group flex flex-col items-center gap-0.5"
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
title={item.publicName || item.itemName}
|
||||
>
|
||||
<FurniThumb item={item} />
|
||||
</div>
|
||||
{item.alreadyPlaced ? (
|
||||
<span className="max-w-14 truncate text-[8px] text-muted-foreground">
|
||||
{item.sourcePageCaption
|
||||
? item.sourcePageCaption.replace(/^Imp /, "")
|
||||
: "moved"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="max-w-14 truncate text-[8px] text-muted-foreground">
|
||||
{t("isNew")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{group.items.length > 12 && (
|
||||
<p className="self-center text-[10px] text-muted-foreground">
|
||||
+{group.items.length - 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}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3" />
|
||||
)}
|
||||
{t("create", { count: totalSelected })}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user