refactor(studio): extract helper components from OrganizeImportsDialog
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:
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 { useServerAction } from "@/hooks/use-server-action";
|
||||||
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
|
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AddItemFormFields } from "./add-item-form-fields";
|
||||||
import {
|
import {
|
||||||
InteractionTypeCombobox,
|
InteractionTypeCombobox,
|
||||||
MovePageCombobox,
|
MovePageCombobox,
|
||||||
ToggleField,
|
ToggleField,
|
||||||
} from "./field-helpers";
|
} from "./field-helpers";
|
||||||
import { AddItemFormFields } from "./add-item-form-fields";
|
|
||||||
import { FurniIcon } from "./furni-icon";
|
import { FurniIcon } from "./furni-icon";
|
||||||
import { SongPicker } from "./song-picker";
|
import { SongPicker } from "./song-picker";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Eye,
|
Eye,
|
||||||
Folder,
|
Folder,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
GripVertical,
|
|
||||||
Home,
|
Home,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -21,7 +19,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
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 { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
type OrganizeImportGroup as OrganizeGroupPayload,
|
type OrganizeImportGroup as OrganizeGroupPayload,
|
||||||
@@ -29,6 +27,14 @@ import {
|
|||||||
} from "@/actions/catalog";
|
} from "@/actions/catalog";
|
||||||
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||||
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -51,7 +57,6 @@ import {
|
|||||||
isValidCatalogLayout,
|
isValidCatalogLayout,
|
||||||
LAYOUT_DESCRIPTIONS,
|
LAYOUT_DESCRIPTIONS,
|
||||||
} from "@/lib/catalog-layouts";
|
} from "@/lib/catalog-layouts";
|
||||||
import { getLocalIconUrl } from "@/lib/furni/classname";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const MAX_ITEMS_PER_RUN = 500;
|
const MAX_ITEMS_PER_RUN = 500;
|
||||||
@@ -96,28 +101,6 @@ interface RootPage {
|
|||||||
caption: string;
|
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 =
|
type MoveEntry =
|
||||||
| { kind: "item"; item: OrganizeImportItem; from: string; to: string }
|
| { kind: "item"; item: OrganizeImportItem; from: string; to: string }
|
||||||
| {
|
| {
|
||||||
@@ -167,16 +150,6 @@ interface OrganizeImportsDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
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({
|
export function OrganizeImportsDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user