From 3a4089a5fa932b1cff02ba75f10e4dde9b298e06 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 14 Sep 2026 18:40:50 +0200 Subject: [PATCH] Organize imports: drag & drop, virtualization, dupes, undo, days select, localStorage - Native HTML5 drag & drop between categories with drop-target highlight - Virtualized item lists via @tanstack/react-virtual (fixed 34px rows) - Auto-batching of large groups (500 items / 50 groups per run) - Duplicate detection against the destination page with badge + summary - Per-category layout preview grid - Undo history for item moves (single + batch, tracks source groups) - Days-range selector to load older imports (30/60/90/180) - LocalStorage persistence of user settings (mode, destination, price, days) - Added translation keys across all 25 locales --- .../admin/studio/organize-imports-dialog.tsx | 919 +++++++++++++----- src/messages/ar.json | 12 +- src/messages/bg.json | 12 +- src/messages/cs.json | 12 +- src/messages/da.json | 12 +- src/messages/de.json | 12 +- src/messages/el.json | 12 +- src/messages/en.json | 12 +- src/messages/es.json | 12 +- src/messages/fi.json | 12 +- src/messages/fr.json | 12 +- src/messages/hr.json | 12 +- src/messages/hu.json | 12 +- src/messages/it.json | 12 +- src/messages/ja.json | 12 +- src/messages/nl.json | 12 +- src/messages/no.json | 12 +- src/messages/pl.json | 12 +- src/messages/pt.json | 12 +- src/messages/ro.json | 12 +- src/messages/ru.json | 12 +- src/messages/sk.json | 12 +- src/messages/sr.json | 12 +- src/messages/sv.json | 12 +- src/messages/tr.json | 12 +- src/messages/uk.json | 12 +- 26 files changed, 959 insertions(+), 260 deletions(-) diff --git a/src/components/admin/studio/organize-imports-dialog.tsx b/src/components/admin/studio/organize-imports-dialog.tsx index 3447a551..7417b5ca 100644 --- a/src/components/admin/studio/organize-imports-dialog.tsx +++ b/src/components/admin/studio/organize-imports-dialog.tsx @@ -1,24 +1,32 @@ "use client"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { + AlertTriangle, ArrowRight, Eye, Folder, FolderTree, + GripVertical, Home, + LayoutGrid, Loader2, Plus, RefreshCw, Search, Trash2, + Undo2, Wand2, X, } from "lucide-react"; import { useRouter } from "next/navigation"; 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 { organizeImportFurni } from "@/actions/catalog"; +import { + type OrganizeImportGroup as OrganizeGroupPayload, + 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"; @@ -47,8 +55,34 @@ import { getLocalIconUrl } from "@/lib/furni/classname"; import { cn } from "@/lib/utils"; const MAX_ITEMS_PER_RUN = 500; -const ITEMS_VISIBLE = 40; +const MAX_GROUPS_PER_RUN = 50; const QUICK_PICKS = 40; +const DAYS_OPTIONS = [30, 60, 90, 180] as const; + +const LS_KEY = "epicnexst:organizePrefs"; + +interface OrganizePrefs { + moveMode?: boolean; + parentId?: number; + freeMode?: boolean; + credits?: number; + points?: number; + pointsType?: number; + days?: number; + destinationPageId?: number | null; + destinationCaption?: string | null; +} + +function readPrefs(): OrganizePrefs { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(LS_KEY); + if (raw) return JSON.parse(raw) as OrganizePrefs; + } catch { + // ignore storage errors + } + return {}; +} interface OrganizeGroupOverride { caption?: string; @@ -83,11 +117,30 @@ interface OrganizeImportGroup { items: OrganizeImportItem[]; } +type MoveEntry = + | { kind: "item"; item: OrganizeImportItem; from: string; to: string } + | { + kind: "batch"; + items: OrganizeImportItem[]; + from: Record; + to: string; + }; + interface OrganizeImportsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } +/** Best-effort grid dimensions for the better-known catalog layouts. */ +const LAYOUT_GRIDS: Record = { + default_3x3: { cols: 3, rows: 3 }, + sold_ltd_items: { cols: 3, rows: 3 }, + room_bundle: { cols: 3, rows: 2 }, + badge_display: { cols: 3, rows: 3 }, + spaces_new: { cols: 4, rows: 2 }, + single_bundle: { cols: 1, rows: 1 }, +}; + export function OrganizeImportsDialog({ open, onOpenChange, @@ -116,6 +169,8 @@ export function OrganizeImportsDialog({ const [destDebounced, setDestDebounced] = useState(""); const [destResults, setDestResults] = useState([]); const [destSearchLoading, setDestSearchLoading] = useState(false); + const [destDupes, setDestDupes] = useState>(new Set()); + const [destDupesLoading, setDestDupesLoading] = useState(false); const [approved, setApproved] = useState>({}); const [overrides, setOverrides] = useState< Record @@ -133,20 +188,26 @@ export function OrganizeImportsDialog({ const [onlyUnplaced, setOnlyUnplaced] = useState(false); const [batchLayout, setBatchLayout] = useState("default_3x3"); const [batchPrefix, setBatchPrefix] = useState(""); + const [days, setDays] = useState(30); - const [expanded, setExpanded] = useState>({}); const [selection, setSelection] = useState>(new Set()); + const [history, setHistory] = useState([]); + const [drag, setDrag] = useState<{ + item: OrganizeImportItem; + from: string; + } | null>(null); + const [hoverGroup, setHoverGroup] = useState(null); - const load = useCallback(async () => { + const load = useCallback(async (rangeDays: number) => { 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/import/organize?days=${rangeDays}`, { + signal: controller.signal, + }).then((r) => r.json()), fetch("/api/admin/catalog/tree?parentId=-1", { signal: controller.signal, }).then((r) => r.json()), @@ -155,12 +216,25 @@ export function OrganizeImportsDialog({ setImportRootPageId(data?.importRootPageId ?? null); setImportRootCaption(data?.importRootCaption ?? null); const pages = Array.isArray(tree?.pages) ? tree.pages : []; - setRootPages( - pages.map((p: { id: number; caption: string }) => ({ + const loadedRoots: RootPage[] = pages.map( + (p: { id: number; caption: string }) => ({ id: Number(p.id), caption: String(p.caption ?? p.id), - })), + }), ); + setRootPages(loadedRoots); + // Restore a persisted destination only if it still exists. + const prefs = readPrefs(); + if (prefs.moveMode) { + setMoveMode(true); + if ( + prefs.destinationPageId != null && + loadedRoots.some((p) => p.id === prefs.destinationPageId) + ) { + setDestinationPageId(prefs.destinationPageId); + setDestinationCaption(prefs.destinationCaption ?? null); + } + } } catch (error) { console.error("[OrganizeImports] load failed:", error); setLoadError(true); @@ -173,25 +247,69 @@ export function OrganizeImportsDialog({ useEffect(() => { if (!open) return; + const prefs = readPrefs(); setGroups([]); setApproved({}); setOverrides({}); - setExpanded({}); setSelection(new Set()); - setParentId(-1); + setHistory([]); + setDrag(null); + setHoverGroup(null); + setParentId(prefs.parentId ?? -1); setMoveMode(false); setDestinationPageId(null); setDestinationCaption(null); setDestQuery(""); setDestDebounced(""); setDestResults([]); - setFreeMode(true); - setCredits(0); - setPoints(0); - setPointsType(0); - void load(); + setDestDupes(new Set()); + setFreeMode(prefs.freeMode ?? true); + setCredits(prefs.credits ?? 0); + setPoints(prefs.points ?? 0); + setPointsType(prefs.pointsType ?? 0); + setDays( + DAYS_OPTIONS.includes(prefs.days as (typeof DAYS_OPTIONS)[number]) + ? (prefs.days as number) + : 30, + ); + void load( + DAYS_OPTIONS.includes(prefs.days as (typeof DAYS_OPTIONS)[number]) + ? (prefs.days as number) + : 30, + ); }, [open, load]); + // Persist working settings on every change. + useEffect(() => { + if (typeof window === "undefined") return; + const prefs: OrganizePrefs = { + moveMode, + parentId, + freeMode, + credits, + points, + pointsType, + days, + destinationPageId, + destinationCaption, + }; + try { + window.localStorage.setItem(LS_KEY, JSON.stringify(prefs)); + } catch { + // ignore quota / privacy-mode write errors + } + }, [ + moveMode, + parentId, + freeMode, + credits, + points, + pointsType, + days, + destinationPageId, + destinationCaption, + ]); + useEffect(() => { const t = setTimeout(() => setDestDebounced(destQuery.trim()), 300); return () => clearTimeout(t); @@ -206,9 +324,7 @@ export function OrganizeImportsDialog({ const ac = new AbortController(); fetch( `/api/admin/catalog/tree?search=${encodeURIComponent(destDebounced)}`, - { - signal: ac.signal, - }, + { signal: ac.signal }, ) .then((r) => r.json()) .then((data) => { @@ -229,6 +345,35 @@ export function OrganizeImportsDialog({ return () => ac.abort(); }, [moveMode, destDebounced]); + // Duplicate detection against the chosen destination page. + useEffect(() => { + if (!moveMode || destinationPageId == null) { + setDestDupes(new Set()); + return; + } + const ac = new AbortController(); + setDestDupesLoading(true); + fetch(`/api/admin/catalog/items?pageId=${destinationPageId}`, { + signal: ac.signal, + }) + .then((r) => r.json()) + .then((data) => { + const items = Array.isArray(data?.items) ? data.items : []; + setDestDupes( + new Set( + items + .map((i: { baseItemName?: string | null }) => + String(i.baseItemName ?? ""), + ) + .filter(Boolean), + ), + ); + }) + .catch(() => setDestDupes(new Set())) + .finally(() => setDestDupesLoading(false)); + return () => ac.abort(); + }, [moveMode, destinationPageId]); + const allApproved = groups.length > 0 && groups.every((g) => approved[g.name] !== false); const totalSelected = useMemo( @@ -247,6 +392,18 @@ export function OrganizeImportsDialog({ [groups, approved], ); + const totalDuplicates = useMemo(() => { + if (destDupes.size === 0) return 0; + let count = 0; + for (const g of groups) { + if (approved[g.name] === false) continue; + for (const item of g.items) { + if (destDupes.has(item.itemName)) count++; + } + } + return count; + }, [groups, approved, destDupes]); + const parentOptions = useMemo(() => { const options: RootPage[] = []; for (const p of rootPages) { @@ -315,19 +472,20 @@ export function OrganizeImportsDialog({ }); }, [groups, groupSearchQuery, onlyUnplaced, overrides]); - const moveItemToGroup = ( - item: OrganizeImportItem, - fromName: string, - toName: string, - ) => { - if (!toName || toName === fromName) return; + const pushHistory = (entry: MoveEntry) => { + setHistory((prev) => [...prev, entry]); + }; + + const applyMove = (item: OrganizeImportItem, from: string, to: string) => { 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 === from) { + return { + ...g, + items: g.items.filter((i) => i.itemId !== item.itemId), + }; } - if (g.name === toName) { + if (g.name === to) { const exists = g.items.some((i) => i.itemId === item.itemId); return exists ? g : { ...g, items: [...g.items, item] }; } @@ -336,38 +494,141 @@ export function OrganizeImportsDialog({ ); }; + const moveItemToGroup = ( + item: OrganizeImportItem, + from: string, + to: string, + ) => { + if (!to || to === from) return; + applyMove(item, from, to); + pushHistory({ kind: "item", item, from, to }); + }; + 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) => + const movedByGroup: Record = {}; + const next = groups.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)); + if (taken.length > 0) movedByGroup[g.name] = taken; + return taken.length === 0 ? g : { ...g, items: kept }; + }); + const moved = Object.values(movedByGroup).flat(); + if (moved.length === 0) return; + setGroups( + next.map((g) => g.name === toName ? { ...g, items: [...g.items, ...moved] } : g, - ); + ), + ); + pushHistory({ + kind: "batch", + items: moved, + from: movedByGroup, + to: toName, }); setSelection(new Set()); }; + const undo = () => { + if (history.length === 0) return; + const last = history[history.length - 1]; + if (last.kind === "item") { + applyMove(last.item, last.to, last.from); + } else { + const fromGroups = last.from; + setGroups((prev) => + prev.map((g) => { + if (g.name === last.to) { + const remove = new Set(last.items.map((i) => i.itemId)); + return { + ...g, + items: g.items.filter((i) => !remove.has(i.itemId)), + }; + } + const toRestore = fromGroups[g.name]; + if (toRestore) { + const existing = new Set(g.items.map((i) => i.itemId)); + const missing = toRestore.filter((i) => !existing.has(i.itemId)); + return missing.length === 0 + ? g + : { ...g, items: [...g.items, ...missing] }; + } + return g; + }), + ); + } + setHistory((prev) => prev.slice(0, -1)); + }; + const moveItemOptions = (fromName: string) => groups.filter((g) => g.name !== fromName); + const changeDays = (value: number) => { + setDays(value); + void load(value); + }; + + /** Split approved groups into runs that respect the 500-item / 50-group caps. */ + const chunkGroups = (payload: OrganizeGroupPayload[]) => { + const chunks: OrganizeGroupPayload[][] = []; + let current: OrganizeGroupPayload[] = []; + let currentItems = 0; + const flush = () => { + if (current.length > 0) { + chunks.push(current); + current = []; + currentItems = 0; + } + }; + for (const group of payload) { + if (group.items.length === 0) continue; + if (current.length >= MAX_GROUPS_PER_RUN) flush(); + // A single oversized group is split into multiple sequential groups + // with the same destination (move mode) or a " (2)" suffix (create mode). + if (group.items.length > MAX_ITEMS_PER_RUN) { + flush(); + for ( + let start = 0; + start < group.items.length; + start += MAX_ITEMS_PER_RUN + ) { + const part = group.items.slice(start, start + MAX_ITEMS_PER_RUN); + const partIndex = Math.floor(start / MAX_ITEMS_PER_RUN); + chunks.push([ + { + ...group, + caption: + group.destinationPageId != null || partIndex === 0 + ? group.caption + : `${group.caption} (${partIndex + 1})`, + items: part, + }, + ]); + } + continue; + } + if ( + currentItems + group.items.length > MAX_ITEMS_PER_RUN && + current.length > 0 + ) { + flush(); + } + current.push(group); + currentItems += group.items.length; + if (currentItems >= MAX_ITEMS_PER_RUN) flush(); + } + flush(); + return chunks; + }; + const handleCreate = async () => { if (isCreating) return; + // De-duplicate reads: don't run while nothing is approved. 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")); @@ -395,25 +656,35 @@ export function OrganizeImportsDialog({ }; }); - // Frontend validation for group count - const MAX_GROUPS = 50; - if (payload.length > MAX_GROUPS) { - toast.error(t("errorMaxGroups", { count: MAX_GROUPS })); - return; - } - - if (payload.some((g) => !g.caption)) { + if (payload.length === 0 || payload.some((g) => !g.caption)) { toast.error(t("errorNoName")); return; } + + const runs = chunkGroups(payload); setIsCreating(true); try { - const result = await organizeImportFurni({ parentId, groups: payload }); - if (!result.ok) throw new Error(result.error ?? "Failed"); - const created = result.data?.created ?? []; + const created: Array<{ pageId: number; moved: number; added: number }> = + []; + let failedRuns = 0; + for (const run of runs) { + const result = await organizeImportFurni({ + parentId, + groups: run, + }); + if (!result.ok) { + failedRuns++; + continue; + } + created.push(...(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) { + if (failedRuns > 0) { + toast.error( + t("errorPartial", { failed: failedRuns, total: runs.length }), + ); + } else if (moveMode) { toast.success( t("successMove", { moved, @@ -423,11 +694,14 @@ export function OrganizeImportsDialog({ ); } else { toast.success( - t("successCreate", { - count: created.length, - moved, - added, - }), + runs.length > 1 + ? t("successCreateRuns", { + count: created.length, + moved, + added, + runs: runs.length, + }) + : t("successCreate", { count: created.length, moved, added }), ); } onOpenChange(false); @@ -441,6 +715,7 @@ export function OrganizeImportsDialog({ }; const quickPicks = rootPages.slice(0, QUICK_PICKS); + const dupeList = totalDuplicates > 0; return ( <> @@ -582,25 +857,36 @@ export function OrganizeImportsDialog({
{t("destination")} {destinationPageId != null ? ( -
- - - {destinationCaption} - - - {t("destinationChosen")} - - +
+
+ + + {destinationCaption} + + {destDupesLoading && ( + + )} + + {t("destinationChosen")} + + +
+ {dupeList && ( +

+ + {t("dupeSummary", { count: totalDuplicates })} +

+ )}
) : ( <> @@ -646,8 +932,8 @@ export function OrganizeImportsDialog({ ) : ( destResults.map((p) => ( - - {Object.keys(overrides).length > 0 && ( - - )}
+ + {history.length > 0 && ( + + )} + {Object.keys(overrides).length > 0 && ( + + )}
{/* Move selected to another category */} @@ -844,10 +1161,7 @@ export function OrganizeImportsDialog({ - setSelection((prev) => { - const next = new Set(prev); - if (e.target.checked) next.add(item.itemId); - else next.delete(item.itemId); - return next; - }) - } - /> - -
-

- {item.publicName || item.itemName} -

-

- {item.itemName} -

-
- {item.alreadyPlaced ? ( - - {item.sourcePageCaption - ? item.sourcePageCaption.replace( - new RegExp(`^${t("impPrefix")}`), - "", - ) - : t("isMoved")} - - ) : ( - - {t("isNew")} - - )} - {moveTargets.length > 0 && ( - - )} - - ); - })} - - {group.items.length > ITEMS_VISIBLE && - (isExpanded ? ( - - ) : ( - - ))} + + setSelection((prev) => { + const next = new Set(prev); + if (checked) next.add(itemId); + else next.delete(itemId); + return next; + }) + } + onMoveItem={(item, toName) => + moveItemToGroup(item, group.name, toName) + } + onDragStart={(item) => + setDrag({ item, from: group.name }) + } + onDragEnd={(item) => { + if (drag?.item.itemId === item.itemId) { + setHoverGroup(null); + setDrag(null); + } + }} + /> ); @@ -1211,7 +1478,6 @@ export function OrganizeImportsDialog({ loading || isCreating || totalSelected === 0 || - totalSelected > MAX_ITEMS_PER_RUN || (moveMode && destinationPageId == null) } onClick={() => void handleCreate()} @@ -1260,28 +1526,15 @@ export function OrganizeImportsDialog({ {LAYOUT_DESCRIPTIONS[previewGroup.layout]}

)} -
-
- {previewGroup.items.slice(0, 40).map((item) => ( -
- - - {item.itemName} - -
- ))} -
- {previewGroup.items.length > 40 && ( -

- {t("showAll", { count: previewGroup.items.length })} -

- )} +
+

+ + {t("layoutPreview")} +

+
)} @@ -1291,6 +1544,202 @@ export function OrganizeImportsDialog({ ); } +interface GroupItemListProps { + items: OrganizeImportItem[]; + moveTargets: OrganizeImportGroup[]; + selection: Set; + destDupes: Set; + onToggleSelect: (itemId: number, checked: boolean) => void; + onMoveItem: (item: OrganizeImportItem, toName: string) => void; + onDragStart: (item: OrganizeImportItem) => void; + onDragEnd: (item: OrganizeImportItem) => void; +} + +// Virtualized row list, scrolled inside its own container so groups with +// hundreds of furniture items stay smooth. +function GroupItemList({ + items, + moveTargets, + selection, + destDupes, + onToggleSelect, + onMoveItem, + onDragStart, + onDragEnd, +}: GroupItemListProps) { + const t = useTranslations("pages.admin.catalog.workspace.organizeImports"); + const scrollRef = useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 34, + overscan: 14, + }); + + return ( +
8 ? "max-h-64" : "max-h-none", + )} + style={{ height: items.length > 8 ? undefined : "auto" }} + > + {items.length === 0 ? ( +

+ {t("emptyCategory")} +

+ ) : ( +
+ {rowVirtualizer.getVirtualItems().map((vItem) => { + const item = items[vItem.index]; + const checked = selection.has(item.itemId); + const isDupe = destDupes.has(item.itemName); + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: draggable row with native HTML5 DnD +
{ + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(item.itemId)); + onDragStart(item); + }} + onDragEnd={() => onDragEnd(item)} + > + + + onToggleSelect(item.itemId, e.target.checked) + } + /> + +
+

+ {item.publicName || item.itemName} +

+

+ {item.itemName} +

+
+ {item.alreadyPlaced ? ( + + {item.sourcePageCaption + ? item.sourcePageCaption.replace( + new RegExp(`^${t("impPrefix")}`), + "", + ) + : t("isMoved")} + + ) : ( + + {t("isNew")} + + )} + {isDupe && ( + + {t("dupeOnDestShort")} + + )} + {moveTargets.length > 0 && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} + +/** Schematic grid of how a layout places the first furniture items. */ +function LayoutPreview({ + layout, + items, + compact = false, +}: { + layout: string; + items: OrganizeImportItem[]; + compact?: boolean; +}) { + const grid = LAYOUT_GRIDS[layout]; + if (!grid) { + return ( +
+ + {layout} +
+ ); + } + const cells = grid.cols * grid.rows; + return ( +
+ {Array.from({ length: cells }).map((_, index) => { + const item = items[index]; + return item ? ( +
+ +
+ ) : ( +
+ ); + })} +
+ ); +} + function FurniThumb({ item, size = 24, diff --git a/src/messages/ar.json b/src/messages/ar.json index e271db9b..580d0c69 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -926,7 +926,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/bg.json b/src/messages/bg.json index ee8fcf51..49317d81 100644 --- a/src/messages/bg.json +++ b/src/messages/bg.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/cs.json b/src/messages/cs.json index 33cedb13..50508d6f 100644 --- a/src/messages/cs.json +++ b/src/messages/cs.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/da.json b/src/messages/da.json index e86d2fbc..3fa34a56 100644 --- a/src/messages/da.json +++ b/src/messages/da.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/de.json b/src/messages/de.json index f832e9ea..533572d5 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -2023,7 +2023,17 @@ "moveItemTo": "Verschieben zu…", "batchMoveTo": "Auswahl verschieben zu…", "selectedCount": "{count} ausgewählt", - "clearSelection": "Auswahl aufheben" + "clearSelection": "Auswahl aufheben", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/el.json b/src/messages/el.json index 399c4837..6a7d5e38 100644 --- a/src/messages/el.json +++ b/src/messages/el.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/en.json b/src/messages/en.json index e3599615..89f38d83 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2279,7 +2279,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "bulk": { diff --git a/src/messages/es.json b/src/messages/es.json index ef3e00ec..2f04ca88 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -2023,7 +2023,17 @@ "moveItemTo": "Mover a…", "batchMoveTo": "Mover seleccionados a…", "selectedCount": "{count} seleccionados", - "clearSelection": "Limpiar selección" + "clearSelection": "Limpiar selección", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/fi.json b/src/messages/fi.json index 73b9a6c5..2fe20bab 100644 --- a/src/messages/fi.json +++ b/src/messages/fi.json @@ -926,7 +926,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/fr.json b/src/messages/fr.json index 5db09502..1a4e2a39 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -2023,7 +2023,17 @@ "moveItemTo": "Déplacer vers…", "batchMoveTo": "Déplacer la sélection vers…", "selectedCount": "{count} sélectionné(s)", - "clearSelection": "Effacer la sélection" + "clearSelection": "Effacer la sélection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/hr.json b/src/messages/hr.json index dda42a73..d246d9d0 100644 --- a/src/messages/hr.json +++ b/src/messages/hr.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/hu.json b/src/messages/hu.json index 1cb471fb..4c3a4c23 100644 --- a/src/messages/hu.json +++ b/src/messages/hu.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/it.json b/src/messages/it.json index 54af6abd..a0e8df61 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -2198,7 +2198,17 @@ "moveItemTo": "Sposta verso…", "batchMoveTo": "Sposta la selezione verso…", "selectedCount": "{count} selezionati", - "clearSelection": "Azzera selezione" + "clearSelection": "Azzera selezione", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "bulk": { diff --git a/src/messages/ja.json b/src/messages/ja.json index d696c604..b1bb0e75 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -926,7 +926,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/nl.json b/src/messages/nl.json index c316c55c..bc3a0a65 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -2143,7 +2143,17 @@ "moveItemTo": "Verplaatsen naar…", "batchMoveTo": "Geselecteerde verplaatsen naar…", "selectedCount": "{count} geselecteerd", - "clearSelection": "Selectie wissen" + "clearSelection": "Selectie wissen", + "undo": "Ongedaan maken", + "daysLabel": "Dagen", + "daysValue": "{days} dagen", + "dupeSummary": "Al aanwezig op de bestemmingspagina: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layoutvoorbeeld", + "emptyCategory": "Geen items in deze categorie", + "dupeOnDest": "Staat al op de bestemmingspagina", + "dupeOnDestShort": "duplicaat", + "errorPartial": "{failed} van de {total} rondes mislukt. Controleer en probeer opnieuw.", + "successCreateRuns": "{count, plural, one {1 pagina} other {# pagina's}} aangemaakt in {runs, plural, one {1 ronde} other {# rondes}} met {moved} verplaatst en {added} nieuw meubilair" } }, "bulk": { diff --git a/src/messages/no.json b/src/messages/no.json index 2648632f..aa468909 100644 --- a/src/messages/no.json +++ b/src/messages/no.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/pl.json b/src/messages/pl.json index 9acad109..29acca42 100644 --- a/src/messages/pl.json +++ b/src/messages/pl.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/pt.json b/src/messages/pt.json index 70b13904..656d27dd 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Mover para…", "batchMoveTo": "Mover seleção para…", "selectedCount": "{count} selecionados", - "clearSelection": "Limpar seleção" + "clearSelection": "Limpar seleção", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/ro.json b/src/messages/ro.json index a1cce804..f305f1c4 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/ru.json b/src/messages/ru.json index 42b855f8..b43ecbfd 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -2051,7 +2051,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/sk.json b/src/messages/sk.json index 3dee14b9..795d7bbb 100644 --- a/src/messages/sk.json +++ b/src/messages/sk.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/sr.json b/src/messages/sr.json index 847dfba6..47b85b15 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/sv.json b/src/messages/sv.json index 6e33a111..1a64fac0 100644 --- a/src/messages/sv.json +++ b/src/messages/sv.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/tr.json b/src/messages/tr.json index deceb116..11d5c1ab 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Taşı…", "batchMoveTo": "Seçimi taşı…", "selectedCount": "{count} seçildi", - "clearSelection": "Seçimi temizle" + "clearSelection": "Seçimi temizle", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": { diff --git a/src/messages/uk.json b/src/messages/uk.json index 1ddaede5..f646224d 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -2053,7 +2053,17 @@ "moveItemTo": "Move to…", "batchMoveTo": "Move selected to…", "selectedCount": "{count} selected", - "clearSelection": "Clear selection" + "clearSelection": "Clear selection", + "undo": "Undo", + "daysLabel": "Days", + "daysValue": "{days} days", + "dupeSummary": "Already in the destination page: {count} item{count, plural, one {} other {s}}", + "layoutPreview": "Layout preview", + "emptyCategory": "No items in this category", + "dupeOnDest": "Already present on the destination page", + "dupeOnDestShort": "duplicate", + "errorPartial": "{failed} of {total} runs failed. Review and retry.", + "successCreateRuns": "Created {count, plural, one {1 page} other {# pages}} in {runs, plural, one {1 run} other {# runs}} with {moved} moved and {added} added furniture" } }, "discovery": {