2 Commits
Author SHA1 Message Date
openhands 5923b736fa 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.
2026-09-17 21:35:16 +02:00
openhands 3862649369 refactor(catalog): extract AddItemFormFields from CatalogItemsTable
Split the Add Item dialog form fields into its own module,
reducing the main table component size while preserving all
form fields, validation and handler logic.
2026-09-17 21:21:40 +02:00
4 changed files with 449 additions and 426 deletions

No files matched your search

@@ -0,0 +1,175 @@
"use client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { type DEFAULT_NEW_ITEM, POINTS_TYPES } from "./types";
export interface AddItemFormFieldsProps {
data: typeof DEFAULT_NEW_ITEM;
onChange: (key: string, value: string | number) => void;
}
export function AddItemFormFields({ data, onChange }: AddItemFormFieldsProps) {
return (
<div className="space-y-3">
<div className="space-y-2">
<Label>Catalog Name</Label>
<Input
value={data.catalogName}
onChange={(e) => onChange("catalogName", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Item IDs (semicolon-separated)</Label>
<Input
value={data.itemIds}
onChange={(e) => onChange("itemIds", e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">Credits</Label>
<Input
type="number"
value={data.costCredits}
onChange={(e) =>
onChange("costCredits", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Points</Label>
<Input
type="number"
value={data.costPoints}
onChange={(e) =>
onChange("costPoints", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Points Type</Label>
<Select
value={String(data.pointsType)}
onValueChange={(v) => onChange("pointsType", parseInt(v, 10))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(POINTS_TYPES).map(([val, { label }]) => (
<SelectItem key={val} value={val}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">Amount</Label>
<Input
type="number"
value={data.amount}
onChange={(e) =>
onChange("amount", parseInt(e.target.value, 10) || 1)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Order</Label>
<Input
type="number"
value={data.orderNumber}
onChange={(e) =>
onChange("orderNumber", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Offer ID</Label>
<Input
type="number"
value={data.offerId}
onChange={(e) => onChange("offerId", parseInt(e.target.value, 10))}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Limited Sells</Label>
<Input
type="number"
value={data.limitedSells}
onChange={(e) =>
onChange("limitedSells", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Limited Stack</Label>
<Input
type="number"
value={data.limitedStack}
onChange={(e) =>
onChange("limitedStack", parseInt(e.target.value, 10) || 0)
}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Have Offer</Label>
<Select
value={data.haveOffer}
onValueChange={(v) => onChange("haveOffer", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Yes</SelectItem>
<SelectItem value="0">No</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Club Only</Label>
<Select
value={data.clubOnly}
onValueChange={(v) => onChange("clubOnly", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No</SelectItem>
<SelectItem value="1">Yes</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Extra Data</Label>
<Input
value={data.extradata}
onChange={(e) => onChange("extradata", e.target.value)}
placeholder="Optional extra data"
/>
</div>
</div>
);
}
@@ -90,6 +90,7 @@ 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,
@@ -796,171 +797,6 @@ export function CatalogItemsTable({
}
// ── Add Item Form Fields (for "Add" dialog) ───────────────────
function AddItemFormFields({
data,
onChange,
}: {
data: typeof DEFAULT_NEW_ITEM;
onChange: (key: string, value: string | number) => void;
}) {
return (
<div className="space-y-3">
<div className="space-y-2">
<Label>Catalog Name</Label>
<Input
value={data.catalogName}
onChange={(e) => onChange("catalogName", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Item IDs (semicolon-separated)</Label>
<Input
value={data.itemIds}
onChange={(e) => onChange("itemIds", e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">Credits</Label>
<Input
type="number"
value={data.costCredits}
onChange={(e) =>
onChange("costCredits", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Points</Label>
<Input
type="number"
value={data.costPoints}
onChange={(e) =>
onChange("costPoints", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Points Type</Label>
<Select
value={String(data.pointsType)}
onValueChange={(v) => onChange("pointsType", parseInt(v, 10))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(POINTS_TYPES).map(([val, { label }]) => (
<SelectItem key={val} value={val}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">Amount</Label>
<Input
type="number"
value={data.amount}
onChange={(e) =>
onChange("amount", parseInt(e.target.value, 10) || 1)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Order</Label>
<Input
type="number"
value={data.orderNumber}
onChange={(e) =>
onChange("orderNumber", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Offer ID</Label>
<Input
type="number"
value={data.offerId}
onChange={(e) =>
onChange("offerId", parseInt(e.target.value, 10))
}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Limited Sells</Label>
<Input
type="number"
value={data.limitedSells}
onChange={(e) =>
onChange("limitedSells", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Limited Stack</Label>
<Input
type="number"
value={data.limitedStack}
onChange={(e) =>
onChange("limitedStack", parseInt(e.target.value, 10) || 0)
}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Have Offer</Label>
<Select
value={data.haveOffer}
onValueChange={(v) => onChange("haveOffer", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Yes</SelectItem>
<SelectItem value="0">No</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Club Only</Label>
<Select
value={data.clubOnly}
onValueChange={(v) => onChange("clubOnly", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No</SelectItem>
<SelectItem value="1">Yes</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Extra Data</Label>
<Input
value={data.extradata}
onChange={(e) => onChange("extradata", e.target.value)}
placeholder="Optional extra data"
/>
</div>
</div>
);
}
return (
<>
@@ -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>
);
}