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 { 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user