diff --git a/src/app/admin/import/official/official-sync-client.tsx b/src/app/admin/import/official/official-sync-client.tsx new file mode 100644 index 0000000000..630377f190 --- /dev/null +++ b/src/app/admin/import/official/official-sync-client.tsx @@ -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([]); + const [result, setResult] = useState(null); + const abortRef = useRef(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 ( +
+ {!result && ( +
+ + {syncing && ( + + )} +
+ )} + + {progress && ( +
+ Processed {progress.done} items + {progress.total > 0 ? ` out of ${progress.total}` : ""} +
+ )} + + {log.length > 0 && ( +
+ {log.map((line) => ( +
+ {line} +
+ ))} +
+ )} + + {result && ( +
+
+ {result.failed === 0 ? ( + + ) : ( + + )} + Sync Complete +
+
+

+ Succeeded:{" "} + {result.succeeded} +

+

+ Failed:{" "} + {result.failed} +

+

{result.message}

+
+
+ )} + + {!syncing && !result && ( +
+ +

+ Click the button above to scan the official Habbo furnidata and + automatically import any furniture that your hotel doesn't have yet. +

+
+ )} +
+ ); +} diff --git a/src/app/admin/studio/sync/page.tsx b/src/app/admin/studio/sync/page.tsx index 70f8c3722d..8abdf58d5c 100644 --- a/src/app/admin/studio/sync/page.tsx +++ b/src/app/admin/studio/sync/page.tsx @@ -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: {

+ +
+

+ Official Furnidata +

+

+ Scan the official Habbo furnidata and automatically import any + furniture that your hotel doesn't have yet. +

+ +
); diff --git a/src/app/api/admin/import/official/sync-all/route.ts b/src/app/api/admin/import/official/sync-all/route.ts new file mode 100644 index 0000000000..67dc7c7439 --- /dev/null +++ b/src/app/api/admin/import/official/sync-all/route.ts @@ -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 + }, + }); + }, +);