Merge remote-tracking branch 'origin/main' into codex/housekeeping-rebuild-stepwise
# Conflicts: # .env.example # src/features/housekeeping/foundation/preview-route-contract.test.ts
This commit is contained in:
commit
52e00854a2
25 files changed
+1865
-654
No files matched your search
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, Check, CloudDownload, Loader2 } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
|
||||
interface SyncEvent {
|
||||
type: string;
|
||||
classname?: string;
|
||||
status?: string;
|
||||
succeeded?: number;
|
||||
failed?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Official furnidata sync client.
|
||||
* Imports all furniture from official Habbo furnidata that the hotel doesn't have yet.
|
||||
*/
|
||||
export function OfficialSyncClient() {
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [progress, setProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
const [result, setResult] = useState<SyncResult | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const startSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
setProgress(null);
|
||||
setLog([]);
|
||||
setResult(null);
|
||||
|
||||
const abort = new AbortController();
|
||||
abortRef.current = abort;
|
||||
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/import/official/sync-all", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
if (!res.body) {
|
||||
toast.error("No response stream");
|
||||
setSyncing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.headers.get("content-type")?.includes("text/event-stream")) {
|
||||
let errMsg = "Sync failed";
|
||||
try {
|
||||
const json = await res.json();
|
||||
errMsg = json.message || errMsg;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
toast.error(errMsg);
|
||||
setSyncing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let totalItems = 0;
|
||||
|
||||
while (true) {
|
||||
const { value, done: streamDone } = await reader.read();
|
||||
if (streamDone) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith("data: ")) continue;
|
||||
try {
|
||||
const evt = JSON.parse(part.slice(6)) as SyncEvent;
|
||||
if (evt.type === "item_progress") {
|
||||
const classname = evt.classname ?? "";
|
||||
if (evt.status === "done") {
|
||||
succeeded++;
|
||||
totalItems++;
|
||||
setProgress({ done: succeeded + failed, total: totalItems });
|
||||
setLog((prev) => [...prev, `✓ ${classname}`]);
|
||||
} else if (evt.status === "failed") {
|
||||
failed++;
|
||||
totalItems++;
|
||||
setProgress({ done: succeeded + failed, total: totalItems });
|
||||
setLog((prev) => [...prev, `✗ ${classname} (failed)`]);
|
||||
}
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
succeeded = evt.succeeded ?? succeeded;
|
||||
failed = evt.failed ?? failed;
|
||||
}
|
||||
if (evt.type === "error") {
|
||||
toast.error(
|
||||
typeof evt.message === "string"
|
||||
? evt.message
|
||||
: "Sync finished with errors",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// skip parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setResult({
|
||||
succeeded,
|
||||
failed,
|
||||
message:
|
||||
totalItems > 0
|
||||
? `${succeeded} items synced`
|
||||
: "All items were already up to date",
|
||||
});
|
||||
if (succeeded > 0) toast.success(`${succeeded} items synced`);
|
||||
if (failed > 0) toast.error(`${failed} items failed`);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
toast.info("Sync cancelled");
|
||||
} else {
|
||||
toast.error("Sync request failed");
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!result && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button onClick={startSync} disabled={syncing} size="lg">
|
||||
{syncing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<CloudDownload className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{syncing
|
||||
? `Syncing… ${progress ? `${progress.done}/${progress.total}` : ""}`
|
||||
: "Import All Missing from Official Furnidata"}
|
||||
</Button>
|
||||
{syncing && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => abortRef.current?.abort()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Processed {progress.done} items
|
||||
{progress.total > 0 ? ` out of ${progress.total}` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.length > 0 && (
|
||||
<div className="rounded-xl border bg-card p-4 max-h-80 overflow-x-auto overflow-y-auto space-y-0.5">
|
||||
{log.map((line) => (
|
||||
<div
|
||||
key={line}
|
||||
className="text-xs font-mono text-muted-foreground whitespace-nowrap"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="rounded-xl border bg-card p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{result.failed === 0 ? (
|
||||
<Check className="w-5 h-5 text-[var(--admin-success)]" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-[var(--admin-error)]" />
|
||||
)}
|
||||
<span className="text-lg font-semibold">Sync Complete</span>
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-muted-foreground">Succeeded:</span>{" "}
|
||||
{result.succeeded}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">Failed:</span>{" "}
|
||||
{result.failed}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{result.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!syncing && !result && (
|
||||
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground text-sm">
|
||||
<CloudDownload className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
||||
<p>
|
||||
Click the button above to scan the official Habbo furnidata and
|
||||
automatically import any furniture that your hotel doesn't have yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function PermissionRankError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<Card className="border-[var(--admin-error-border)]">
|
||||
<CardHeader>
|
||||
<CardTitle>Permission page error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The rank editor could not be rendered. The technical detail below is
|
||||
visible only inside this protected admin route.
|
||||
</p>
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 text-xs">
|
||||
{error.stack ?? error.message}
|
||||
</pre>
|
||||
{error.digest ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reference: {error.digest}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="button" onClick={reset}>
|
||||
Try again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-client";
|
||||
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -28,6 +29,17 @@ export default async function StudioSyncPage(_props: {
|
||||
</p>
|
||||
<SyncAllClient />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-muted-foreground mb-2">
|
||||
Official Furnidata
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan the official Habbo furnidata and automatically import any
|
||||
furniture that your hotel doesn't have yet.
|
||||
</p>
|
||||
<OfficialSyncClient />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getSource } from "@/lib/services/clone-sources";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import {
|
||||
appendFurniEntriesBulk,
|
||||
readFurniData,
|
||||
rebuildCatalogOfferIds,
|
||||
reconcileFurniDataWithItemsBase,
|
||||
verifyAndFixSpriteIds,
|
||||
@@ -311,6 +312,38 @@ export const GET = withAdmin(
|
||||
});
|
||||
|
||||
const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, "");
|
||||
|
||||
// Bulk-load FurnitureData and catalog references for status checks
|
||||
const furniDataSet = new Set<string>();
|
||||
const catalogIdSet = new Set<number>();
|
||||
try {
|
||||
const fd = (await readFurniData()) as {
|
||||
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
};
|
||||
for (const e of [
|
||||
...(fd.roomitemtypes?.furnitype ?? []),
|
||||
...(fd.wallitemtypes?.furnitype ?? []),
|
||||
]) {
|
||||
if (e.classname) furniDataSet.add(e.classname);
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
try {
|
||||
const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`);
|
||||
for (const row of (
|
||||
rows as unknown as [Array<{ item_ids: string | null }>, unknown]
|
||||
)[0]) {
|
||||
for (const part of String(row.item_ids ?? "").split(/[;,]/)) {
|
||||
const n = Number(part.trim());
|
||||
if (Number.isFinite(n) && n > 0) catalogIdSet.add(n);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
|
||||
const enriched = items.map((item) => {
|
||||
const imported = item.present;
|
||||
let nitroExists = false;
|
||||
@@ -336,6 +369,8 @@ export const GET = withAdmin(
|
||||
category: String(item.category ?? ""),
|
||||
alreadyImported: imported,
|
||||
nitroExists,
|
||||
hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined,
|
||||
hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined,
|
||||
iconUrl: sourceIconBase
|
||||
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
|
||||
: undefined,
|
||||
@@ -364,6 +399,37 @@ export const GET = withAdmin(
|
||||
allImported.map((e) => e.itemName).filter((n): n is string => !!n),
|
||||
);
|
||||
|
||||
// Bulk-load FurnitureData and catalog references for status checks
|
||||
const furniDataSet = new Set<string>();
|
||||
const catalogIdSet = new Set<number>();
|
||||
try {
|
||||
const fd = (await readFurniData()) as {
|
||||
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
};
|
||||
for (const e of [
|
||||
...(fd.roomitemtypes?.furnitype ?? []),
|
||||
...(fd.wallitemtypes?.furnitype ?? []),
|
||||
]) {
|
||||
if (e.classname) furniDataSet.add(e.classname);
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
try {
|
||||
const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`);
|
||||
for (const row of (
|
||||
rows as unknown as [Array<{ item_ids: string | null }>, unknown]
|
||||
)[0]) {
|
||||
for (const part of String(row.item_ids ?? "").split(/[;,]/)) {
|
||||
const n = Number(part.trim());
|
||||
if (Number.isFinite(n) && n > 0) catalogIdSet.add(n);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
|
||||
const result = await listFurniture({
|
||||
search: search || undefined,
|
||||
page,
|
||||
@@ -386,7 +452,13 @@ export const GET = withAdmin(
|
||||
);
|
||||
nitroExists = existsSync(/*turbopackIgnore: true*/ p);
|
||||
}
|
||||
return { ...item, alreadyImported: imported, nitroExists };
|
||||
return {
|
||||
...item,
|
||||
alreadyImported: imported,
|
||||
nitroExists,
|
||||
hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined,
|
||||
hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return apiOk({ items: enriched, meta: result.meta });
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { db, ItemsBase } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { importSingleFurni } from "@/lib/services/furni-import";
|
||||
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { runSseBatch } from "@/lib/services/import/core/sse-batch";
|
||||
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request) => {
|
||||
// Haal alle officiële furnidata
|
||||
const furnidata = await getOfficialHabboFurnidata();
|
||||
|
||||
// Haal alle bestaande classnames uit de database
|
||||
const existingClassnames = new Set(
|
||||
await db
|
||||
.select({ itemName: ItemsBase.itemName })
|
||||
.from(ItemsBase)
|
||||
.then((rows) => rows.map((r) => r.itemName)),
|
||||
);
|
||||
|
||||
// Filter naar alleen items die niet in de database staan
|
||||
const missingItems = Array.from(furnidata.entries())
|
||||
.filter(([, entry]) => !existingClassnames.has(entry.classname))
|
||||
.map(([, entry]) => ({
|
||||
classname: entry.classname,
|
||||
name: entry.name,
|
||||
description: entry.description ?? "",
|
||||
type: entry.category === "wallitem" ? "wallitem" : "flooritem",
|
||||
revision: entry.revision ?? 0,
|
||||
category: entry.category ?? "unknown",
|
||||
}));
|
||||
|
||||
if (missingItems.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "All items are already in the database",
|
||||
imported: 0,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (missingItems.length > 500) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
message: `Too many missing items (${missingItems.length}). Consider importing in smaller batches.`,
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Importeren via SSE batch
|
||||
return runSseBatch({
|
||||
items: missingItems,
|
||||
concurrency: 3,
|
||||
signal: request.signal,
|
||||
labelOf: (it) => it.classname,
|
||||
worker: async (it, _index, report) => {
|
||||
const result = await importSingleFurni({
|
||||
id: 0,
|
||||
classname: it.classname,
|
||||
name: it.name,
|
||||
description: it.description,
|
||||
type: it.type,
|
||||
revision: it.revision,
|
||||
category: it.category,
|
||||
onProgress: report,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
warnings: result.warnings,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
warnings: result.warnings,
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
flush: async () => {
|
||||
// FurnitureData.json wordt automatisch geschreven door importSingleFurni
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -2,12 +2,26 @@ import { notFound } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { env } from "@/env";
|
||||
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
export default async function AdminNextLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const hasProductionAccess =
|
||||
env.NODE_ENV === "production" && env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED
|
||||
? (await getHousekeepingCapabilityContext()).has(
|
||||
PERMS.HOUSEKEEPING_PREVIEW_ACCESS,
|
||||
)
|
||||
: false;
|
||||
|
||||
export default function AdminNextLayout({ children }: { children: ReactNode }) {
|
||||
if (
|
||||
!isHousekeepingPreviewEnabled({
|
||||
nodeEnv: env.NODE_ENV,
|
||||
flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
|
||||
hasProductionAccess,
|
||||
})
|
||||
) {
|
||||
notFound();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -32,6 +33,13 @@ function SummaryRow({
|
||||
);
|
||||
}
|
||||
|
||||
function formatEta(ms: number): string {
|
||||
if (ms < 60_000) return `${Math.ceil(ms / 1000)}s`;
|
||||
const min = Math.floor(ms / 60_000);
|
||||
const sec = Math.ceil((ms % 60_000) / 1000);
|
||||
return `${min}m ${sec}s`;
|
||||
}
|
||||
|
||||
export function BatchProgress({
|
||||
progress,
|
||||
done,
|
||||
@@ -39,8 +47,11 @@ export function BatchProgress({
|
||||
failed,
|
||||
cancelling,
|
||||
verification,
|
||||
startTime,
|
||||
failedClassnames,
|
||||
onCancel,
|
||||
onDismiss,
|
||||
onRetry,
|
||||
}: {
|
||||
progress: Map<string, BatchItemStatus>;
|
||||
done: boolean;
|
||||
@@ -48,8 +59,11 @@ export function BatchProgress({
|
||||
failed: number;
|
||||
cancelling?: boolean;
|
||||
verification?: VerificationSummary | null;
|
||||
startTime?: number;
|
||||
failedClassnames?: string[];
|
||||
onCancel?: () => void;
|
||||
onDismiss?: () => void;
|
||||
onRetry?: (classnames: string[]) => void;
|
||||
}) {
|
||||
const entries = [...progress.entries()];
|
||||
const total = entries.length;
|
||||
@@ -58,6 +72,27 @@ export function BatchProgress({
|
||||
).length;
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
// Live ETA calculation
|
||||
const [eta, setEta] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (done || !startTime || completed === 0) {
|
||||
setEta(null);
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - startTime;
|
||||
const avgPerItem = elapsed / completed;
|
||||
const remaining = (total - completed) * avgPerItem;
|
||||
setEta(formatEta(remaining));
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const avgPerItem = elapsed / completed;
|
||||
const remaining = (total - completed) * avgPerItem;
|
||||
setEta(formatEta(remaining));
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [done, startTime, completed, total]);
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--admin-border)] p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
@@ -81,6 +116,11 @@ export function BatchProgress({
|
||||
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
|
||||
{completed}/{total}
|
||||
</span>
|
||||
{eta && (
|
||||
<span className="text-xs font-mono text-[var(--admin-accent)]">
|
||||
~{eta}
|
||||
</span>
|
||||
)}
|
||||
<Loader2
|
||||
size={14}
|
||||
className="animate-spin text-[var(--admin-accent)]"
|
||||
@@ -90,11 +130,16 @@ export function BatchProgress({
|
||||
</div>
|
||||
|
||||
{!done && (
|
||||
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono font-bold text-[var(--admin-text)]">
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -165,10 +210,22 @@ export function BatchProgress({
|
||||
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
{done ? (
|
||||
<Button size="sm" variant="secondary" onClick={onDismiss}>
|
||||
<Check size={14} />
|
||||
Dismiss
|
||||
</Button>
|
||||
<>
|
||||
{failedClassnames && failedClassnames.length > 0 && onRetry && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onRetry(failedClassnames)}
|
||||
>
|
||||
<Loader2 size={14} />
|
||||
Retry {failedClassnames.length} failed
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="secondary" onClick={onDismiss}>
|
||||
<Check size={14} />
|
||||
Dismiss
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
setFurnidataTranslateEnabled,
|
||||
} from "@/actions/import-furni";
|
||||
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
previewAutoCatalog,
|
||||
} from "@/lib/furni/auto-catalog";
|
||||
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||
import { readSseStream } from "@/lib/sse-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TreeNode } from "@/types/catalog";
|
||||
import { BatchProgress } from "./batch-progress";
|
||||
@@ -164,6 +166,20 @@ export function StudioClient({
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Confirm dialog for clone all
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
|
||||
// Clone source stats (shown when a clone source is active)
|
||||
const [cloneStats, setCloneStats] = useState<{
|
||||
total: number;
|
||||
present: number;
|
||||
clonable: number;
|
||||
} | null>(null);
|
||||
const [cloneAllCancelling, setCloneAllCancelling] = useState(false);
|
||||
const cloneAllAbortRef = useRef<AbortController | null>(null);
|
||||
const batchStartTimeRef = useRef<number>(0);
|
||||
const [failedClassnames, setFailedClassnames] = useState<string[]>([]);
|
||||
|
||||
// Nitro regeneration (missing .nitro)
|
||||
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
|
||||
new Set(),
|
||||
@@ -237,17 +253,22 @@ export function StudioClient({
|
||||
if (categoryFilter !== "all") {
|
||||
result = result.filter((i) => i.category === categoryFilter);
|
||||
}
|
||||
if (statusFilter !== "all") {
|
||||
if (statusFilter === "imported")
|
||||
result = result.filter((i) => i.alreadyImported);
|
||||
else if (statusFilter === "inCatalog")
|
||||
result = result.filter((i) => i.alreadyImported && i.nitroExists);
|
||||
else if (statusFilter === "missingNitro")
|
||||
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
|
||||
}
|
||||
// Not imported filter - works independently (also with statusFilter === "all")
|
||||
if (statusFilter === "notImported" || statusFilter === "all")
|
||||
result = result.filter((i) => i.alreadyImported === false);
|
||||
if (statusFilter === "imported")
|
||||
result = result.filter((i) => i.alreadyImported);
|
||||
else if (statusFilter === "inCatalog")
|
||||
result = result.filter((i) => i.alreadyImported && i.nitroExists);
|
||||
else if (statusFilter === "missingNitro")
|
||||
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
|
||||
else if (statusFilter === "missingFurnidata")
|
||||
result = result.filter(
|
||||
(i) => i.alreadyImported && i.hasFurnidata === false,
|
||||
);
|
||||
else if (statusFilter === "missingCatalog")
|
||||
result = result.filter(
|
||||
(i) => i.alreadyImported && i.hasCatalogEntry === false,
|
||||
);
|
||||
else if (statusFilter === "notImported")
|
||||
result = result.filter((i) => !i.alreadyImported);
|
||||
const sorted = [...result];
|
||||
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
else if (sortBy === "classname")
|
||||
@@ -325,6 +346,9 @@ export function StudioClient({
|
||||
setItems([]);
|
||||
setActiveSearch("");
|
||||
setSearchTerm("");
|
||||
setCloneStats(null);
|
||||
setBatchProgress(null);
|
||||
fetchCloneStats(sourceId);
|
||||
};
|
||||
|
||||
const fetchSources = useCallback(async () => {
|
||||
@@ -339,6 +363,183 @@ export function StudioClient({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCloneStats = useCallback(async (sourceId: string) => {
|
||||
if (!sourceId) {
|
||||
setCloneStats(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await adminFetch(
|
||||
`/api/admin/import/clone?source=${encodeURIComponent(sourceId)}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCloneStats(data.stats ?? null);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function cloneAllMissing() {
|
||||
if (!activeSource) return;
|
||||
|
||||
// Fetch all clonable classnames
|
||||
let classnames: string[] = [];
|
||||
try {
|
||||
const res = await adminFetch(
|
||||
`/api/admin/import/clone?source=${encodeURIComponent(activeSource)}&action=clonable`,
|
||||
);
|
||||
const data = (await res.json()) as {
|
||||
classnames?: string[];
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || "Failed to fetch clonable items");
|
||||
return;
|
||||
}
|
||||
classnames = data.classnames || [];
|
||||
} catch {
|
||||
toast.error("Network error fetching clonable items");
|
||||
return;
|
||||
}
|
||||
|
||||
if (classnames.length === 0) {
|
||||
toast.info("Alles is al geïmporteerd voor deze bron");
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceName =
|
||||
sources.find((s) => s.id === activeSource)?.name || activeSource;
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: "Alles importeren",
|
||||
description: `${classnames.length} items importeren uit "${sourceName}"? Dit kan even duren.`,
|
||||
confirmLabel: "Alles importeren",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
// Initialize progress tracking as a Map for BatchProgress compatibility
|
||||
const initial = new Map<string, BatchItemStatus>();
|
||||
for (const cn of classnames) {
|
||||
initial.set(cn, { classname: cn, status: "pending" });
|
||||
}
|
||||
setBatchProgress(new Map(initial));
|
||||
setBatchDone(false);
|
||||
setBatchSucceeded(0);
|
||||
setBatchFailed(0);
|
||||
setFailedClassnames([]);
|
||||
setCloneAllCancelling(false);
|
||||
batchStartTimeRef.current = Date.now();
|
||||
|
||||
const abort = new AbortController();
|
||||
cloneAllAbortRef.current = abort;
|
||||
|
||||
const CHUNK = 400;
|
||||
let done = 0;
|
||||
let okCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < classnames.length; i += CHUNK) {
|
||||
if (abort.signal.aborted) break;
|
||||
const chunk = classnames.slice(i, i + CHUNK);
|
||||
|
||||
const res = await adminFetch("/api/admin/import/clone/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceId: activeSource,
|
||||
classnames: chunk,
|
||||
concurrency: 10,
|
||||
}),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
toast.error(`Batch chunk mislukt (${res.status})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await readSseStream(
|
||||
res.body,
|
||||
(evt) => {
|
||||
if (evt.type === "item_progress") {
|
||||
const cn = String(evt.classname ?? "");
|
||||
setBatchProgress((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(cn, {
|
||||
classname: cn,
|
||||
status: evt.status as BatchItemStatus["status"],
|
||||
message: evt.message as string | undefined,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
if (evt.status === "done" || evt.status === "failed") {
|
||||
done++;
|
||||
if (evt.status === "failed") {
|
||||
setFailedClassnames((prev) =>
|
||||
prev.includes(cn) ? prev : [...prev, cn],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
okCount += Number(evt.succeeded ?? 0);
|
||||
failCount += Number(evt.failed ?? 0);
|
||||
}
|
||||
if (evt.type === "error") {
|
||||
toast.error(
|
||||
typeof evt.message === "string" ? evt.message : "Import fout",
|
||||
);
|
||||
}
|
||||
},
|
||||
abort.signal,
|
||||
);
|
||||
}
|
||||
|
||||
if (!abort.signal.aborted) {
|
||||
setBatchSucceeded(okCount);
|
||||
setBatchFailed(failCount);
|
||||
setBatchDone(true);
|
||||
|
||||
if (okCount > 0) {
|
||||
toast.success(
|
||||
`${okCount} items geïmporteerd${
|
||||
failCount > 0 ? ` (${failCount} mislukt)` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (failCount > 0 && okCount === 0) {
|
||||
toast.error(`${failCount} items mislukt`);
|
||||
}
|
||||
|
||||
fetchItems(activeSearch, 1, activeSource);
|
||||
fetchStats();
|
||||
fetchCloneStats(activeSource);
|
||||
|
||||
// Auto-translate after clone all if translation is enabled
|
||||
if (translate && okCount > 0) {
|
||||
toast.info("Automatisch vertalen van alle talen...");
|
||||
buildAllLanguages();
|
||||
}
|
||||
} else {
|
||||
toast.info("Clone all geannuleerd");
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === "AbortError") {
|
||||
toast.info("Clone all geannuleerd");
|
||||
} else {
|
||||
toast.error("Verbindingsfout tijdens clone all");
|
||||
}
|
||||
} finally {
|
||||
cloneAllAbortRef.current = null;
|
||||
setCloneAllCancelling(false);
|
||||
setSelected(new Set());
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems("", 1, activeSource);
|
||||
fetchStats();
|
||||
@@ -492,6 +693,8 @@ export function StudioClient({
|
||||
setBatchSucceeded(0);
|
||||
setBatchFailed(0);
|
||||
setBatchCancelling(false);
|
||||
setFailedClassnames([]);
|
||||
batchStartTimeRef.current = Date.now();
|
||||
|
||||
const initial = new Map<string, BatchItemStatus>();
|
||||
for (const i of toImport) {
|
||||
@@ -565,6 +768,12 @@ export function StudioClient({
|
||||
: i,
|
||||
),
|
||||
);
|
||||
} else if (event.status === "failed") {
|
||||
setFailedClassnames((prev) =>
|
||||
prev.includes(event.classname)
|
||||
? prev
|
||||
: [...prev, event.classname],
|
||||
);
|
||||
}
|
||||
} else if (event.type === "batch_complete") {
|
||||
setBatchSucceeded(event.succeeded);
|
||||
@@ -607,6 +816,103 @@ export function StudioClient({
|
||||
}
|
||||
};
|
||||
|
||||
const cancelCloneAll = () => {
|
||||
if (cloneAllAbortRef.current) {
|
||||
setCloneAllCancelling(true);
|
||||
cloneAllAbortRef.current.abort();
|
||||
}
|
||||
};
|
||||
|
||||
async function retryFailed(failed: string[]) {
|
||||
if (!activeSource || failed.length === 0) return;
|
||||
// For clone sources, re-run cloneAllMissing with only the failed classnames
|
||||
setBatchProgress(new Map());
|
||||
setBatchDone(false);
|
||||
setBatchSucceeded(0);
|
||||
setBatchFailed(0);
|
||||
setFailedClassnames([]);
|
||||
batchStartTimeRef.current = Date.now();
|
||||
|
||||
const initial = new Map<string, BatchItemStatus>();
|
||||
for (const cn of failed) {
|
||||
initial.set(cn, { classname: cn, status: "pending" });
|
||||
}
|
||||
setBatchProgress(new Map(initial));
|
||||
|
||||
const abort = new AbortController();
|
||||
cloneAllAbortRef.current = abort;
|
||||
let done = 0;
|
||||
let okCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/import/clone/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceId: activeSource,
|
||||
classnames: failed,
|
||||
concurrency: 10,
|
||||
}),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
toast.error(`Retry mislukt (${res.status})`);
|
||||
return;
|
||||
}
|
||||
|
||||
await readSseStream(
|
||||
res.body,
|
||||
(evt) => {
|
||||
if (evt.type === "item_progress") {
|
||||
const cn = String(evt.classname ?? "");
|
||||
setBatchProgress((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(cn, {
|
||||
classname: cn,
|
||||
status: evt.status as BatchItemStatus["status"],
|
||||
message: evt.message as string | undefined,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
if (evt.status === "done" || evt.status === "failed") {
|
||||
done++;
|
||||
if (evt.status === "failed") {
|
||||
setFailedClassnames((prev) =>
|
||||
prev.includes(cn) ? prev : [...prev, cn],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (evt.type === "batch_complete") {
|
||||
okCount += Number(evt.succeeded ?? 0);
|
||||
failCount += Number(evt.failed ?? 0);
|
||||
}
|
||||
},
|
||||
abort.signal,
|
||||
);
|
||||
|
||||
setBatchSucceeded(okCount);
|
||||
setBatchFailed(failCount);
|
||||
setBatchDone(true);
|
||||
|
||||
if (okCount > 0) {
|
||||
toast.success(`${okCount} items opnieuw geïmporteerd`);
|
||||
}
|
||||
fetchItems(activeSearch, 1, activeSource);
|
||||
fetchStats();
|
||||
fetchCloneStats(activeSource);
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name !== "AbortError") {
|
||||
toast.error("Verbindingsfout tijdens retry");
|
||||
}
|
||||
} finally {
|
||||
cloneAllAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
@@ -690,8 +996,11 @@ export function StudioClient({
|
||||
);
|
||||
|
||||
const notImportedCount = useMemo(
|
||||
() => filteredItems.filter((i) => i.alreadyImported === false).length,
|
||||
[filteredItems],
|
||||
() =>
|
||||
activeSource && cloneStats
|
||||
? cloneStats.clonable
|
||||
: filteredItems.filter((i) => i.alreadyImported === false).length,
|
||||
[filteredItems, activeSource, cloneStats],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -748,7 +1057,7 @@ export function StudioClient({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{stats && (
|
||||
{stats && !activeSource && (
|
||||
<>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Package size={11} /> {stats.totalInDb} in DB
|
||||
@@ -767,6 +1076,26 @@ export function StudioClient({
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{cloneStats && activeSource && (
|
||||
<>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Package size={11} /> {cloneStats.total} total
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check size={11} className="text-[var(--admin-success)]" />{" "}
|
||||
{cloneStats.present} aanwezig
|
||||
</Badge>
|
||||
{cloneStats.clonable > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 text-[var(--admin-accent)] border-[var(--admin-accent)]/40"
|
||||
>
|
||||
<CloudDownload size={11} /> {cloneStats.clonable} te
|
||||
importeren
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 py-1">
|
||||
@@ -848,11 +1177,17 @@ export function StudioClient({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
All statuses {notImportedCount > 0 && notImportedCount}
|
||||
All statuses{" "}
|
||||
{notImportedCount > 0 && `(${notImportedCount} te importeren)`}
|
||||
</SelectItem>
|
||||
<SelectItem value="notImported">Not imported</SelectItem>
|
||||
<SelectItem value="imported">Imported</SelectItem>
|
||||
<SelectItem value="inCatalog">In catalog</SelectItem>
|
||||
<SelectItem value="missingNitro">Missing nitro</SelectItem>
|
||||
<SelectItem value="missingFurnidata">Missing furnidata</SelectItem>
|
||||
<SelectItem value="missingCatalog">
|
||||
Missing catalog entry
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -1044,6 +1379,13 @@ export function StudioClient({
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
|
||||
<Button size="sm" className="gap-1.5" onClick={cloneAllMissing}>
|
||||
<CloudDownload size={14} />
|
||||
Alles importeren ({cloneStats.clonable})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{selectedNonImported > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1162,17 +1504,42 @@ export function StudioClient({
|
||||
{TYPE_LABELS[item.type] ?? item.type}
|
||||
</Badge>
|
||||
{item.alreadyImported ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-4",
|
||||
item.nitroExists
|
||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
||||
<>
|
||||
{!item.nitroExists && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No nitro
|
||||
</Badge>
|
||||
)}
|
||||
>
|
||||
{item.nitroExists ? "Imported" : "No nitro"}
|
||||
</Badge>
|
||||
{item.hasFurnidata === false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No furnidata
|
||||
</Badge>
|
||||
)}
|
||||
{item.hasCatalogEntry === false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No catalog
|
||||
</Badge>
|
||||
)}
|
||||
{item.nitroExists &&
|
||||
item.hasFurnidata !== false &&
|
||||
item.hasCatalogEntry !== false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||
>
|
||||
Imported
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
New
|
||||
@@ -1286,17 +1653,42 @@ export function StudioClient({
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{item.alreadyImported ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-4",
|
||||
item.nitroExists
|
||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{!item.nitroExists && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No nitro
|
||||
</Badge>
|
||||
)}
|
||||
>
|
||||
{item.nitroExists ? "Imported" : "No nitro"}
|
||||
</Badge>
|
||||
{item.hasFurnidata === false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No furnidata
|
||||
</Badge>
|
||||
)}
|
||||
{item.hasCatalogEntry === false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||
>
|
||||
No catalog
|
||||
</Badge>
|
||||
)}
|
||||
{item.nitroExists &&
|
||||
item.hasFurnidata !== false &&
|
||||
item.hasCatalogEntry !== false && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 text-[0.6rem] text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||
>
|
||||
Imported
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
New
|
||||
@@ -1401,14 +1793,20 @@ export function StudioClient({
|
||||
done={batchDone}
|
||||
succeeded={batchSucceeded}
|
||||
failed={batchFailed}
|
||||
cancelling={batchCancelling}
|
||||
cancelling={batchCancelling || cloneAllCancelling}
|
||||
verification={verification}
|
||||
onCancel={cancelBatch}
|
||||
startTime={batchStartTimeRef.current || undefined}
|
||||
failedClassnames={
|
||||
failedClassnames.length > 0 ? failedClassnames : undefined
|
||||
}
|
||||
onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch}
|
||||
onDismiss={() => {
|
||||
setBatchProgress(null);
|
||||
setBatchDone(false);
|
||||
setVerification(null);
|
||||
setFailedClassnames([]);
|
||||
}}
|
||||
onRetry={activeSource ? retryFailed : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
@@ -1586,6 +1984,8 @@ export function StudioClient({
|
||||
onOpenChange={(open) => !open && setEditingClassname(null)}
|
||||
/>
|
||||
|
||||
{confirmDialog}
|
||||
|
||||
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -8,6 +8,10 @@ export interface FurniItem {
|
||||
category: string;
|
||||
alreadyImported: boolean;
|
||||
nitroExists: boolean;
|
||||
/** Has an entry in FurnitureData.json */
|
||||
hasFurnidata?: boolean;
|
||||
/** Has an entry in catalog_items (SQL) */
|
||||
hasCatalogEntry?: boolean;
|
||||
/** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,24 @@ import { isHousekeepingPreviewEnabled } from "./preview-gate";
|
||||
|
||||
describe("isHousekeepingPreviewEnabled", () => {
|
||||
it.each([
|
||||
["development", true, true],
|
||||
["test", true, true],
|
||||
["development", false, false],
|
||||
["production", true, false],
|
||||
["production", false, false],
|
||||
] as const)("NODE_ENV=%s flag=%s => %s", (nodeEnv, flag, expected) => {
|
||||
expect(isHousekeepingPreviewEnabled({ nodeEnv, flag })).toBe(expected);
|
||||
});
|
||||
["development", true, false, true],
|
||||
["test", true, false, true],
|
||||
["development", false, true, false],
|
||||
["production", true, true, true],
|
||||
["production", true, false, false],
|
||||
["production", false, true, false],
|
||||
] as const)(
|
||||
"NODE_ENV=%s flag=%s productionAccess=%s => %s",
|
||||
(nodeEnv, flag, hasProductionAccess, expected) => {
|
||||
expect(
|
||||
isHousekeepingPreviewEnabled({
|
||||
nodeEnv,
|
||||
flag,
|
||||
hasProductionAccess,
|
||||
}),
|
||||
).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export function isHousekeepingPreviewEnabled(input: {
|
||||
nodeEnv: "development" | "test" | "production";
|
||||
flag: boolean;
|
||||
hasProductionAccess?: boolean;
|
||||
}): boolean {
|
||||
return input.nodeEnv !== "production" && input.flag;
|
||||
return (
|
||||
input.flag &&
|
||||
(input.nodeEnv !== "production" || input.hasProductionAccess === true)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Instrumentation } from "next";
|
||||
|
||||
export const onRequestError: Instrumentation.onRequestError = async (
|
||||
error,
|
||||
request,
|
||||
context,
|
||||
) => {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
const { logServerError } = await import("@/lib/server-log");
|
||||
logServerError("next.request_failed", error, {
|
||||
path: request.path.split("?", 1)[0] ?? request.path,
|
||||
method: request.method,
|
||||
routePath: context.routePath,
|
||||
routeType: context.routeType,
|
||||
renderSource: context.renderSource,
|
||||
digest:
|
||||
typeof error === "object" && error !== null && "digest" in error
|
||||
? String(error.digest)
|
||||
: null,
|
||||
});
|
||||
};
|
||||
+6
-13
@@ -1,17 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_IMAGER_URL: "https://img.example.com" as string | undefined,
|
||||
NEXT_PUBLIC_APP_URL: undefined as string | undefined,
|
||||
IMAGING_UPSTREAM_URL: "https://up.example.com" as string | undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => envMock);
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { avatarImageUrl, excerpt, slugify } from "./format";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("slugify", () => {
|
||||
it("lowercases and hyphenates", () => {
|
||||
expect(slugify("Hello World!")).toBe("hello-world");
|
||||
@@ -26,7 +19,7 @@ describe("slugify", () => {
|
||||
|
||||
describe("avatarImageUrl", () => {
|
||||
beforeEach(() => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com");
|
||||
});
|
||||
|
||||
it("appends the figure and options", () => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const originalDatabaseUrl = process.env.DATABASE_URL;
|
||||
const originalHotelName = process.env.HOTEL_NAME;
|
||||
const originalSkipValidation = process.env.SKIP_ENV_VALIDATION;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
||||
else process.env.DATABASE_URL = originalDatabaseUrl;
|
||||
if (originalHotelName === undefined) delete process.env.HOTEL_NAME;
|
||||
else process.env.HOTEL_NAME = originalHotelName;
|
||||
if (originalSkipValidation === undefined)
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
else process.env.SKIP_ENV_VALIDATION = originalSkipValidation;
|
||||
});
|
||||
|
||||
describe("imager client boundary", () => {
|
||||
it("loads without server-only environment variables", async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
delete process.env.HOTEL_NAME;
|
||||
vi.resetModules();
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
|
||||
const imager = await import("./imager");
|
||||
|
||||
expect(imager.getAvatarUrl("hd-180-1")).toContain("figure=hd-180-1");
|
||||
});
|
||||
});
|
||||
+10
-17
@@ -1,20 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_IMAGER_URL: "https://img.example.com" as string | undefined,
|
||||
NEXT_PUBLIC_APP_URL: undefined as string | undefined,
|
||||
IMAGING_UPSTREAM_URL: "https://up.example.com" as string | undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => envMock);
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getAvatarUrl, resolveImagerBase } from "./imager";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("resolveImagerBase", () => {
|
||||
beforeEach(() => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com");
|
||||
});
|
||||
|
||||
it("returns configured imager env var", () => {
|
||||
@@ -22,12 +15,12 @@ describe("resolveImagerBase", () => {
|
||||
});
|
||||
|
||||
it("strips trailing slashes", () => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com/";
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com/");
|
||||
expect(resolveImagerBase()).toBe("https://img.example.com");
|
||||
});
|
||||
|
||||
it("falls back to epicnabbo.nl when env var is not set", () => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "");
|
||||
expect(resolveImagerBase()).toBe(
|
||||
"https://epicnabbo.nl/imaging/avatarimage",
|
||||
);
|
||||
@@ -36,7 +29,7 @@ describe("resolveImagerBase", () => {
|
||||
|
||||
describe("getAvatarUrl", () => {
|
||||
beforeEach(() => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "https://img.example.com");
|
||||
});
|
||||
|
||||
it("builds a query string with defaults (omits default params)", () => {
|
||||
@@ -83,7 +76,7 @@ describe("getAvatarUrl", () => {
|
||||
});
|
||||
|
||||
it("uses epicnabbo.nl when no env var is set", () => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
|
||||
vi.stubEnv("NEXT_PUBLIC_IMAGER_URL", "");
|
||||
const url = getAvatarUrl("hd-180-1");
|
||||
expect(url.startsWith("https://epicnabbo.nl/imaging/avatarimage?")).toBe(
|
||||
true,
|
||||
|
||||
+1
-2
@@ -7,7 +7,6 @@
|
||||
|
||||
export type { AvatarOptions } from "@/types/admin";
|
||||
|
||||
import { env } from "@/env";
|
||||
import type { AvatarOptions } from "@/types/admin";
|
||||
|
||||
const DEFAULT_IMAGER_URL = "https://epicnabbo.nl/imaging/avatarimage";
|
||||
@@ -17,7 +16,7 @@ const DEFAULT_IMAGER_URL = "https://epicnabbo.nl/imaging/avatarimage";
|
||||
* Falls back to epicnabbo.nl if not configured.
|
||||
*/
|
||||
export function resolveImagerBase(): string {
|
||||
const fromEnv = env.NEXT_PUBLIC_IMAGER_URL?.trim();
|
||||
const fromEnv = process.env.NEXT_PUBLIC_IMAGER_URL?.trim();
|
||||
if (!fromEnv) {
|
||||
return DEFAULT_IMAGER_URL;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// (modules.ts → sidebar.tsx) without pulling in db/server-only deps.
|
||||
|
||||
export const PERMS = {
|
||||
HOUSEKEEPING_PREVIEW_ACCESS: "housekeeping.preview.access",
|
||||
ADMIN_DASHBOARD: "admin.dashboard",
|
||||
USERS_VIEW: "admin.users.view",
|
||||
USERS_EDIT: "admin.users.edit",
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
fetchEmulatorRankForEdit,
|
||||
getRankPermissionColumn,
|
||||
RANK_GENERAL_FIELDS,
|
||||
splitRankUpdateFields,
|
||||
toPermissionValue,
|
||||
} from "./permission-ranks";
|
||||
|
||||
const dialect = new MySqlDialect();
|
||||
|
||||
function queryText(query: SQL | string) {
|
||||
return typeof query === "string" ? query : dialect.sqlToQuery(query).sql;
|
||||
}
|
||||
|
||||
describe("getRankPermissionColumn", () => {
|
||||
it("returns rank_N for valid rank", () => {
|
||||
expect(getRankPermissionColumn(1)).toBe("rank_1");
|
||||
@@ -76,3 +85,54 @@ describe("RANK_GENERAL_FIELDS", () => {
|
||||
expect(RANK_GENERAL_FIELDS.has("nonexistent")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchEmulatorRankForEdit", () => {
|
||||
it("loads permissions from the legacy table when permission_definitions is absent", async () => {
|
||||
const db = {
|
||||
execute: async (query: SQL | string) => {
|
||||
const text = queryText(query);
|
||||
if (text.includes("FROM permission_ranks")) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
rank_name: "Developer",
|
||||
level: 11,
|
||||
},
|
||||
],
|
||||
[],
|
||||
];
|
||||
}
|
||||
if (text.includes("FROM information_schema.columns")) {
|
||||
return [[], []];
|
||||
}
|
||||
if (text.includes("FROM permissions")) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
rank_name: "Developer",
|
||||
level: 11,
|
||||
cmd_ban: 1,
|
||||
cmd_owner: 2,
|
||||
},
|
||||
],
|
||||
[],
|
||||
];
|
||||
}
|
||||
throw new Error(`Unexpected query: ${text}`);
|
||||
},
|
||||
transaction: async () => {
|
||||
throw new Error("Unexpected transaction");
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchEmulatorRankForEdit(db as never, 11),
|
||||
).resolves.toMatchObject({
|
||||
id: 11,
|
||||
permissions: { cmd_ban: "1", cmd_owner: "2" },
|
||||
permissionMaxValues: { cmd_ban: 1, cmd_owner: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -125,26 +125,51 @@ export async function fetchEmulatorRankForEdit(
|
||||
if (rankRows.length === 0) return null;
|
||||
|
||||
const rankColumn = getRankPermissionColumn(rankId);
|
||||
const permissionRows = await rawRows<
|
||||
{
|
||||
permission_key: string;
|
||||
max_value: number;
|
||||
value: number | string | null;
|
||||
}[]
|
||||
>(
|
||||
db,
|
||||
sql`
|
||||
SELECT permission_key, max_value, ${sql.raw(quoteIdentifier(rankColumn))} AS value
|
||||
FROM permission_definitions
|
||||
ORDER BY permission_key ASC
|
||||
`,
|
||||
);
|
||||
|
||||
const permissions: Record<string, string> = {};
|
||||
const permissionMaxValues: Record<string, number> = {};
|
||||
for (const row of permissionRows) {
|
||||
permissions[row.permission_key] = String(row.value ?? 0);
|
||||
permissionMaxValues[row.permission_key] = Number(row.max_value ?? 1);
|
||||
const definitionColumns = await getTableColumns(db, "permission_definitions");
|
||||
if (definitionColumns.has(rankColumn)) {
|
||||
const permissionRows = await rawRows<
|
||||
{
|
||||
permission_key: string;
|
||||
max_value: number;
|
||||
value: number | string | null;
|
||||
}[]
|
||||
>(
|
||||
db,
|
||||
sql`
|
||||
SELECT permission_key, max_value, ${sql.raw(quoteIdentifier(rankColumn))} AS value
|
||||
FROM permission_definitions
|
||||
ORDER BY permission_key ASC
|
||||
`,
|
||||
);
|
||||
|
||||
for (const permission of permissionRows) {
|
||||
permissions[permission.permission_key] = String(permission.value ?? 0);
|
||||
permissionMaxValues[permission.permission_key] = Number(
|
||||
permission.max_value ?? 1,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const legacyRows = await rawRows<Record<string, unknown>[]>(
|
||||
db,
|
||||
sql`SELECT * FROM permissions WHERE id = ${rankId} LIMIT 1`,
|
||||
);
|
||||
for (const [key, value] of Object.entries(legacyRows[0] ?? {})) {
|
||||
if (
|
||||
key === "id" ||
|
||||
RANK_GENERAL_FIELDS.has(key) ||
|
||||
!VALID_KEY.test(key)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const permissionValue =
|
||||
typeof value === "string" || typeof value === "number"
|
||||
? toPermissionValue(value)
|
||||
: 0;
|
||||
permissions[key] = String(permissionValue);
|
||||
permissionMaxValues[key] = Math.max(1, permissionValue);
|
||||
}
|
||||
}
|
||||
|
||||
const row = rankRows[0];
|
||||
|
||||
Reference in new issue
Block a user