- Cache read-heavy public API routes via redisCache (leaderboard, values, shop, articles, photos, guilds, teams, staff, users, home, radio, badges) - Add single-flight and bounded-memory cache layer with unit tests - Parallelize independent DB queries on search, rares, shop, staff, polls and profile pages - Push radio points leaderboard aggregation to SQL with a LIMIT - Split studio-client and import-furni-client into focused modules - Clean up next.config.ts
2033 lines
62 KiB
TypeScript
2033 lines
62 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
AlertCircle,
|
|
ArrowUpDown,
|
|
BarChart3,
|
|
Check,
|
|
CheckCheck,
|
|
DatabaseZap,
|
|
Download,
|
|
ExternalLink,
|
|
FileWarning,
|
|
FolderSync,
|
|
Globe2,
|
|
LayoutGrid,
|
|
List,
|
|
Loader2,
|
|
MoreHorizontal,
|
|
Package,
|
|
Pencil,
|
|
RefreshCw,
|
|
Search,
|
|
ShoppingBag,
|
|
Trash2,
|
|
X,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { cleanSwfFiles, deleteImportedFurni } from "@/actions/import-furni";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
type RecentFurniResyncResult,
|
|
requestRecentFurniResync,
|
|
} from "@/lib/admin/recent-furni-resync";
|
|
import { adminFetch } from "@/lib/admin-fetch";
|
|
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
|
import {
|
|
getFurniIconUrl,
|
|
getFurniImageUrl,
|
|
handleImgError,
|
|
STATUS_LABELS,
|
|
} from "./import-furni-icons";
|
|
import type {
|
|
BatchItemStatus,
|
|
BatchSummary,
|
|
FurniItem,
|
|
FurniStats,
|
|
Meta,
|
|
} from "./import-furni-types";
|
|
import { NitroEditorDialog } from "./nitro-editor-dialog";
|
|
import { Pagination } from "./pagination";
|
|
|
|
export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [activeSearch, setActiveSearch] = useState("");
|
|
const [items, setItems] = useState<FurniItem[]>([]);
|
|
const [meta, setMeta] = useState<Meta | null>(null);
|
|
const [_page, setPage] = useState(1);
|
|
const [loading, setLoading] = useState(false);
|
|
const [importingId, setImportingId] = useState<string | null>(null);
|
|
const [batchImporting, setBatchImporting] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [cleaningSwf, setCleaningSwf] = useState(false);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
|
|
// Filters & sort
|
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
|
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
const [sortBy, setSortBy] = useState<string>("default");
|
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
|
|
|
// Batch regen SSE
|
|
const [regenProgress, setRegenProgress] = useState<Map<
|
|
string,
|
|
{ status: string; message?: string }
|
|
> | null>(null);
|
|
const [regenSummary, setRegenSummary] = useState<{
|
|
succeeded: number;
|
|
failed: number;
|
|
skipped: number;
|
|
duration: number;
|
|
} | null>(null);
|
|
const [batchRegenerating, setBatchRegenerating] = useState(false);
|
|
|
|
// Recently imported (this session)
|
|
const [_recentImports, setRecentImports] = useState<string[]>([]);
|
|
|
|
// Bulk delete
|
|
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
|
|
|
|
// Dashboard stats
|
|
const [stats, setStats] = useState<FurniStats | null>(null);
|
|
|
|
// Batch progress (SSE)
|
|
const [batchProgress, setBatchProgress] = useState<Map<
|
|
string,
|
|
BatchItemStatus
|
|
> | null>(null);
|
|
const [batchSummary, setBatchSummary] = useState<BatchSummary | null>(null);
|
|
|
|
// Nitro regeneration
|
|
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
|
|
// Item deletion
|
|
const [deletingItem, setDeletingItem] = useState<string | null>(null);
|
|
|
|
// Nitro editor
|
|
const [editingClassname, setEditingClassname] = useState<string | null>(null);
|
|
|
|
// Reorganize catalog
|
|
const [reorganizing, setReorganizing] = useState(false);
|
|
const [confirmRecentResync, setConfirmRecentResync] = useState(false);
|
|
const [recentResyncing, setRecentResyncing] = useState(false);
|
|
const [recentResyncResult, setRecentResyncResult] =
|
|
useState<RecentFurniResyncResult | null>(null);
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
const batchAbortRef = useRef<AbortController | null>(null);
|
|
|
|
// Confirmation dialogs
|
|
const [confirmBatch, setConfirmBatch] = useState(false);
|
|
const [confirmDelete, setConfirmDelete] = useState<FurniItem | null>(null);
|
|
const [reorgPreview, setReorgPreview] = useState<{
|
|
moved: number;
|
|
repriced: number;
|
|
skipped: number;
|
|
total: number;
|
|
categoryCounts: Record<string, number>;
|
|
} | null>(null);
|
|
|
|
const fetchStats = useCallback(async () => {
|
|
try {
|
|
const res = await adminFetch("/api/admin/import/furni?action=stats");
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
setStats({
|
|
totalInDb: data.totalInDb ?? 0,
|
|
inCatalog: data.inCatalog ?? 0,
|
|
missingNitro: data.missingNitro ?? 0,
|
|
notInCatalog: data.notInCatalog ?? 0,
|
|
});
|
|
}
|
|
} catch {
|
|
console.error("[Import] Failed to load stats");
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchStats();
|
|
}, [fetchStats]);
|
|
|
|
// Keyboard shortcuts: "/" focus search, Ctrl+A select all, Escape deselect
|
|
useEffect(() => {
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
|
|
|
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) {
|
|
e.preventDefault();
|
|
searchRef.current?.focus();
|
|
}
|
|
if (e.key === "a" && (e.metaKey || e.ctrlKey) && !isInput) {
|
|
e.preventDefault();
|
|
toggleSelectAll();
|
|
}
|
|
if (e.key === "Escape" && !isInput) {
|
|
setSelected(new Set());
|
|
}
|
|
}
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: toggleSelectAll is a state setter, stable
|
|
}, [toggleSelectAll]);
|
|
|
|
// Derived: unique categories from loaded items
|
|
const categories = useMemo(() => {
|
|
const cats = new Set(items.map((i) => i.category).filter(Boolean));
|
|
return ["all", ...Array.from(cats).sort()];
|
|
}, [items]);
|
|
|
|
// Derived: filtered + sorted items
|
|
const filteredItems = useMemo(() => {
|
|
let result: FurniItem[];
|
|
if (statusFilter === "missing-nitro") {
|
|
result = items.filter((i) => {
|
|
if (typeFilter !== "all" && i.type !== typeFilter) return false;
|
|
if (categoryFilter !== "all" && i.category !== categoryFilter)
|
|
return false;
|
|
return true;
|
|
});
|
|
} else {
|
|
result = items.filter((i) => {
|
|
if (typeFilter !== "all" && i.type !== typeFilter) return false;
|
|
if (categoryFilter !== "all" && i.category !== categoryFilter)
|
|
return false;
|
|
if (statusFilter === "not-imported" && i.alreadyImported) return false;
|
|
if (statusFilter === "imported" && !i.alreadyImported) return false;
|
|
if (statusFilter === "ok" && !(i.alreadyImported && i.nitroExists))
|
|
return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// Sort
|
|
if (sortBy === "name") result.sort((a, b) => a.name.localeCompare(b.name));
|
|
else if (sortBy === "classname")
|
|
result.sort((a, b) => a.classname.localeCompare(b.classname));
|
|
else if (sortBy === "category")
|
|
result.sort((a, b) => (a.category || "").localeCompare(b.category || ""));
|
|
else if (sortBy === "imported-first")
|
|
result.sort(
|
|
(a, b) => (b.alreadyImported ? 1 : 0) - (a.alreadyImported ? 1 : 0),
|
|
);
|
|
else if (sortBy === "not-imported-first")
|
|
result.sort(
|
|
(a, b) => (a.alreadyImported ? 1 : 0) - (b.alreadyImported ? 1 : 0),
|
|
);
|
|
|
|
return result;
|
|
}, [items, typeFilter, categoryFilter, statusFilter, sortBy]);
|
|
|
|
// Derived: importable (not yet imported) count in filtered set
|
|
const importableCount = useMemo(
|
|
() => filteredItems.filter((i) => !i.alreadyImported).length,
|
|
[filteredItems],
|
|
);
|
|
|
|
const fetchItems = useCallback(
|
|
async (search: string, p: number, status?: string) => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const params = new URLSearchParams({ page: String(p) });
|
|
if (search) params.set("search", search);
|
|
// Server-side filter for missing-nitro — much faster than client-side
|
|
if (status === "missing-nitro") {
|
|
params.set("action", "missing-nitro");
|
|
}
|
|
const res = await adminFetch(`/api/admin/import/furni?${params}`);
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setError(data.error || "Failed to load");
|
|
return;
|
|
}
|
|
const loadedItems = data.items || [];
|
|
setItems(loadedItems);
|
|
setMeta(data.meta || null);
|
|
setSelected(new Set());
|
|
} catch {
|
|
setError("Network error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
|
useEffect(() => {
|
|
fetchItems("", 1, statusFilter);
|
|
}, [fetchItems]);
|
|
|
|
// Auto-search with debounce (500ms)
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
if (searchTerm !== activeSearch) {
|
|
setActiveSearch(searchTerm);
|
|
setPage(1);
|
|
fetchItems(searchTerm, 1, statusFilter);
|
|
}
|
|
}, 500);
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
}, [searchTerm, fetchItems, activeSearch, statusFilter]);
|
|
|
|
// Re-fetch when status filter changes to/from missing-nitro
|
|
const prevStatusRef = useRef(statusFilter);
|
|
useEffect(() => {
|
|
if (prevStatusRef.current !== statusFilter) {
|
|
const wasMissing = prevStatusRef.current === "missing-nitro";
|
|
const isMissing = statusFilter === "missing-nitro";
|
|
prevStatusRef.current = statusFilter;
|
|
if (wasMissing || isMissing) {
|
|
setPage(1);
|
|
fetchItems(activeSearch, 1, statusFilter);
|
|
}
|
|
}
|
|
}, [statusFilter, activeSearch, fetchItems]);
|
|
|
|
function goPage(p: number) {
|
|
setPage(p);
|
|
fetchItems(activeSearch, p, statusFilter);
|
|
}
|
|
|
|
function toggleSelect(classname: string) {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(classname)) next.delete(classname);
|
|
else next.add(classname);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
const allSelected =
|
|
filteredItems.length > 0 &&
|
|
filteredItems.every((i) => selected.has(i.classname));
|
|
if (allSelected) {
|
|
setSelected(new Set());
|
|
} else {
|
|
setSelected(new Set(filteredItems.map((i) => i.classname)));
|
|
}
|
|
}
|
|
|
|
async function doImport(item: FurniItem) {
|
|
setImportingId(item.classname);
|
|
try {
|
|
const res = await adminFetch("/api/admin/import/furni", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
id: item.id,
|
|
classname: item.classname,
|
|
name: item.name,
|
|
description: item.description,
|
|
type: item.type,
|
|
revision: item.revision,
|
|
category: item.category,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
toast.success(`${item.name} imported`);
|
|
setRecentImports((prev) =>
|
|
[item.classname, ...prev.filter((c) => c !== item.classname)].slice(
|
|
0,
|
|
20,
|
|
),
|
|
);
|
|
setItems((prev) =>
|
|
prev.map((i) =>
|
|
i.classname === item.classname
|
|
? { ...i, alreadyImported: true, nitroExists: true }
|
|
: i,
|
|
),
|
|
);
|
|
setSelected((prev) => {
|
|
const n = new Set(prev);
|
|
n.delete(item.classname);
|
|
return n;
|
|
});
|
|
fetchStats();
|
|
} else {
|
|
toast.error(data.error || `Failed to import ${item.name}`);
|
|
}
|
|
} catch (err) {
|
|
console.error("[Import] Single import failed:", err);
|
|
toast.error(`Network error importing ${item.name}`);
|
|
} finally {
|
|
setImportingId(null);
|
|
}
|
|
}
|
|
|
|
async function doBatchImport() {
|
|
const toImport = filteredItems.filter(
|
|
(i) => selected.has(i.classname) && !i.alreadyImported,
|
|
);
|
|
if (toImport.length === 0) return;
|
|
|
|
setBatchImporting(true);
|
|
setBatchSummary(null);
|
|
|
|
// Initialize progress map
|
|
const initialProgress = new Map<string, BatchItemStatus>();
|
|
for (const i of toImport) {
|
|
initialProgress.set(i.classname, {
|
|
classname: i.classname,
|
|
status: "pending",
|
|
});
|
|
}
|
|
setBatchProgress(new Map(initialProgress));
|
|
|
|
const abort = new AbortController();
|
|
batchAbortRef.current = abort;
|
|
|
|
try {
|
|
const res = await adminFetch("/api/admin/import/furni/batch", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
items: toImport.map((i) => ({
|
|
id: i.id,
|
|
classname: i.classname,
|
|
name: i.name,
|
|
description: i.description,
|
|
type: i.type,
|
|
revision: i.revision,
|
|
category: i.category,
|
|
})),
|
|
concurrency: 3,
|
|
}),
|
|
signal: abort.signal,
|
|
});
|
|
|
|
if (!res.ok || !res.body) {
|
|
setBatchImporting(false);
|
|
return;
|
|
}
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data: ")) continue;
|
|
try {
|
|
const event = JSON.parse(line.slice(6));
|
|
|
|
if (event.type === "item_progress") {
|
|
setBatchProgress((prev) => {
|
|
if (!prev) return prev;
|
|
const next = new Map(prev);
|
|
next.set(event.classname, {
|
|
classname: event.classname,
|
|
status: event.status,
|
|
message: event.message,
|
|
warnings: event.warnings,
|
|
itemId: event.itemId,
|
|
});
|
|
return next;
|
|
});
|
|
|
|
if (event.status === "done") {
|
|
setItems((prev) =>
|
|
prev.map((i) =>
|
|
i.classname === event.classname
|
|
? { ...i, alreadyImported: true, nitroExists: true }
|
|
: i,
|
|
),
|
|
);
|
|
}
|
|
} else if (event.type === "batch_complete") {
|
|
setBatchSummary({
|
|
succeeded: event.succeeded,
|
|
failed: event.failed,
|
|
warnings: event.warnings,
|
|
duration: event.duration,
|
|
});
|
|
}
|
|
} catch {
|
|
/* skip malformed events */
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if ((err as Error).name === "AbortError") {
|
|
toast.info("Batch import cancelled");
|
|
} else {
|
|
console.error("[Import] Batch import error:", err);
|
|
toast.error(
|
|
"Connection lost during batch import. Check results above.",
|
|
);
|
|
}
|
|
} finally {
|
|
setBatchImporting(false);
|
|
batchAbortRef.current = null;
|
|
setSelected(new Set());
|
|
fetchStats();
|
|
}
|
|
}
|
|
|
|
async function cleanSwf() {
|
|
setCleaningSwf(true);
|
|
try {
|
|
const result = await cleanSwfFiles();
|
|
if (result.ok) {
|
|
const data = result.data as Record<string, unknown>;
|
|
toast.success(
|
|
`Deleted ${data.deleted} SWF files (${data.remaining} remaining)`,
|
|
);
|
|
} else {
|
|
toast.error(result.error || "Failed to clean SWF files");
|
|
}
|
|
} catch {
|
|
toast.error("Network error");
|
|
} finally {
|
|
setCleaningSwf(false);
|
|
}
|
|
}
|
|
|
|
async function regenNitro(item: FurniItem) {
|
|
setRegeneratingNitro((prev) => new Set(prev).add(item.classname));
|
|
try {
|
|
const res = await adminFetch("/api/admin/import/furni", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
items: [{ classname: item.classname, revision: item.revision }],
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && data.succeeded > 0) {
|
|
setItems((prev) =>
|
|
prev.map((i) =>
|
|
i.classname === item.classname ? { ...i, nitroExists: true } : i,
|
|
),
|
|
);
|
|
toast.success(`Nitro file regenerated for ${item.classname}`);
|
|
} else {
|
|
const warning =
|
|
data.results?.[0]?.warning || data.error || "Unknown error";
|
|
toast.error(`Failed: ${warning}`);
|
|
}
|
|
} catch {
|
|
toast.error("Network error");
|
|
} finally {
|
|
setRegeneratingNitro((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(item.classname);
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
|
|
const missingNitroItems = useMemo(() => {
|
|
return filteredItems.filter((i) => i.alreadyImported && !i.nitroExists);
|
|
}, [filteredItems]);
|
|
|
|
async function regenAllMissing() {
|
|
if (missingNitroItems.length === 0) return;
|
|
|
|
setBatchRegenerating(true);
|
|
setRegenSummary(null);
|
|
const initialProgress = new Map<
|
|
string,
|
|
{ status: string; message?: string }
|
|
>();
|
|
for (const i of missingNitroItems) {
|
|
initialProgress.set(i.classname, { status: "pending" });
|
|
}
|
|
setRegenProgress(new Map(initialProgress));
|
|
|
|
try {
|
|
const res = await adminFetch("/api/admin/import/furni/batch-regen", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
items: missingNitroItems.map((i) => ({
|
|
classname: i.classname,
|
|
revision: i.revision,
|
|
})),
|
|
concurrency: 3,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok || !res.body) {
|
|
toast.error("Failed to start batch regen");
|
|
setBatchRegenerating(false);
|
|
return;
|
|
}
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data: ")) continue;
|
|
try {
|
|
const event = JSON.parse(line.slice(6));
|
|
if (event.type === "regen_progress") {
|
|
setRegenProgress((prev) => {
|
|
if (!prev) return prev;
|
|
const next = new Map(prev);
|
|
next.set(event.classname, {
|
|
status: event.status,
|
|
message: event.message,
|
|
});
|
|
return next;
|
|
});
|
|
if (event.status === "done") {
|
|
setItems((prev) =>
|
|
prev.map((i) =>
|
|
i.classname === event.classname
|
|
? { ...i, nitroExists: true }
|
|
: i,
|
|
),
|
|
);
|
|
}
|
|
} else if (event.type === "regen_complete") {
|
|
setRegenSummary({
|
|
succeeded: event.succeeded,
|
|
failed: event.failed,
|
|
skipped: event.skipped,
|
|
duration: event.duration,
|
|
});
|
|
}
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
toast.error("Connection lost during regen");
|
|
} finally {
|
|
setBatchRegenerating(false);
|
|
setRegeneratingNitro(new Set());
|
|
fetchStats();
|
|
}
|
|
}
|
|
|
|
// Bulk delete selected imported items
|
|
async function doBulkDelete() {
|
|
const toDelete = filteredItems.filter(
|
|
(i) => selected.has(i.classname) && i.alreadyImported,
|
|
);
|
|
if (toDelete.length === 0) return;
|
|
setConfirmBulkDelete(false);
|
|
let ok = 0,
|
|
fail = 0;
|
|
for (const item of toDelete) {
|
|
setDeletingItem(item.classname);
|
|
try {
|
|
const result = await deleteImportedFurni({ classname: item.classname });
|
|
if (result.ok) {
|
|
ok++;
|
|
setItems((prev) =>
|
|
prev.map((i) =>
|
|
i.classname === item.classname
|
|
? { ...i, alreadyImported: false, nitroExists: false }
|
|
: i,
|
|
),
|
|
);
|
|
} else {
|
|
fail++;
|
|
}
|
|
} catch {
|
|
fail++;
|
|
}
|
|
}
|
|
setDeletingItem(null);
|
|
setSelected(new Set());
|
|
if (ok > 0) toast.success(`${ok} item(s) deleted`);
|
|
if (fail > 0) toast.error(`${fail} deletion(s) failed`);
|
|
fetchStats();
|
|
}
|
|
|
|
async function startReorganize() {
|
|
setReorganizing(true);
|
|
try {
|
|
const previewRes = await adminFetch("/api/admin/import/furni?dryrun=1", {
|
|
method: "PUT",
|
|
});
|
|
const previewData = await previewRes.json();
|
|
if (!previewRes.ok) {
|
|
toast.error(previewData.error || "Preview failed");
|
|
return;
|
|
}
|
|
if (previewData.moved === 0 && previewData.repriced === 0) {
|
|
toast.info(
|
|
previewData.message || "Everything is already correctly organized",
|
|
);
|
|
return;
|
|
}
|
|
setReorgPreview(previewData);
|
|
} catch {
|
|
toast.error("Network error");
|
|
} finally {
|
|
setReorganizing(false);
|
|
}
|
|
}
|
|
|
|
async function resyncRecentImports() {
|
|
setConfirmRecentResync(false);
|
|
setRecentResyncing(true);
|
|
setRecentResyncResult(null);
|
|
try {
|
|
const result = await requestRecentFurniResync(adminFetch);
|
|
setRecentResyncResult(result);
|
|
if (result.examined === 0) {
|
|
toast.info("No imported furni found in the last 7 days");
|
|
} else if (result.failed > 0 || !result.rconOk) {
|
|
toast.warning("Furni resync completed with warnings");
|
|
} else {
|
|
toast.success(`Updated ${result.resynced} imported furni`);
|
|
}
|
|
fetchStats();
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Furni resync failed",
|
|
);
|
|
} finally {
|
|
setRecentResyncing(false);
|
|
}
|
|
}
|
|
|
|
async function applyReorganize() {
|
|
setReorgPreview(null);
|
|
setReorganizing(true);
|
|
try {
|
|
const res = await adminFetch("/api/admin/import/furni", {
|
|
method: "PUT",
|
|
});
|
|
const d = await res.json();
|
|
if (res.ok) {
|
|
const parts: string[] = [];
|
|
if (d.moved > 0) parts.push(`${d.moved} moved`);
|
|
if (d.repriced > 0) parts.push(`${d.repriced} repriced`);
|
|
if (d.skipped > 0) parts.push(`${d.skipped} already ok`);
|
|
if (d.pagesDeleted > 0)
|
|
parts.push(`${d.pagesDeleted} empty pages cleaned`);
|
|
toast.success(`Done! ${parts.join(", ")} (${d.total} total)`);
|
|
} else {
|
|
toast.error(d.error || "Failed");
|
|
}
|
|
} catch {
|
|
toast.error("Network error");
|
|
} finally {
|
|
setReorganizing(false);
|
|
}
|
|
}
|
|
|
|
async function doDeleteItem(item: FurniItem) {
|
|
setConfirmDelete(null);
|
|
setDeletingItem(item.classname);
|
|
try {
|
|
const result = await deleteImportedFurni({ classname: item.classname });
|
|
if (result.ok) {
|
|
const data = result.data as Record<string, unknown>;
|
|
setItems((prev) =>
|
|
prev.map((i) =>
|
|
i.classname === item.classname
|
|
? { ...i, alreadyImported: false, nitroExists: false }
|
|
: i,
|
|
),
|
|
);
|
|
const files = (data.deletedFiles as string[])?.join(", ") || "none";
|
|
toast.success(`Deleted ${item.classname} (files: ${files})`);
|
|
if ((data.errors as string[])?.length > 0) {
|
|
toast.warning(`Some errors: ${(data.errors as string[]).join(", ")}`);
|
|
}
|
|
} else {
|
|
toast.error(result.error || "Delete failed");
|
|
}
|
|
} catch {
|
|
toast.error("Network error");
|
|
} finally {
|
|
setDeletingItem(null);
|
|
}
|
|
}
|
|
|
|
// ── Batch progress helpers ──────────────────────────────────────────
|
|
const progressStats = useMemo(() => {
|
|
if (!batchProgress) return null;
|
|
const arr = Array.from(batchProgress.values());
|
|
const done = arr.filter((s) => s.status === "done").length;
|
|
const failed = arr.filter((s) => s.status === "failed").length;
|
|
const completed = done + failed;
|
|
const percent =
|
|
arr.length > 0 ? Math.round((completed / arr.length) * 100) : 0;
|
|
return { completed, done, failed, total: arr.length, percent };
|
|
}, [batchProgress]);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-md bg-primary/10 p-2 text-primary">
|
|
<Globe2 className="h-4 w-4" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Official furnidata source
|
|
</p>
|
|
<p className="text-sm font-medium">{source.label}</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
Furniture assets: {source.assetCdnHost}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<a
|
|
href={source.furnidataUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
|
>
|
|
{source.host}
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
{/* Dashboard Statistics */}
|
|
{!stats && (
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
|
{Array.from({ length: 5 }, (_, i) => i).map((n) => (
|
|
<div
|
|
key={`skeleton-furni-${n}`}
|
|
className="rounded-lg border bg-card p-3 animate-pulse"
|
|
>
|
|
<div className="h-3 bg-muted rounded w-2/3 mb-2" />
|
|
<div className="h-6 bg-muted rounded w-1/2" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{stats && (
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
|
<div className="rounded-lg border bg-card p-3">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Package className="w-3.5 h-3.5" />
|
|
Total in DB
|
|
</div>
|
|
<div className="text-xl font-bold mt-1">
|
|
{stats.totalInDb.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border bg-card p-3">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<ShoppingBag className="w-3.5 h-3.5" />
|
|
In Catalog
|
|
</div>
|
|
<div className="text-xl font-bold mt-1">
|
|
{stats.inCatalog.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border bg-card p-3">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Check className="w-3.5 h-3.5" />
|
|
Imported
|
|
</div>
|
|
<div className="text-xl font-bold mt-1">
|
|
{items.filter((i) => i.alreadyImported).length.toLocaleString()}
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
on this page
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border bg-card p-3">
|
|
<div className="flex items-center gap-2 text-xs text-[var(--admin-warning)]">
|
|
<FileWarning className="w-3.5 h-3.5" />
|
|
Missing .nitro
|
|
</div>
|
|
<div className="text-xl font-bold mt-1 text-[var(--admin-warning)]">
|
|
{stats.missingNitro.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border bg-card p-3">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<BarChart3 className="w-3.5 h-3.5" />
|
|
Not in Catalog
|
|
</div>
|
|
<div className="text-xl font-bold mt-1">
|
|
{stats.notInCatalog.toLocaleString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{recentResyncResult && (
|
|
<div className="rounded-lg border bg-card p-4 space-y-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h3 className="text-sm font-semibold">Imported Furni Update</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setRecentResyncResult(null)}
|
|
>
|
|
Dismiss
|
|
</Button>
|
|
</div>
|
|
<div className="flex flex-wrap gap-4 text-sm">
|
|
<span>{recentResyncResult.examined} examined</span>
|
|
<span className="text-[var(--admin-success)]">
|
|
{recentResyncResult.resynced} updated
|
|
</span>
|
|
<span
|
|
className={recentResyncResult.failed ? "text-destructive" : ""}
|
|
>
|
|
{recentResyncResult.failed} failed
|
|
</span>
|
|
<span
|
|
className={
|
|
recentResyncResult.rconOk
|
|
? "text-[var(--admin-success)]"
|
|
: "text-[var(--admin-warning)]"
|
|
}
|
|
>
|
|
RCON {recentResyncResult.rconOk ? "refreshed" : "not refreshed"}
|
|
</span>
|
|
</div>
|
|
{recentResyncResult.errors.length > 0 && (
|
|
<details className="text-xs">
|
|
<summary className="cursor-pointer text-muted-foreground">
|
|
View errors
|
|
</summary>
|
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
|
|
{recentResyncResult.errors.map((error) => (
|
|
<p key={`${error.classname}:${error.message}`}>
|
|
<span className="font-mono">{error.classname}</span>:{" "}
|
|
{error.message}
|
|
</p>
|
|
))}
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Batch Summary */}
|
|
{batchSummary && (
|
|
<div className="rounded-lg border p-4 space-y-3 bg-card">
|
|
<h3 className="text-sm font-semibold">Import Complete</h3>
|
|
<div className="flex gap-4 text-sm">
|
|
<span className="text-[var(--admin-success)]">
|
|
{batchSummary.succeeded} succeeded
|
|
</span>
|
|
{batchSummary.failed > 0 && (
|
|
<span className="text-destructive">
|
|
{batchSummary.failed} failed
|
|
</span>
|
|
)}
|
|
{batchSummary.warnings > 0 && (
|
|
<span className="text-[var(--admin-warning)]">
|
|
{batchSummary.warnings} with warnings
|
|
</span>
|
|
)}
|
|
<span className="text-muted-foreground">
|
|
{(batchSummary.duration / 1000).toFixed(1)}s
|
|
</span>
|
|
</div>
|
|
{batchProgress && (
|
|
<details className="text-xs">
|
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
|
View details
|
|
</summary>
|
|
<div className="mt-2 space-y-1 max-h-[200px] overflow-y-auto">
|
|
{Array.from(batchProgress.values())
|
|
.filter(
|
|
(s) =>
|
|
s.status === "failed" ||
|
|
(s.warnings && s.warnings.length > 0),
|
|
)
|
|
.map((item) => (
|
|
<div
|
|
key={item.classname}
|
|
className="flex items-start gap-2 py-1"
|
|
>
|
|
{item.status === "failed" ? (
|
|
<XCircle className="w-3 h-3 text-destructive mt-0.5 shrink-0" />
|
|
) : (
|
|
<AlertCircle className="w-3 h-3 text-[var(--admin-warning)] mt-0.5 shrink-0" />
|
|
)}
|
|
<div className="min-w-0">
|
|
<span className="font-mono">{item.classname}</span>
|
|
{item.message && (
|
|
<p className="text-destructive">{item.message}</p>
|
|
)}
|
|
{item.warnings
|
|
?.map((w, i) => ({ w, n: i }))
|
|
.map(({ w, n }) => (
|
|
<p
|
|
key={`warn-${n}`}
|
|
className="text-[var(--admin-warning)]"
|
|
>
|
|
{w}
|
|
</p>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setBatchSummary(null);
|
|
setBatchProgress(null);
|
|
}}
|
|
>
|
|
Dismiss
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Batch Progress (during import) */}
|
|
{batchProgress && batchImporting && progressStats && (
|
|
<div className="space-y-3 rounded-lg border p-4 bg-card">
|
|
<div className="flex items-center gap-3">
|
|
<Loader2 className="w-4 h-4 animate-spin text-primary shrink-0" />
|
|
<div className="flex-1 h-2.5 rounded-full bg-muted overflow-hidden">
|
|
<div
|
|
className="h-full bg-primary transition-all duration-300"
|
|
style={{ width: `${progressStats.percent}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs font-mono text-muted-foreground shrink-0">
|
|
{progressStats.percent}%
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
<span>
|
|
{progressStats.completed}/{progressStats.total} completed
|
|
</span>
|
|
{progressStats.done > 0 && (
|
|
<span className="text-[var(--admin-success)]">
|
|
{progressStats.done} OK
|
|
</span>
|
|
)}
|
|
{progressStats.failed > 0 && (
|
|
<span className="text-destructive">
|
|
{progressStats.failed} failed
|
|
</span>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 ml-auto"
|
|
onClick={() => batchAbortRef.current?.abort()}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
<div className="max-h-[250px] overflow-y-auto space-y-0.5">
|
|
{Array.from(batchProgress.values()).map((item) => (
|
|
<div
|
|
key={item.classname}
|
|
className="flex items-center gap-2 text-xs py-1 px-2 rounded hover:bg-muted/50"
|
|
>
|
|
{item.status === "done" && (
|
|
<Check className="w-3 h-3 text-[var(--admin-success)] shrink-0" />
|
|
)}
|
|
{item.status === "failed" && (
|
|
<XCircle className="w-3 h-3 text-destructive shrink-0" />
|
|
)}
|
|
{[
|
|
"downloading",
|
|
"converting",
|
|
"writing_db",
|
|
"started",
|
|
].includes(item.status) && (
|
|
<Loader2 className="w-3 h-3 animate-spin text-primary shrink-0" />
|
|
)}
|
|
{item.status === "pending" && (
|
|
<div className="w-3 h-3 rounded-full border border-muted-foreground/30 shrink-0" />
|
|
)}
|
|
|
|
<span className="font-mono truncate flex-1 min-w-0">
|
|
{item.classname}
|
|
</span>
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] px-1.5 shrink-0"
|
|
>
|
|
{STATUS_LABELS[item.status] || item.status}
|
|
</Badge>
|
|
{item.message && (
|
|
<span className="text-destructive truncate max-w-[200px]">
|
|
{item.message}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Regen SSE Progress */}
|
|
{regenProgress &&
|
|
batchRegenerating &&
|
|
(() => {
|
|
const arr = Array.from(regenProgress.values());
|
|
const done = arr.filter((s) => s.status === "done").length;
|
|
const failed = arr.filter((s) => s.status === "failed").length;
|
|
const completed =
|
|
done + failed + arr.filter((s) => s.status === "skipped").length;
|
|
const percent =
|
|
arr.length > 0 ? Math.round((completed / arr.length) * 100) : 0;
|
|
return (
|
|
<div className="space-y-3 rounded-lg border p-4 bg-card">
|
|
<div className="flex items-center gap-3">
|
|
<Loader2 className="w-4 h-4 animate-spin text-primary shrink-0" />
|
|
<span className="text-sm font-medium">
|
|
Regenerating .nitro files...
|
|
</span>
|
|
<div className="flex-1 h-2.5 rounded-full bg-muted overflow-hidden">
|
|
<div
|
|
className="h-full bg-primary transition-all duration-300"
|
|
style={{ width: `${percent}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs font-mono text-muted-foreground shrink-0">
|
|
{percent}%
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
<span>
|
|
{completed}/{arr.length}
|
|
</span>
|
|
{done > 0 && (
|
|
<span className="text-[var(--admin-success)]">{done} OK</span>
|
|
)}
|
|
{failed > 0 && (
|
|
<span className="text-destructive">{failed} failed</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Regen Summary */}
|
|
{regenSummary && !batchRegenerating && (
|
|
<div className="rounded-lg border p-4 space-y-3 bg-card">
|
|
<h3 className="text-sm font-semibold">Regeneration Complete</h3>
|
|
<div className="flex gap-4 text-sm">
|
|
{regenSummary.succeeded > 0 && (
|
|
<span className="text-[var(--admin-success)]">
|
|
{regenSummary.succeeded} regenerated
|
|
</span>
|
|
)}
|
|
{regenSummary.failed > 0 && (
|
|
<span className="text-destructive">
|
|
{regenSummary.failed} failed
|
|
</span>
|
|
)}
|
|
{regenSummary.skipped > 0 && (
|
|
<span className="text-muted-foreground">
|
|
{regenSummary.skipped} skipped
|
|
</span>
|
|
)}
|
|
<span className="text-muted-foreground">
|
|
{(regenSummary.duration / 1000).toFixed(1)}s
|
|
</span>
|
|
</div>
|
|
{/* Show failed items details */}
|
|
{regenProgress &&
|
|
(() => {
|
|
const failedItems = Array.from(regenProgress.entries()).filter(
|
|
([, v]) => v.status === "failed",
|
|
);
|
|
if (failedItems.length === 0) return null;
|
|
return (
|
|
<details className="text-xs">
|
|
<summary className="cursor-pointer text-destructive hover:text-destructive/80">
|
|
View {failedItems.length} failed item
|
|
{failedItems.length > 1 ? "s" : ""}
|
|
</summary>
|
|
<div className="mt-2 space-y-1 max-h-[250px] overflow-y-auto">
|
|
{failedItems.map(([classname, info]) => (
|
|
<div
|
|
key={classname}
|
|
className="flex items-start gap-2 py-1 px-2 rounded hover:bg-muted/50"
|
|
>
|
|
<XCircle className="w-3 h-3 text-destructive mt-0.5 shrink-0" />
|
|
<div className="min-w-0">
|
|
<span className="font-mono">{classname}</span>
|
|
{info.message && (
|
|
<p className="text-muted-foreground">
|
|
{info.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
);
|
|
})()}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setRegenSummary(null);
|
|
setRegenProgress(null);
|
|
}}
|
|
>
|
|
Dismiss
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Toolbar: Search + View toggle */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="relative flex-1 min-w-0 max-w-md">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
ref={searchRef}
|
|
placeholder="Search furniture... /"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") {
|
|
setSearchTerm("");
|
|
setActiveSearch("");
|
|
searchRef.current?.blur();
|
|
}
|
|
}}
|
|
className="pl-9 pr-8"
|
|
/>
|
|
{searchTerm && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSearchTerm("");
|
|
setActiveSearch("");
|
|
fetchItems("", 1, statusFilter);
|
|
}}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{meta && (
|
|
<span className="text-xs font-bold text-muted-foreground shrink-0">
|
|
{meta.total.toLocaleString()} furni
|
|
</span>
|
|
)}
|
|
{/* View toggle */}
|
|
<div className="flex items-center rounded-md border bg-card ml-auto">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={`h-8 rounded-r-none ${viewMode === "grid" ? "bg-accent" : ""}`}
|
|
onClick={() => setViewMode("grid")}
|
|
>
|
|
<LayoutGrid className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={`h-8 rounded-l-none ${viewMode === "list" ? "bg-accent" : ""}`}
|
|
onClick={() => setViewMode("list")}
|
|
>
|
|
<List className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters + Sort row */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
|
<SelectTrigger className="w-[110px] h-8 text-xs">
|
|
<SelectValue placeholder="Type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Types</SelectItem>
|
|
<SelectItem value="flooritem">Floor</SelectItem>
|
|
<SelectItem value="wallitem">Wall</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
|
<SelectTrigger className="w-[130px] h-8 text-xs">
|
|
<SelectValue placeholder="Category" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{categories.map((cat) => (
|
|
<SelectItem key={cat} value={cat}>
|
|
{cat === "all" ? "All Categories" : cat}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="w-[150px] h-8 text-xs">
|
|
<SelectValue placeholder="Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Status</SelectItem>
|
|
<SelectItem value="not-imported">Not Imported</SelectItem>
|
|
<SelectItem value="imported">Imported</SelectItem>
|
|
<SelectItem value="missing-nitro">Missing .nitro</SelectItem>
|
|
<SelectItem value="ok">Imported + .nitro OK</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={sortBy} onValueChange={setSortBy}>
|
|
<SelectTrigger className="w-[160px] h-8 text-xs">
|
|
<ArrowUpDown className="h-3 w-3 mr-1" />
|
|
<SelectValue placeholder="Sort" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="default">Default order</SelectItem>
|
|
<SelectItem value="name">Name A-Z</SelectItem>
|
|
<SelectItem value="classname">Classname A-Z</SelectItem>
|
|
<SelectItem value="category">Category</SelectItem>
|
|
<SelectItem value="imported-first">Imported first</SelectItem>
|
|
<SelectItem value="not-imported-first">
|
|
Not imported first
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Badge variant="secondary" className="text-[10px] px-2 py-0.5 shrink-0">
|
|
{filteredItems.length} / {items.length}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Actions row */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{importableCount > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={toggleSelectAll}
|
|
className="text-xs h-8"
|
|
disabled={batchImporting}
|
|
>
|
|
<CheckCheck className="w-3.5 h-3.5 mr-1" />
|
|
{selected.size === importableCount
|
|
? "Deselect"
|
|
: `Select All (${importableCount})`}
|
|
</Button>
|
|
)}
|
|
{selected.size > 0 &&
|
|
(() => {
|
|
const hasImportable = filteredItems.some(
|
|
(i) => selected.has(i.classname) && !i.alreadyImported,
|
|
);
|
|
const hasImported = filteredItems.some(
|
|
(i) => selected.has(i.classname) && i.alreadyImported,
|
|
);
|
|
return (
|
|
<>
|
|
{hasImportable && (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setConfirmBatch(true)}
|
|
disabled={batchImporting}
|
|
className="h-8"
|
|
>
|
|
{batchImporting ? (
|
|
<Loader2 className="w-4 h-4 animate-spin mr-1" />
|
|
) : (
|
|
<Download className="w-4 h-4 mr-1" />
|
|
)}
|
|
Import{" "}
|
|
{
|
|
filteredItems.filter(
|
|
(i) => selected.has(i.classname) && !i.alreadyImported,
|
|
).length
|
|
}
|
|
</Button>
|
|
)}
|
|
{batchImporting && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={() => batchAbortRef.current?.abort()}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
{hasImported && (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => setConfirmBulkDelete(true)}
|
|
disabled={batchImporting}
|
|
className="h-8"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-1" />
|
|
Delete{" "}
|
|
{
|
|
filteredItems.filter(
|
|
(i) => selected.has(i.classname) && i.alreadyImported,
|
|
).length
|
|
}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSelected(new Set())}
|
|
className="h-8 text-xs"
|
|
>
|
|
Clear selection
|
|
</Button>
|
|
</>
|
|
);
|
|
})()}
|
|
{missingNitroItems.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={regenAllMissing}
|
|
disabled={batchRegenerating || regeneratingNitro.size > 0}
|
|
className="h-8 text-[var(--admin-warning)] border-[var(--admin-warning-border)] hover:bg-[var(--admin-warning-subtle)]"
|
|
>
|
|
{batchRegenerating ? (
|
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-4 h-4 mr-1" />
|
|
)}
|
|
Regen {missingNitroItems.length} .nitro
|
|
</Button>
|
|
)}
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" className="h-8 ml-auto">
|
|
<MoreHorizontal className="w-4 h-4 mr-1" /> Tools
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={() => setConfirmRecentResync(true)}
|
|
disabled={recentResyncing}
|
|
>
|
|
{recentResyncing ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<DatabaseZap className="mr-2 h-4 w-4" />
|
|
)}
|
|
{recentResyncing
|
|
? "Updating imported furni..."
|
|
: "Update imported furni"}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={startReorganize} disabled={reorganizing}>
|
|
<FolderSync className="w-4 h-4 mr-2" />
|
|
{reorganizing ? "Reorganizing..." : "Reorganize Catalog"}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={cleanSwf} disabled={cleaningSwf}>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
{cleaningSwf ? "Cleaning..." : "Clean SWF Files"}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
{/* Keyboard shortcuts hint */}
|
|
{selected.size === 0 && !loading && items.length > 0 && (
|
|
<div className="text-[10px] text-muted-foreground flex items-center gap-3">
|
|
<span>
|
|
<kbd className="rounded border bg-muted px-1 font-mono">/</kbd>{" "}
|
|
Search
|
|
</span>
|
|
<span>
|
|
<kbd className="rounded border bg-muted px-1 font-mono">Ctrl+A</kbd>{" "}
|
|
Select all
|
|
</span>
|
|
<span>
|
|
<kbd className="rounded border bg-muted px-1 font-mono">Esc</kbd>{" "}
|
|
Deselect
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Furni grid */}
|
|
{loading && items.length === 0 && (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
{Array.from({ length: 12 }, (_, i) => i).map((n) => (
|
|
<div
|
|
key={`skeleton-card-${n}`}
|
|
className="rounded-lg border overflow-hidden bg-card animate-pulse"
|
|
>
|
|
<div className="h-36 bg-muted" />
|
|
<div className="px-3 py-2 border-t space-y-2">
|
|
<div className="h-4 bg-muted rounded w-3/4" />
|
|
<div className="h-3 bg-muted rounded w-1/2" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!loading && items.length === 0 && !error && (
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<p className="text-sm">
|
|
No furniture found{activeSearch ? ` for "${activeSearch}"` : ""}.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{filteredItems.length > 0 && viewMode === "list" && (
|
|
<div
|
|
className={`rounded-md border overflow-auto max-h-[600px] ${loading ? "opacity-50" : ""}`}
|
|
>
|
|
<table className="w-full text-sm">
|
|
<thead className="sticky top-0 z-10 bg-background border-b">
|
|
<tr>
|
|
<th className="w-8 px-2 py-2">
|
|
<Checkbox
|
|
checked={
|
|
selected.size > 0 && selected.size === importableCount
|
|
}
|
|
onCheckedChange={toggleSelectAll}
|
|
/>
|
|
</th>
|
|
<th className="w-12 px-1 py-2" />
|
|
<th className="text-left px-2 py-2 font-medium">Name</th>
|
|
<th className="text-left px-2 py-2 font-medium">Classname</th>
|
|
<th className="text-left px-2 py-2 font-medium">Type</th>
|
|
<th className="text-left px-2 py-2 font-medium">Category</th>
|
|
<th className="text-left px-2 py-2 font-medium">Status</th>
|
|
<th className="w-24 px-2 py-2 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredItems.map((item) => (
|
|
<tr
|
|
key={item.classname}
|
|
className="border-b hover:bg-accent/30 transition-colors"
|
|
>
|
|
<td className="px-2 py-1.5">
|
|
{!item.alreadyImported && (
|
|
<Checkbox
|
|
checked={selected.has(item.classname)}
|
|
onCheckedChange={() => toggleSelect(item.classname)}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td className="px-1 py-1.5">
|
|
<img
|
|
src={getFurniImageUrl(item.classname, item.revision)}
|
|
alt=""
|
|
className="w-8 h-8 object-contain"
|
|
style={{ imageRendering: "pixelated" }}
|
|
onError={(e) =>
|
|
handleImgError(e, item.classname, item.revision)
|
|
}
|
|
/>
|
|
</td>
|
|
<td className="px-2 py-1.5 font-medium truncate max-w-[200px]">
|
|
{item.name}
|
|
</td>
|
|
<td className="px-2 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[180px]">
|
|
{item.classname}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-xs">
|
|
{item.type === "wallitem" ? "Wall" : "Floor"}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-xs text-muted-foreground">
|
|
{item.category || "-"}
|
|
</td>
|
|
<td className="px-2 py-1.5">
|
|
{item.alreadyImported ? (
|
|
item.nitroExists ? (
|
|
<Badge className="text-[10px] bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0">
|
|
OK
|
|
</Badge>
|
|
) : (
|
|
<Badge className="text-[10px] bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] border-0">
|
|
No .nitro
|
|
</Badge>
|
|
)
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-2 py-1.5">
|
|
<div className="flex gap-1">
|
|
{!item.alreadyImported && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 text-xs px-2"
|
|
onClick={() => doImport(item)}
|
|
disabled={importingId === item.classname}
|
|
>
|
|
{importingId === item.classname ? (
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
) : (
|
|
<Download className="w-3 h-3" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
{item.alreadyImported && item.nitroExists && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0"
|
|
onClick={() => setEditingClassname(item.classname)}
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
</Button>
|
|
)}
|
|
{item.alreadyImported && !item.nitroExists && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0 text-[var(--admin-warning)]"
|
|
onClick={() => regenNitro(item)}
|
|
disabled={regeneratingNitro.has(item.classname)}
|
|
>
|
|
{regeneratingNitro.has(item.classname) ? (
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-3 h-3" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
{item.alreadyImported && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 w-6 p-0 text-destructive"
|
|
onClick={() => setConfirmDelete(item)}
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{filteredItems.length > 0 && viewMode === "grid" && (
|
|
<div
|
|
className={`grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 ${loading ? "opacity-50" : ""}`}
|
|
>
|
|
{filteredItems.map((item) => (
|
|
<div
|
|
key={item.classname}
|
|
className={`rounded-lg border border-border/50 overflow-hidden bg-card hover:border-border transition-all group ${
|
|
item.alreadyImported
|
|
? "ring-1 ring-[var(--admin-success)]/30 hover:ring-2"
|
|
: "hover:shadow-md"
|
|
}`}
|
|
>
|
|
{/* Preview area with furni floor bg */}
|
|
<div
|
|
className="relative flex items-center justify-center h-36"
|
|
style={{
|
|
backgroundImage: `url(/images/furni_floor.png)`,
|
|
backgroundRepeat: "repeat",
|
|
backgroundPosition: "center",
|
|
}}
|
|
>
|
|
{/* Checkbox top-left (for non-imported AND imported) */}
|
|
<div className="absolute top-2 left-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Checkbox
|
|
checked={selected.has(item.classname)}
|
|
onCheckedChange={() => toggleSelect(item.classname)}
|
|
/>
|
|
</div>
|
|
{/* Import button top-right on hover */}
|
|
{!item.alreadyImported && (
|
|
<div className="absolute top-2 right-2 z-10 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
|
|
<Button
|
|
size="sm"
|
|
className="h-7 text-xs px-2 shadow-sm"
|
|
onClick={() => doImport(item)}
|
|
disabled={importingId === item.classname}
|
|
>
|
|
{importingId === item.classname ? (
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
) : (
|
|
<Download className="w-3 h-3" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{/* Hover detail tooltip */}
|
|
<div className="absolute bottom-1 left-1 right-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
|
<div className="bg-[var(--admin-overlay)] backdrop-blur-sm rounded px-2 py-1.5 text-[10px] text-[var(--admin-sidebar-text)] space-y-0.5">
|
|
{item.description && (
|
|
<p className="truncate">{item.description}</p>
|
|
)}
|
|
<div className="flex gap-2 text-[var(--admin-sidebar-text)]/70">
|
|
<span>Rev: {item.revision}</span>
|
|
<span>{item.type === "wallitem" ? "Wall" : "Floor"}</span>
|
|
<span>{item.category || "credit"}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Furni image */}
|
|
<img
|
|
src={getFurniImageUrl(item.classname, item.revision)}
|
|
alt={item.name}
|
|
className="max-h-[110px] max-w-[140px] drop-shadow-md transition-transform duration-200 group-hover:scale-110"
|
|
style={{ imageRendering: "pixelated" }}
|
|
onError={(e) =>
|
|
handleImgError(e, item.classname, item.revision)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Info bar */}
|
|
<div className="flex items-center gap-2 px-3 py-2 border-t">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-sm font-semibold truncate">
|
|
{item.name}
|
|
</span>
|
|
{item.alreadyImported && (
|
|
<>
|
|
{item.nitroExists ? (
|
|
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-[var(--admin-success-border)] gap-0.5 shrink-0">
|
|
<Check className="w-2.5 h-2.5" />
|
|
Imported
|
|
</Badge>
|
|
) : (
|
|
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] border-[var(--admin-warning-border)] gap-0.5 shrink-0">
|
|
<AlertCircle className="w-2.5 h-2.5" />
|
|
No .nitro
|
|
</Badge>
|
|
)}
|
|
{item.nitroExists ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0 shrink-0"
|
|
title="Edit Nitro metadata"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditingClassname(item.classname);
|
|
}}
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0 shrink-0 text-[var(--admin-warning)]"
|
|
title="Regenerate .nitro file"
|
|
disabled={regeneratingNitro.has(item.classname)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
regenNitro(item);
|
|
}}
|
|
>
|
|
{regeneratingNitro.has(item.classname) ? (
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-3 h-3" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0 shrink-0 text-destructive"
|
|
title="Delete imported item"
|
|
disabled={deletingItem === item.classname}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setConfirmDelete(item);
|
|
}}
|
|
>
|
|
{deletingItem === item.classname ? (
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-3 h-3" />
|
|
)}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{item.type === "wallitem" ? "Wall" : "Room"} -{" "}
|
|
{item.category || "credit"}
|
|
</span>
|
|
</div>
|
|
{/* Small icon */}
|
|
<div className="w-10 h-10 flex-shrink-0 flex items-center justify-center">
|
|
<img
|
|
src={getFurniIconUrl(item.classname, item.revision)}
|
|
alt=""
|
|
className="max-w-full max-h-full"
|
|
style={{ imageRendering: "pixelated" }}
|
|
onError={(e) =>
|
|
handleImgError(e, item.classname, item.revision)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{items.length > 0 && filteredItems.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<p className="text-sm">No furniture matches the selected filters.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
<Pagination meta={meta} loading={loading} onPageChange={goPage} />
|
|
|
|
{/* Nitro Editor Dialog */}
|
|
<NitroEditorDialog
|
|
classname={editingClassname ?? ""}
|
|
open={!!editingClassname}
|
|
onOpenChange={(open) => {
|
|
if (!open) setEditingClassname(null);
|
|
}}
|
|
/>
|
|
|
|
{/* Batch Import Confirmation */}
|
|
<Dialog open={confirmBatch} onOpenChange={setConfirmBatch}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Confirm Batch Import</DialogTitle>
|
|
<DialogDescription>
|
|
Import <strong>{selected.size}</strong> furniture items? This will
|
|
download SWFs, convert to .nitro, and add to the catalog.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setConfirmBatch(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setConfirmBatch(false);
|
|
doBatchImport();
|
|
}}
|
|
>
|
|
<Download className="w-4 h-4 mr-1" /> Start Import
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={confirmRecentResync} onOpenChange={setConfirmRecentResync}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Update imported furni?</DialogTitle>
|
|
<DialogDescription>
|
|
This updates FurnitureData for furni imported during the last 7
|
|
days, then refreshes the emulator catalog and item caches. No
|
|
database rows or asset files are deleted.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setConfirmRecentResync(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={resyncRecentImports} disabled={recentResyncing}>
|
|
Update last 7 days
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation */}
|
|
<Dialog
|
|
open={!!confirmDelete}
|
|
onOpenChange={() => setConfirmDelete(null)}
|
|
>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Delete Imported Item</DialogTitle>
|
|
<DialogDescription>
|
|
Delete <strong>{confirmDelete?.name}</strong> (
|
|
{confirmDelete?.classname})? This removes database records,
|
|
FurnitureData entry, .nitro, .swf, and icon files.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setConfirmDelete(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => confirmDelete && doDeleteItem(confirmDelete)}
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-1" /> Delete
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Bulk Delete Confirmation */}
|
|
<Dialog open={confirmBulkDelete} onOpenChange={setConfirmBulkDelete}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Bulk Delete</DialogTitle>
|
|
<DialogDescription>
|
|
Delete{" "}
|
|
<strong>
|
|
{
|
|
filteredItems.filter(
|
|
(i) => selected.has(i.classname) && i.alreadyImported,
|
|
).length
|
|
}
|
|
</strong>{" "}
|
|
imported item(s)? This removes database records, FurnitureData
|
|
entries, .nitro, .swf, and icon files for each.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setConfirmBulkDelete(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={doBulkDelete}>
|
|
<Trash2 className="w-4 h-4 mr-1" /> Delete All
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Reorganize Preview */}
|
|
<Dialog open={!!reorgPreview} onOpenChange={() => setReorgPreview(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Reorganize Catalog</DialogTitle>
|
|
<DialogDescription>
|
|
Preview of changes to {reorgPreview?.total} imported items:
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{reorgPreview && (
|
|
<div className="space-y-3 text-sm">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{reorgPreview.moved > 0 && (
|
|
<div className="rounded-lg border p-3 text-center">
|
|
<p className="text-lg font-bold text-primary">
|
|
{reorgPreview.moved}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Items to move
|
|
</p>
|
|
</div>
|
|
)}
|
|
{reorgPreview.repriced > 0 && (
|
|
<div className="rounded-lg border p-3 text-center">
|
|
<p className="text-lg font-bold text-[var(--admin-warning)]">
|
|
{reorgPreview.repriced}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Prices to update
|
|
</p>
|
|
</div>
|
|
)}
|
|
{reorgPreview.skipped > 0 && (
|
|
<div className="rounded-lg border p-3 text-center">
|
|
<p className="text-lg font-bold text-muted-foreground">
|
|
{reorgPreview.skipped}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Already correct
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{Object.keys(reorgPreview.categoryCounts || {}).length > 0 && (
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground mb-1">
|
|
Categories:
|
|
</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{Object.entries(reorgPreview.categoryCounts)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.map(([cat, count]) => (
|
|
<Badge
|
|
key={cat}
|
|
variant="secondary"
|
|
className="text-xs"
|
|
>
|
|
{cat}: {count}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setReorgPreview(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={applyReorganize}>
|
|
<FolderSync className="w-4 h-4 mr-1" /> Apply Changes
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|