From 1dc8d1d46f14f078d906a59299eb2695195d4a55 Mon Sep 17 00:00:00 2001
From: openhands
Date: Mon, 31 Aug 2026 20:24:46 +0200
Subject: [PATCH] feat: add official furnidata sync button for importing all
missing items
- API endpoint: /api/admin/import/official/sync-all
- Fetches all official Habbo furnidata
- Compares with database to find missing items
- Imports missing items via SSE batch with progress reporting
- 500 item limit for safety
- Proper abort signal support
- Client component: OfficialSyncClient with progress UI
- Updated StudioSyncPage to include Official Furnidata sync option
- Uses existing importSingleFurni infrastructure
---
.../import/official/official-sync-client.tsx | 225 ++++++++++++++++++
src/app/admin/studio/sync/page.tsx | 12 +
.../admin/import/official/sync-all/route.ts | 97 ++++++++
3 files changed, 334 insertions(+)
create mode 100644 src/app/admin/import/official/official-sync-client.tsx
create mode 100644 src/app/api/admin/import/official/sync-all/route.ts
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 00000000..630377f1
--- /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 70f8c372..8abdf58d 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 00000000..67dc7c74
--- /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
+ },
+ });
+ },
+);