refactor(studio): extract helper components from OrganizeImportsDialog
CI / check (push) Successful in 4m27s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m5s

Extract FurniThumb, LayoutPreview, and GroupItemList into a dedicated
mall-helpers module alongside OrganizeImportsDialog. Preserves all
virtualization, drag-and-drop, and preview behavior while reducing
the main dialog component size.
This commit is contained in:
openhands committed 2026-09-17 21:35:16 +02:00
1 parent 3862649369
commit 5923b736fa
3 files changed
+274 -262

No files matched your search

@@ -90,12 +90,12 @@ import {
import { useServerAction } from "@/hooks/use-server-action";
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
import { cn } from "@/lib/utils";
import { AddItemFormFields } from "./add-item-form-fields";
import {
InteractionTypeCombobox,
MovePageCombobox,
ToggleField,
} from "./field-helpers";
import { AddItemFormFields } from "./add-item-form-fields";
import { FurniIcon } from "./furni-icon";
import { SongPicker } from "./song-picker";
import {
@@ -1,13 +1,11 @@
"use client";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
AlertTriangle,
ArrowRight,
Eye,
Folder,
FolderTree,
GripVertical,
Home,
LayoutGrid,
Loader2,
@@ -21,7 +19,7 @@ import {
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
type OrganizeImportGroup as OrganizeGroupPayload,
@@ -29,6 +27,14 @@ import {
} from "@/actions/catalog";
import { IconPicker } from "@/components/admin/catalog/icon-picker";
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
import type {
OrganizeImportGroup,
OrganizeImportItem,
} from "@/components/admin/studio/organize-imports-dialog/mall-helpers";
import {
GroupItemList,
LayoutPreview,
} from "@/components/admin/studio/organize-imports-dialog/mall-helpers";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -51,7 +57,6 @@ import {
isValidCatalogLayout,
LAYOUT_DESCRIPTIONS,
} from "@/lib/catalog-layouts";
import { getLocalIconUrl } from "@/lib/furni/classname";
import { cn } from "@/lib/utils";
const MAX_ITEMS_PER_RUN = 500;
@@ -96,28 +101,6 @@ 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;
layout: CatalogLayout | null;
total: number;
moved: number;
added: number;
items: OrganizeImportItem[];
}
type MoveEntry =
| { kind: "item"; item: OrganizeImportItem; from: string; to: string }
| {
@@ -167,16 +150,6 @@ interface OrganizeImportsDialogProps {
onOpenChange: (open: boolean) => void;
}
/** Best-effort grid dimensions for the better-known catalog layouts. */
const LAYOUT_GRIDS: Record<string, { cols: number; rows: number }> = {
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,
@@ -1598,228 +1571,3 @@ export function OrganizeImportsDialog({
</>
);
}
interface GroupItemListProps {
items: OrganizeImportItem[];
moveTargets: OrganizeImportGroup[];
selection: Set<number>;
destDupes: Set<string>;
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<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 34,
overscan: 14,
});
return (
<div
ref={scrollRef}
className={cn(
"overflow-y-auto rounded border border-[var(--admin-border)] bg-[var(--admin-canvas)]",
items.length > 8 ? "max-h-64" : "max-h-none",
)}
style={{ height: items.length > 8 ? undefined : "auto" }}
>
{items.length === 0 ? (
<p className="px-2 py-2 text-[10px] text-muted-foreground">
{t("emptyCategory")}
</p>
) : (
<div
style={{
height: rowVirtualizer.getTotalSize(),
position: "relative",
}}
>
{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
<div
key={item.itemId}
className="absolute left-0 right-0 flex items-center gap-2 border-b border-[var(--admin-border)]/50 px-2 hover:bg-[var(--admin-surface-elevated)]"
style={{ top: vItem.start, height: vItem.size }}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(item.itemId));
onDragStart(item);
}}
onDragEnd={() => onDragEnd(item)}
>
<GripVertical className="h-3.5 w-3.5 shrink-0 cursor-grab text-muted-foreground/50" />
<input
type="checkbox"
checked={checked}
onChange={(e) =>
onToggleSelect(item.itemId, e.target.checked)
}
/>
<FurniThumb item={{ itemName: item.itemName }} size={26} />
<div className="min-w-0 flex-1 truncate">
<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>
)}
{isDupe && (
<span
title={t("dupeOnDest")}
className="shrink-0 rounded bg-[var(--admin-warning-subtle)] px-1.5 py-0.5 text-[9px] font-semibold text-[var(--admin-warning)]"
>
{t("dupeOnDestShort")}
</span>
)}
{moveTargets.length > 0 && (
<select
title={t("moveItemTo")}
value=""
onChange={(e) => {
onMoveItem(item, 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>
);
})}
</div>
)}
</div>
);
}
/** 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 (
<div
className={cn(
"flex items-center justify-center gap-1.5 rounded border border-dashed border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[10px] text-muted-foreground",
compact ? "h-12 w-24" : "h-20 w-40",
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
<span className="truncate">{layout}</span>
</div>
);
}
const cells = grid.cols * grid.rows;
return (
<div
className="grid gap-1 rounded border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-1.5"
style={{ gridTemplateColumns: `repeat(${grid.cols}, 1fr)` }}
>
{Array.from({ length: cells }).map((_, index) => {
const item = items[index];
return item ? (
<div
key={item.itemId}
className="flex items-center justify-center rounded bg-[var(--admin-surface)]"
style={{ width: compact ? 22 : 36, height: compact ? 22 : 36 }}
title={item.publicName || item.itemName}
>
<FurniThumb
item={{ itemName: item.itemName }}
size={compact ? 16 : 26}
/>
</div>
) : (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: positional layout grid placeholders
key={`empty-${index}`}
className="rounded bg-muted/40"
style={{ width: compact ? 22 : 36, height: compact ? 22 : 36 }}
/>
);
})}
</div>
);
}
function FurniThumb({
item,
size = 24,
}: {
item: { itemName: string };
size?: number;
}) {
const primary = getLocalIconUrl(item.itemName);
return (
<div
className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={item.itemName}
className="max-h-full max-w-full object-contain"
style={{ imageRendering: "pixelated" }}
onError={(e) => {
const img = e.target as HTMLImageElement;
img.style.opacity = "0";
}}
loading="lazy"
/>
</div>
);
}
@@ -0,0 +1,264 @@
"use client";
import { useVirtualizer } from "@tanstack/react-virtual";
import { GripVertical, LayoutGrid } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef } from "react";
import type { CatalogLayout } from "@/lib/catalog-layouts";
import { getLocalIconUrl } from "@/lib/furni/classname";
import { cn } from "@/lib/utils";
export interface OrganizeImportItem {
itemId: number;
catalogItemId: number | null;
alreadyPlaced: boolean;
itemName: string;
publicName: string;
spriteId: number;
type: string;
interactionType: string;
sourcePageCaption: string | null;
}
export interface OrganizeImportGroup {
name: string;
icon: number;
layout: CatalogLayout | null;
total: number;
moved: number;
added: number;
items: OrganizeImportItem[];
}
export const LAYOUT_GRIDS: Record<string, { cols: number; rows: number }> = {
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 FurniThumb({
item,
size = 24,
}: {
item: { itemName: string };
size?: number;
}) {
const primary = getLocalIconUrl(item.itemName);
return (
<div
className="flex items-center justify-center rounded bg-muted/50"
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={primary}
alt={item.itemName}
className="max-h-full max-w-full object-contain"
style={{ imageRendering: "pixelated" }}
onError={(e) => {
const img = e.target as HTMLImageElement;
img.style.opacity = "0";
}}
loading="lazy"
/>
</div>
);
}
/** Schematic grid of how a layout places the first furniture items. */
export function LayoutPreview({
layout,
items,
compact = false,
}: {
layout: string;
items: OrganizeImportItem[];
compact?: boolean;
}) {
const grid = LAYOUT_GRIDS[layout];
if (!grid) {
return (
<div
className={cn(
"flex items-center justify-center gap-1.5 rounded border border-dashed border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[10px] text-muted-foreground",
compact ? "h-12 w-24" : "h-20 w-40",
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
<span className="truncate">{layout}</span>
</div>
);
}
const cells = grid.cols * grid.rows;
return (
<div
className="grid gap-1 rounded border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-1.5"
style={{ gridTemplateColumns: `repeat(${grid.cols}, 1fr)` }}
>
{Array.from({ length: cells }).map((_, index) => {
const item = items[index];
return item ? (
<div
key={item.itemId}
className="flex items-center justify-center rounded bg-[var(--admin-surface)]"
style={{ width: compact ? 22 : 36, height: compact ? 22 : 36 }}
title={item.publicName || item.itemName}
>
<FurniThumb
item={{ itemName: item.itemName }}
size={compact ? 16 : 26}
/>
</div>
) : (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: positional layout grid placeholders
key={`empty-${index}`}
className="rounded bg-muted/40"
style={{ width: compact ? 22 : 36, height: compact ? 22 : 36 }}
/>
);
})}
</div>
);
}
interface GroupItemListProps {
items: OrganizeImportItem[];
moveTargets: OrganizeImportGroup[];
selection: Set<number>;
destDupes: Set<string>;
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.
export function GroupItemList({
items,
moveTargets,
selection,
destDupes,
onToggleSelect,
onMoveItem,
onDragStart,
onDragEnd,
}: GroupItemListProps) {
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
const scrollRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 34,
overscan: 14,
});
return (
<div
ref={scrollRef}
className={cn(
"overflow-y-auto rounded border border-[var(--admin-border)] bg-[var(--admin-canvas)]",
items.length > 8 ? "max-h-64" : "max-h-none",
)}
style={{ height: items.length > 8 ? undefined : "auto" }}
>
{items.length === 0 ? (
<p className="px-2 py-2 text-[10px] text-muted-foreground">
{t("emptyCategory")}
</p>
) : (
<div
style={{
height: rowVirtualizer.getTotalSize(),
position: "relative",
}}
>
{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
<div
key={item.itemId}
className="absolute left-0 right-0 flex items-center gap-2 border-b border-[var(--admin-border)]/50 px-2 hover:bg-[var(--admin-surface-elevated)]"
style={{ top: vItem.start, height: vItem.size }}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(item.itemId));
onDragStart(item);
}}
onDragEnd={() => onDragEnd(item)}
>
<GripVertical className="h-3.5 w-3.5 shrink-0 cursor-grab text-muted-foreground/50" />
<input
type="checkbox"
checked={checked}
onChange={(e) =>
onToggleSelect(item.itemId, e.target.checked)
}
/>
<FurniThumb item={{ itemName: item.itemName }} size={26} />
<div className="min-w-0 flex-1 truncate">
<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>
)}
{isDupe && (
<span
title={t("dupeOnDest")}
className="shrink-0 rounded bg-[var(--admin-warning-subtle)] px-1.5 py-0.5 text-[9px] font-semibold text-[var(--admin-warning)]"
>
{t("dupeOnDestShort")}
</span>
)}
{moveTargets.length > 0 && (
<select
title={t("moveItemTo")}
value=""
onChange={(e) => {
onMoveItem(item, 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>
);
})}
</div>
)}
</div>
);
}