2 Commits
Author SHA1 Message Date
openhands 644d53ca16 Rebuild organize imports: move furniture between categories, 500-item cap
CI / check (push) Failing after 5m12s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
CI / publish-container (push) Skipped
2026-09-14 18:24:48 +02:00
openhands 67430e7e9d Polish catalog studio: undo delete, save status pill, faster totals 2026-09-14 18:24:47 +02:00
33 changed files with 765 additions and 233 deletions

No files matched your search

+1 -1
View File
@@ -349,7 +349,7 @@ export async function createAutoCategory(input: {
}
const MAX_ORGANIZE_GROUPS = 50;
const MAX_ORGANIZE_ITEMS = 1000;
const MAX_ORGANIZE_ITEMS = 500;
export interface OrganizeImportGroup {
caption: string;
+8 -1
View File
@@ -1,5 +1,6 @@
import { asc, count, eq, or } from "drizzle-orm";
import { Crown, Store, Wrench } from "lucide-react";
import { unstable_cache } from "next/cache";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
@@ -42,6 +43,12 @@ async function loadTotals(catalogType: "normal" | "bc"): Promise<Totals> {
};
}
const getCachedTotals = unstable_cache(
async (catalogType: "normal" | "bc") => loadTotals(catalogType),
["admin-catalog-totals"],
{ revalidate: 30 },
);
async function loadRootPages(catalogType: "normal" | "bc"): Promise<RootTab[]> {
const pagesTable = catalogType === "bc" ? CatalogPagesBc : CatalogPages;
const rootRaw = await db
@@ -78,7 +85,7 @@ export default async function CatalogPagesPage({
const catalogType = sp.catalog === "bc" ? "bc" : "normal";
const [totals, rootPages] = await Promise.all([
loadTotals(catalogType),
getCachedTotals(catalogType),
loadRootPages(catalogType),
]);
+8 -7
View File
@@ -210,6 +210,7 @@ export const GET = withAdmin(
// then we can use the extra fields in the returned groups.
const sugItems = items.map((it) => ({
id: it.itemId,
itemId: it.itemId,
itemName: it.itemName,
publicName: it.publicName,
type: it.type,
@@ -217,6 +218,7 @@ export const GET = withAdmin(
interactionType: it.interactionType,
alreadyPlaced: it.alreadyPlaced,
catalogItemId: it.catalogItemId,
sourcePageCaption: it.sourcePageCaption,
})) as SugFurniEntryExtra[];
const groups = groupSuggestedCategories(sugItems).map((g) => {
const moved = g.items.filter((i) => i.alreadyPlaced).length;
@@ -227,17 +229,16 @@ export const GET = withAdmin(
total: g.items.length,
moved,
added: g.items.length - moved,
thumbs: g.items.slice(0, 12).map((i) => ({
itemName: i.itemName,
publicName: i.publicName,
spriteId: i.spriteId,
alreadyPlaced: i.alreadyPlaced,
sourcePageCaption: i.sourcePageCaption,
})),
items: g.items.map((i) => ({
itemId: i.itemId,
catalogItemId: i.catalogItemId,
alreadyPlaced: i.alreadyPlaced,
itemName: i.itemName,
publicName: i.publicName,
spriteId: i.spriteId,
type: i.type,
interactionType: i.interactionType,
sourcePageCaption: i.sourcePageCaption ?? null,
})),
};
});
@@ -2,6 +2,7 @@
import {
AlertCircle,
Check,
ChevronDown,
ChevronRight,
Languages,
@@ -12,7 +13,7 @@ import {
Settings,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog";
import { updateBcPage } from "@/actions/catalog-bc";
@@ -135,6 +136,15 @@ function InlineEditorSession({
const [page, setPage] = useState<PageDetail | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [justSaved, setJustSaved] = useState(false);
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (savedTimer.current) clearTimeout(savedTimer.current);
},
[],
);
const [form, setForm] = useState<FormState>({});
const [original, setOriginal] = useState<FormState>({});
const [showTexts, setShowTexts] = useState(false);
@@ -308,6 +318,9 @@ function InlineEditorSession({
if (!request.isCurrent()) return;
if (!result.ok) throw new Error(result.error || "Failed to save");
toast.success(tActions("saved"));
setJustSaved(true);
if (savedTimer.current) clearTimeout(savedTimer.current);
savedTimer.current = setTimeout(() => setJustSaved(false), 2500);
setOriginal({ ...form });
setPage((prev) =>
prev
@@ -387,14 +400,32 @@ function InlineEditorSession({
>
{form.pageLayout as string}
</Badge>
{(isDirty || offersDirty) && (
{saving ? (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
className="h-4 px-1.5 text-[9px] text-[var(--admin-info)] border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)]"
>
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
{t("unsaved")}
<Loader2 className="h-2.5 w-2.5 mr-0.5 animate-spin" />
{tActions("saving")}
</Badge>
) : justSaved && !isDirty && !offersDirty ? (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] text-[var(--admin-success)] border-[var(--admin-success-border)] bg-[var(--admin-success-subtle)]"
>
<Check className="h-2.5 w-2.5 mr-0.5" />
{tActions("saved")}
</Badge>
) : (
(isDirty || offersDirty) && (
<Badge
variant="outline"
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
>
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
{t("unsaved")}
</Badge>
)
)}
</div>
<p className="text-xs text-muted-foreground">
@@ -55,6 +55,10 @@ import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
import { cn } from "@/lib/utils";
import {
restoreDeletedPage,
showUndoableDeleteToast,
} from "./undo-page-delete";
import { useCatalogManager } from "./use-catalog-store";
export type { RootTab } from "@/types/catalog";
@@ -171,6 +175,13 @@ export function RootTabs({
const handleDelete = async (tab: RootTab) => {
if (!canEdit) return;
const snapshot = {
caption: tab.caption,
parentId: -1,
iconImage: tab.iconImage,
enabled: tab.enabled,
orderNum: tab.orderNum,
};
const ok = await confirm({
title: "Delete root page",
description: `Delete root page "${tab.caption}"? Child pages (if any) will be moved to root; items on this page will be deleted.`,
@@ -183,8 +194,16 @@ export function RootTabs({
} else {
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
}
toast.success(`"${tab.caption}" deleted`);
onMutation?.();
showUndoableDeleteToast(`Page "${tab.caption}" deleted`, async () => {
const newId = await restoreDeletedPage(catalogType, snapshot);
if (!newId) {
toast.error("Could not restore page");
return;
}
onMutation?.();
void onTabClick(newId);
});
} catch {
toast.error("Failed to delete");
}
@@ -19,6 +19,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import {
ChevronsDownUp,
ChevronsUpDown,
FolderOpen,
GripVertical,
Loader2,
MousePointer,
@@ -67,6 +68,10 @@ import {
TreeItemContent,
type TreeItemProps,
} from "./tree-item";
import {
restoreDeletedPage,
showUndoableDeleteToast,
} from "./undo-page-delete";
import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
/* ─── Types ──────────────────────────────────────────────── */
@@ -662,6 +667,18 @@ export function SortableTree({
if (!canEdit) return;
const node = nodes.get(id);
const parentId = node?.parentId ?? -1;
const snapshot = node
? {
caption: node.caption,
parentId: node.parentId,
pageLayout: node.pageLayout,
iconImage: node.iconImage,
iconColor: node.iconColor,
enabled: node.enabled,
visible: node.visible,
orderNum: node.orderNum,
}
: null;
const childInfo =
node && node.childCount > 0
? ` Its ${node.childCount} child page(s) will be moved to the parent.`
@@ -687,6 +704,22 @@ export function SortableTree({
// Children were reparented up — refresh only the affected parent.
await refreshParent(parentId);
onTreeMutation();
if (snapshot) {
showUndoableDeleteToast(
`Page "${node?.caption ?? ""}" deleted`,
async () => {
const newId = await restoreDeletedPage(catalogType, snapshot);
if (!newId) {
toast.error("Could not restore page");
return;
}
dispatch({ type: "EXPAND_PATH", ids: [snapshot.parentId] });
await refreshParent(snapshot.parentId);
onTreeMutation();
onSelectPage(newId);
},
);
}
} catch {
toast.error("Failed to delete page");
}
@@ -1053,8 +1086,21 @@ export function SortableTree({
{/* Tree body (virtualized) */}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{treeLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<div className="space-y-1 p-2" aria-hidden="true">
{[...Array(8)].map((_, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
key={i}
className="flex items-center gap-2 rounded-md px-2 py-1.5"
style={{ marginLeft: `${(i % 3) * 14}px` }}
>
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-muted" />
<div
className="h-3 animate-pulse rounded bg-muted"
style={{ width: `${55 + ((i * 13) % 35)}%` }}
/>
</div>
))}
</div>
) : searchResults !== null ? (
<div className="p-1">
@@ -1068,6 +1114,17 @@ export function SortableTree({
</div>
))}
</div>
) : flatItems.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
<FolderOpen className="h-8 w-8 text-muted-foreground/40" />
<p className="text-xs font-medium text-muted-foreground">
No pages in this category
</p>
<p className="max-w-[200px] text-[10px] text-muted-foreground/70">
Create a page with the + button, or switch to sort mode to reorder
existing ones.
</p>
</div>
) : sortMode ? (
/* Sort mode: dnd-kit enabled, but only for visible virtual items */
<DndContext
@@ -0,0 +1,64 @@
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc";
import type { CatalogManagerMode } from "./use-catalog-store";
export interface DeletedPageSnapshot {
caption: string;
parentId: number;
pageLayout?: string;
iconImage?: number;
iconColor?: number;
enabled?: string;
visible?: string;
minRank?: number;
orderNum?: number;
}
/**
* Re-creates a page that was just deleted, preserving its placement and
* presentation fields. Item rows and text content cannot be restored (they
* were already removed server-side), but editing intent survives.
*/
export async function restoreDeletedPage(
catalogType: CatalogManagerMode,
snapshot: DeletedPageSnapshot,
): Promise<number | null> {
try {
const input = {
parentId: snapshot.parentId ?? -1,
caption: snapshot.caption,
pageLayout: snapshot.pageLayout ?? "default_3x3",
iconImage: snapshot.iconImage ?? 0,
iconColor: snapshot.iconColor ?? 0,
enabled: (snapshot.enabled ?? "1") as "0" | "1",
visible: (snapshot.visible ?? "1") as "0" | "1",
orderNum: snapshot.orderNum ?? 0,
};
const result =
catalogType === "bc"
? await createBcPage(input)
: await createCatalogPage(
catalogType === "normal"
? { ...input, minRank: snapshot.minRank ?? 1 }
: input,
);
if (!result.ok) throw new Error(result.error);
return Number(result.data?.id) || null;
} catch {
return null;
}
}
/** Success toast that doubles as the undo trigger for a page deletion. */
export function showUndoableDeleteToast(
message: string,
onUndo: () => void | Promise<void>,
) {
toast.success(message, {
action: {
label: "Undo",
onClick: () => void onUndo(),
},
});
}
@@ -10,6 +10,7 @@ import {
Plus,
RefreshCw,
Search,
Trash2,
Wand2,
X,
} from "lucide-react";
@@ -42,10 +43,13 @@ import {
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";
const MAX_ITEMS_PER_RUN = 500;
const ITEMS_VISIBLE = 40;
const QUICK_PICKS = 40;
interface OrganizeGroupOverride {
caption?: string;
icon?: number;
@@ -57,6 +61,18 @@ interface RootPage {
caption: string;
}
interface OrganizeImportItem {
itemId: number;
catalogItemId: number | null;
alreadyPlaced: boolean;
itemName: string;
publicName: string;
spriteId: number;
type: string;
interactionType: string;
sourcePageCaption: string | null;
}
interface OrganizeImportGroup {
name: string;
icon: number;
@@ -64,18 +80,7 @@ interface OrganizeImportGroup {
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;
}[];
items: OrganizeImportItem[];
}
interface OrganizeImportsDialogProps {
@@ -129,6 +134,9 @@ export function OrganizeImportsDialog({
const [batchLayout, setBatchLayout] = useState<CatalogLayout>("default_3x3");
const [batchPrefix, setBatchPrefix] = useState("");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [selection, setSelection] = useState<Set<number>>(new Set());
const load = useCallback(async () => {
setLoading(true);
setLoadError(false);
@@ -168,6 +176,8 @@ export function OrganizeImportsDialog({
setGroups([]);
setApproved({});
setOverrides({});
setExpanded({});
setSelection(new Set());
setParentId(-1);
setMoveMode(false);
setDestinationPageId(null);
@@ -220,11 +230,11 @@ export function OrganizeImportsDialog({
}, [moveMode, destDebounced]);
const allApproved =
groups.length > 0 && groups.every((g) => approved[g.name]);
groups.length > 0 && groups.every((g) => approved[g.name] !== false);
const totalSelected = useMemo(
() =>
groups.reduce(
(sum, g) => sum + (approved[g.name] ? g.items.length : 0),
(sum, g) => sum + (approved[g.name] !== false ? g.items.length : 0),
0,
),
[groups, approved],
@@ -233,7 +243,7 @@ export function OrganizeImportsDialog({
// 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.filter((g) => approved[g.name] !== false).length,
[groups, approved],
);
@@ -294,27 +304,77 @@ export function OrganizeImportsDialog({
const filteredGroups = useMemo(() => {
const q = groupSearchQuery.trim().toLowerCase();
return groups.filter((g) => {
if (onlyUnplaced && g.added === 0 && g.moved === 0) return false;
const movedCount = g.items.filter((i) => i.alreadyPlaced).length;
const addedCount = g.items.length - movedCount;
if (onlyUnplaced && addedCount === 0 && movedCount === 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));
return g.items.some((i) => i.itemName.toLowerCase().includes(q));
});
}, [groups, groupSearchQuery, onlyUnplaced, overrides]);
const moveItemToGroup = (
item: OrganizeImportItem,
fromName: string,
toName: string,
) => {
if (!toName || toName === fromName) return;
setGroups((prev) =>
prev.map((g) => {
if (g.name === fromName) {
const items = g.items.filter((i) => i.itemId !== item.itemId);
return { ...g, items };
}
if (g.name === toName) {
const exists = g.items.some((i) => i.itemId === item.itemId);
return exists ? g : { ...g, items: [...g.items, item] };
}
return g;
}),
);
};
const moveSelectedToGroup = (toName: string) => {
if (selection.size === 0 || !toName) return;
setGroups((prev) => {
const moved: OrganizeImportItem[] = [];
const next = prev.map((g) => {
if (g.name === toName) return g;
const taken = g.items.filter((i) => selection.has(i.itemId));
const kept = g.items.filter((i) => !selection.has(i.itemId));
moved.push(...taken);
return taken.length === 0 ? g : { ...g, items: kept };
});
if (moved.length === 0) return prev;
return next.map((g) =>
g.name === toName ? { ...g, items: [...g.items, ...moved] } : g,
);
});
setSelection(new Set());
};
const moveItemOptions = (fromName: string) =>
groups.filter((g) => g.name !== fromName);
const handleCreate = async () => {
if (isCreating) return;
if (totalSelected > 1000) {
toast.error(t("errorMaxItems"));
if (totalSelected === 0) {
toast.error(t("errorNoName"));
return;
}
if (totalSelected > MAX_ITEMS_PER_RUN) {
toast.error(t("errorMaxItems", { count: MAX_ITEMS_PER_RUN }));
return;
}
if (groups.length === 0) return;
if (moveMode && destinationPageId == null) {
toast.error(t("chooseDestination"));
return;
}
const payload = groups
.filter((g) => approved[g.name])
.filter((g) => approved[g.name] !== false)
.map((g) => {
const o = overrides[g.name] ?? {};
return {
@@ -342,7 +402,7 @@ export function OrganizeImportsDialog({
return;
}
if (payload.length === 0 || payload.some((g) => !g.caption)) {
if (payload.some((g) => !g.caption)) {
toast.error(t("errorNoName"));
return;
}
@@ -351,9 +411,9 @@ export function OrganizeImportsDialog({
const result = await organizeImportFurni({ parentId, groups: payload });
if (!result.ok) throw new Error(result.error ?? "Failed");
const created = result.data?.created ?? [];
const moved = created.reduce((sum, c) => sum + c.moved, 0);
const added = created.reduce((sum, c) => sum + c.added, 0);
if (moveMode) {
const moved = created.reduce((sum, c) => sum + c.moved, 0);
const added = created.reduce((sum, c) => sum + c.added, 0);
toast.success(
t("successMove", {
moved,
@@ -362,8 +422,6 @@ export function OrganizeImportsDialog({
}),
);
} else {
const moved = created.reduce((sum, c) => sum + c.moved, 0);
const added = created.reduce((sum, c) => sum + c.added, 0);
toast.success(
t("successCreate", {
count: created.length,
@@ -382,10 +440,12 @@ export function OrganizeImportsDialog({
}
};
const quickPicks = rootPages.slice(0, QUICK_PICKS);
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">
<DialogContent className="flex h-[90vh] max-h-[90vh] w-[860px] 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" />
@@ -543,47 +603,70 @@ export function OrganizeImportsDialog({
</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 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>
{/* Quick pick: click a root page without typing */}
{destQuery.length < 2 && quickPicks.length > 0 && (
<div className="flex max-h-[140px] flex-wrap gap-1 overflow-y-auto rounded border bg-[var(--admin-canvas)] p-1.5">
{quickPicks.map((p) => (
<button
type="button"
key={p.id}
onClick={() => {
setDestinationPageId(p.id);
setDestinationCaption(p.caption);
}}
className="flex max-w-full items-center gap-1 rounded border border-[var(--admin-border)] bg-[var(--admin-surface)] px-2 py-1 text-[10px] text-[var(--admin-text)] hover:border-current"
>
<Folder className="h-3 w-3 shrink-0" />
<span className="min-w-0 truncate">
{p.caption}
</span>
</button>
))}
</div>
)}
</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>
))
{destQuery.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>
)}
</div>
</>
)}
<p className="text-[10px] text-muted-foreground">
{t("destinationHint")}
@@ -748,6 +831,52 @@ export function OrganizeImportsDialog({
)}
</div>
</div>
{/* Move selected to another category */}
{selection.size > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-[var(--admin-accent)]/40 bg-[var(--admin-accent-subtle)] px-2.5 py-2">
<span className="text-[10px] font-semibold text-[var(--admin-accent)]">
{t("selectedCount", { count: selection.size })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("batchMoveTo")}
</span>
<Select
value=""
onValueChange={(v) => {
if (v) {
moveSelectedToGroup(v);
setExpanded((prev) => ({ ...prev, [v]: true }));
}
}}
>
<SelectTrigger className="h-7 w-[180px] text-[10px]">
<SelectValue placeholder={t("batchMoveTo")} />
</SelectTrigger>
<SelectContent>
{groups.map((g) => (
<SelectItem
key={g.name}
value={g.name}
className="text-xs"
>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-[10px] text-muted-foreground hover:text-foreground"
onClick={() => setSelection(new Set())}
>
<Trash2 className="mr-1 h-3 w-3" />
{t("clearSelection")}
</Button>
</div>
)}
</div>
{filteredGroups.map((group) => {
@@ -756,16 +885,23 @@ export function OrganizeImportsDialog({
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;
const movedCount = group.items.filter(
(i) => i.alreadyPlaced,
).length;
const addedCount = group.items.length - movedCount;
const isExpanded = expanded[group.name] ?? false;
const visibleItems = isExpanded
? group.items
: group.items.slice(0, ITEMS_VISIBLE);
const moveTargets = moveItemOptions(group.name);
return (
<label
<div
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",
: "opacity-70",
)}
>
<div className="flex items-center gap-2">
@@ -788,32 +924,28 @@ export function OrganizeImportsDialog({
variant="ghost"
size="sm"
className="h-5 gap-1 px-1.5 text-[10px]"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setPreviewGroup(group);
}}
onClick={() => setPreviewGroup(group)}
>
<Eye className="h-3 w-3" />
{t("visualPreview")}
</Button>
<Badge variant="secondary" className="h-4 text-[9px]">
{group.total}
{group.items.length}
</Badge>
{moved > 0 && (
{movedCount > 0 && (
<Badge
variant="outline"
className="h-4 gap-1 text-[9px] text-muted-foreground"
>
{t("movedLabel", { count: moved })}
{t("movedLabel", { count: movedCount })}
</Badge>
)}
{added > 0 && (
{addedCount > 0 && (
<Badge
variant="outline"
className="h-4 gap-1 text-[9px] text-muted-foreground"
>
{t("newLabel", { count: added })}
{t("newLabel", { count: addedCount })}
</Badge>
)}
</div>
@@ -827,7 +959,6 @@ export function OrganizeImportsDialog({
<Input
className="h-7 text-xs"
value={caption}
onClick={(e) => e.stopPropagation()}
onChange={(e) =>
updateGroup(group.name, {
caption: e.target.value,
@@ -840,13 +971,11 @@ export function OrganizeImportsDialog({
variant="outline"
size="sm"
className="h-7 shrink-0 px-2 text-[10px]"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick={() =>
updateGroup(group.name, {
caption: group.name,
});
}}
})
}
>
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
{t("resetSuggestion")}
@@ -869,7 +998,6 @@ export function OrganizeImportsDialog({
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">
@@ -883,11 +1011,9 @@ export function OrganizeImportsDialog({
variant="outline"
size="sm"
className="h-7 px-2 text-[10px]"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
updateGroup(group.name, { icon: group.icon });
}}
onClick={() =>
updateGroup(group.name, { icon: group.icon })
}
>
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
{group.icon}
@@ -906,13 +1032,11 @@ export function OrganizeImportsDialog({
"bg-muted text-muted-foreground";
return (
<button
key={l}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
updateGroup(group.name, { layout: l });
}}
key={l}
onClick={() =>
updateGroup(group.name, { layout: l })
}
className={cn(
"rounded border px-1.5 py-0.5 font-mono text-[10px] transition-colors",
layout === l
@@ -936,9 +1060,7 @@ export function OrganizeImportsDialog({
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();
onClick={() =>
updateGroup(group.name, {
layout:
group.layout === null
@@ -946,8 +1068,8 @@ export function OrganizeImportsDialog({
: isValidCatalogLayout(group.layout)
? group.layout
: undefined,
});
}}
})
}
>
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
{t("suggestedLayout", {
@@ -959,41 +1081,113 @@ export function OrganizeImportsDialog({
</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"
>
{/* Item rows: every furniture item in this category */}
<div className="mt-2 border-t pt-2">
{visibleItems.map((item) => {
const checked = selection.has(item.itemId);
return (
<div
className="relative"
title={thumb.publicName || thumb.itemName}
key={item.itemId}
className="flex items-center gap-2 rounded border border-[var(--admin-border)] bg-[var(--admin-canvas)] px-2 py-1"
>
<FurniThumb item={{ itemName: thumb.itemName }} />
<input
type="checkbox"
checked={checked}
onChange={(e) =>
setSelection((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(item.itemId);
else next.delete(item.itemId);
return next;
})
}
/>
<FurniThumb
item={{ itemName: item.itemName }}
size={28}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-[var(--admin-text)]">
{item.publicName || item.itemName}
</p>
<p className="truncate font-mono text-[9px] text-muted-foreground">
{item.itemName}
</p>
</div>
{item.alreadyPlaced ? (
<span className="shrink-0 rounded bg-[var(--admin-success-subtle)] px-1.5 py-0.5 text-[9px] text-[var(--admin-text)]">
{item.sourcePageCaption
? item.sourcePageCaption.replace(
new RegExp(`^${t("impPrefix")}`),
"",
)
: t("isMoved")}
</span>
) : (
<span className="shrink-0 rounded bg-[var(--admin-info-subtle)] px-1.5 py-0.5 text-[9px] text-[var(--admin-text)]">
{t("isNew")}
</span>
)}
{moveTargets.length > 0 && (
<select
title={t("moveItemTo")}
value=""
onChange={(e) => {
moveItemToGroup(
item,
group.name,
e.target.value,
);
e.target.value = "";
}}
className="h-6 shrink-0 rounded border border-[var(--admin-border)] bg-[var(--admin-surface)] px-1 text-[9px] text-[var(--admin-text)]"
>
<option value="" disabled>
{t("moveItemTo")}
</option>
{moveTargets.map((g) => (
<option key={g.name} value={g.name}>
{g.name}
</option>
))}
</select>
)}
</div>
{thumb.alreadyPlaced ? (
<span className="max-w-14 truncate text-[8px] text-muted-foreground">
{thumb.sourcePageCaption
? thumb.sourcePageCaption.replace(
new RegExp(`^${t("impPrefix")}`),
"",
)
: t("isMoved")}
</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>
)}
);
})}
{group.items.length > ITEMS_VISIBLE &&
(isExpanded ? (
<button
type="button"
onClick={() =>
setExpanded((prev) => ({
...prev,
[group.name]: false,
}))
}
className="mt-1 w-full rounded border border-[var(--admin-border)] py-1 text-[10px] text-muted-foreground hover:text-foreground"
>
{t("showLess")}
</button>
) : (
<button
type="button"
onClick={() =>
setExpanded((prev) => ({
...prev,
[group.name]: true,
}))
}
className="mt-1 w-full rounded border border-[var(--admin-border)] py-1 text-[10px] text-muted-foreground hover:text-foreground"
>
{t("showAll", {
count: group.items.length,
})}
</button>
))}
</div>
</label>
</div>
);
})}
</>
@@ -1017,6 +1211,7 @@ export function OrganizeImportsDialog({
loading ||
isCreating ||
totalSelected === 0 ||
totalSelected > MAX_ITEMS_PER_RUN ||
(moveMode && destinationPageId == null)
}
onClick={() => void handleCreate()}
@@ -1039,7 +1234,7 @@ export function OrganizeImportsDialog({
open={previewGroup != null}
onOpenChange={(open) => !open && setPreviewGroup(null)}
>
<DialogContent className="max-w-md">
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<CatalogIcon iconImage={previewGroup?.icon ?? 1} size={24} />
@@ -1056,7 +1251,7 @@ export function OrganizeImportsDialog({
</span>
</div>
<Badge variant="outline" className="text-[10px]">
{previewGroup.total} items
{previewGroup.items.length} items
</Badge>
</div>
{previewGroup.layout &&
@@ -1065,20 +1260,28 @@ export function OrganizeImportsDialog({
{LAYOUT_DESCRIPTIONS[previewGroup.layout]}
</p>
)}
<div className="max-h-[220px] overflow-y-auto rounded border p-2">
<div className="max-h-[260px] overflow-y-auto rounded border p-2">
<div className="grid grid-cols-4 gap-1.5">
{previewGroup.thumbs.map((thumb) => (
{previewGroup.items.slice(0, 40).map((item) => (
<div
key={thumb.itemName}
key={item.itemId}
className="flex flex-col items-center gap-1 rounded border bg-background p-1.5"
>
<FurniThumb item={thumb} size={32} />
<FurniThumb
item={{ itemName: item.itemName }}
size={32}
/>
<span className="w-full truncate text-center text-[9px] text-muted-foreground">
{thumb.itemName}
{item.itemName}
</span>
</div>
))}
</div>
{previewGroup.items.length > 40 && (
<p className="py-2 text-center text-[10px] text-muted-foreground">
{t("showAll", { count: previewGroup.items.length })}
</p>
)}
</div>
</div>
)}
+9 -3
View File
@@ -907,12 +907,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -920,7 +920,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2004,12 +2004,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2017,7 +2017,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Alle anzeigen ({count})",
"showLess": "Weniger anzeigen",
"moveItemTo": "Verschieben zu…",
"batchMoveTo": "Auswahl verschieben zu…",
"selectedCount": "{count} ausgewählt",
"clearSelection": "Auswahl aufheben"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2260,12 +2260,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2273,7 +2273,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"bulk": {
+9 -3
View File
@@ -2004,12 +2004,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2017,7 +2017,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Mostrar todo ({count})",
"showLess": "Mostrar menos",
"moveItemTo": "Mover a…",
"batchMoveTo": "Mover seleccionados a…",
"selectedCount": "{count} seleccionados",
"clearSelection": "Limpiar selección"
}
},
"discovery": {
+9 -3
View File
@@ -907,12 +907,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -920,7 +920,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2004,12 +2004,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2017,7 +2017,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Tout afficher ({count})",
"showLess": "Afficher moins",
"moveItemTo": "Déplacer vers…",
"batchMoveTo": "Déplacer la sélection vers…",
"selectedCount": "{count} sélectionné(s)",
"clearSelection": "Effacer la sélection"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2179,12 +2179,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2192,7 +2192,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Mostra tutto ({count})",
"showLess": "Mostra meno",
"moveItemTo": "Sposta verso…",
"batchMoveTo": "Sposta la selezione verso…",
"selectedCount": "{count} selezionati",
"clearSelection": "Azzera selezione"
}
},
"bulk": {
+9 -3
View File
@@ -907,12 +907,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -920,7 +920,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2124,12 +2124,12 @@
"visualPreview": "Voorbeeld",
"resetAll": "Wijzigingen herstellen",
"onlyUnplaced": "Toon alleen ongeplaatste items",
"errorMaxItems": "Maximaal 1000 items per run. Verlaag de selectie.",
"errorMaxItems": "Maximaal 500 items per run. Verlaag de selectie.",
"errorMaxGroups": "Maximaal {count} groepen per run. Verlaag de selectie.",
"errorNoName": "Elke goedgekeurde pagina heeft een naam nodig",
"tipMove": "Tip: Kies de doelpagina in het vak hieronder om meubels te verplaatsen.",
"tipCreate": "Tip: Kies deze modus om nieuwe pagina's aan te maken.",
"limitHint": "Maximaal 1000 items en 50 groepen per run.",
"limitHint": "Maximaal 500 items en 50 groepen per run.",
"errorFailed": "Kon imports niet organiseren",
"successMove": "{moved} item{moved, plural, one { verplaatst} other {s verplaatst}}{added, plural, =0 {} other {, {added} nieuw toegevoegd}} naar \"{destination}\"",
"successCreate": "{count, plural, one {1 pagina} other {# pagina's}} aangemaakt met {moved} verplaatste en {added} toegevoegde meubels",
@@ -2137,7 +2137,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "verplaatst"
"isMoved": "verplaatst",
"showAll": "Alles tonen ({count})",
"showLess": "Minder tonen",
"moveItemTo": "Verplaatsen naar…",
"batchMoveTo": "Geselecteerde verplaatsen naar…",
"selectedCount": "{count} geselecteerd",
"clearSelection": "Selectie wissen"
}
},
"bulk": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Mostrar tudo ({count})",
"showLess": "Mostrar menos",
"moveItemTo": "Mover para…",
"batchMoveTo": "Mover seleção para…",
"selectedCount": "{count} selecionados",
"clearSelection": "Limpar seleção"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2032,12 +2032,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2045,7 +2045,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Tümünü göster ({count})",
"showLess": "Daha az göster",
"moveItemTo": "Taşı…",
"batchMoveTo": "Seçimi taşı…",
"selectedCount": "{count} seçildi",
"clearSelection": "Seçimi temizle"
}
},
"discovery": {
+9 -3
View File
@@ -2034,12 +2034,12 @@
"visualPreview": "Preview",
"resetAll": "Reset overrides",
"onlyUnplaced": "Only show unplaced items",
"errorMaxItems": "Max 1000 items per run. Reduce selection.",
"errorMaxItems": "Max 500 items per run. Reduce selection.",
"errorMaxGroups": "Max {count} groups per run. Reduce selection.",
"errorNoName": "Every approved page needs a name",
"tipMove": "Tip: Pick a destination page below to move furniture.",
"tipCreate": "Tip: Pick this mode to create new pages.",
"limitHint": "Max 1000 items and 50 groups per run.",
"limitHint": "Max 500 items and 50 groups per run.",
"errorFailed": "Failed to organize imports",
"successMove": "Moved {moved} offer{moved, plural, one {} other {s}}{added, plural, =0 {} other {, added {added} new}} to \"{destination}\"",
"successCreate": "Created {count, plural, one {1 page} other {# pages}} with {moved} moved and {added} added furniture",
@@ -2047,7 +2047,13 @@
"layoutFallbackDefault3x3": "3x3",
"itemsUnit": "items",
"impPrefix": "Imp ",
"isMoved": "moved"
"isMoved": "moved",
"showAll": "Show all ({count})",
"showLess": "Show less",
"moveItemTo": "Move to…",
"batchMoveTo": "Move selected to…",
"selectedCount": "{count} selected",
"clearSelection": "Clear selection"
}
},
"discovery": {