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
@@ -11,6 +11,23 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
runtime-diagnostics:
|
||||||
|
if: gitea.event_name == 'workflow_dispatch'
|
||||||
|
runs-on: shell
|
||||||
|
steps:
|
||||||
|
- name: Read recent CMS runtime errors
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
echo "--- Recent CMS runtime errors ---"
|
||||||
|
pm2 logs next --lines 250 --nostream 2>&1 \
|
||||||
|
| grep -Ei 'error|permissions|permission_ranks|permission_definitions|unknown column|doesn.t exist|digest' \
|
||||||
|
| tail -120 \
|
||||||
|
|| true
|
||||||
|
echo "--- Permission page database probe ---"
|
||||||
|
cd /var/www/atom-nexst
|
||||||
|
pnpm diag:permissions
|
||||||
|
|
||||||
|
|
||||||
check:
|
check:
|
||||||
if: startsWith(gitea.ref_name, 'v') == false
|
if: startsWith(gitea.ref_name, 'v') == false
|
||||||
runs-on: shell
|
runs-on: shell
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
INSERT INTO `acl_permissions` (`slug`, `title`)
|
||||||
|
VALUES ('housekeeping.preview.access', 'Access Housekeeping preview')
|
||||||
|
ON DUPLICATE KEY UPDATE `title` = VALUES(`title`);
|
||||||
+16
-15
@@ -5,7 +5,7 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=26.8.1 <27"
|
"node": ">=26.8.1 <27"
|
||||||
},
|
},
|
||||||
"packageManager": "[email protected]4.0",
|
"packageManager": "[email protected]5.0+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"biome:lint": "biome check .",
|
"biome:lint": "biome check .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"knip": "knip --include files,dependencies,devDependencies,unlisted,binaries",
|
"knip": "knip --include files,dependencies,devDependencies,unlisted,binaries",
|
||||||
|
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||||
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
"jobs:worker": "tsx scripts/jobs-worker.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -47,43 +48,43 @@
|
|||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"jszip": "3.10.1",
|
"jszip": "3.10.1",
|
||||||
"lenis": "1.3.26",
|
"lenis": "1.3.26",
|
||||||
"lucide-react": "1.33.0",
|
"lucide-react": "1.38.0",
|
||||||
"lzma-wasm": "1.0.7",
|
"lzma-wasm": "1.0.7",
|
||||||
"motion": "13.1.1",
|
"motion": "13.1.1",
|
||||||
"music-metadata": "11.15.0",
|
"music-metadata": "11.15.0",
|
||||||
"mysql2": "3.23.4",
|
"mysql2": "3.24.2",
|
||||||
"next": "16.3.3",
|
"next": "16.3.3",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "4.13.7",
|
"next-intl": "4.14.1",
|
||||||
"otplib": "13.4.1",
|
"otplib": "13.5.0",
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.1",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-hook-form": "7.85.0",
|
"react-hook-form": "7.87.0",
|
||||||
"resend": "6.21.0",
|
"resend": "6.25.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.4",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"zod": "4.4.3"
|
"zod": "4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.9",
|
"@biomejs/biome": "2.5.11",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tailwindcss/typography": "0.5.20",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
"@types/node": "26.2.0",
|
"@types/node": "26.4.0",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.2.4",
|
"@types/react-dom": "19.2.5",
|
||||||
"@vitest/coverage-v8": "4.1.11",
|
"@vitest/coverage-v8": "4.1.11",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"knip": "6.32.2",
|
"knip": "6.33.0",
|
||||||
"lint-staged": "17.3.0",
|
"lint-staged": "17.4.1",
|
||||||
"pino-pretty": "13.1.3",
|
"pino-pretty": "13.1.3",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "^8.5.26",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"tsx": "4.23.12",
|
"tsx": "4.23.13",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.11"
|
"vitest": "4.1.11"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+544
-529
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -56,12 +56,12 @@ overrides:
|
|||||||
uuid: "^11.1.0"
|
uuid: "^11.1.0"
|
||||||
fast-uri: "^3.1.3"
|
fast-uri: "^3.1.3"
|
||||||
"@hono/node-server": "^1.19.13"
|
"@hono/node-server": "^1.19.13"
|
||||||
postcss: "^8.5.25"
|
postcss: "^8.5.26"
|
||||||
tmp: "^0.2.6"
|
tmp: "^0.2.6"
|
||||||
sharp: "^0.35.3"
|
sharp: "^0.35.4"
|
||||||
brace-expansion: "^5.0.8"
|
brace-expansion: "^5.0.8"
|
||||||
"@types/react": "19.2.18"
|
"@types/react": "19.2.18"
|
||||||
"@types/react-dom": "19.2.4"
|
"@types/react-dom": "19.2.5"
|
||||||
# Tijdelijke mitigatie voor drizzle-kit audit
|
# Tijdelijke mitigatie voor drizzle-kit audit
|
||||||
esbuild: "^0.25.9"
|
esbuild: "^0.25.9"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import "./load-env";
|
||||||
|
import mysql, { type RowDataPacket } from "mysql2/promise";
|
||||||
|
import { mysqlConnectionUrl } from "./db-url";
|
||||||
|
|
||||||
|
type DbError = Error & { code?: string };
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) throw new Error("DATABASE_URL is required");
|
||||||
|
|
||||||
|
const connection = await mysql.createConnection(
|
||||||
|
mysqlConnectionUrl(databaseUrl),
|
||||||
|
);
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
async function probe(
|
||||||
|
name: string,
|
||||||
|
statement: string,
|
||||||
|
values: Array<string | number> = [],
|
||||||
|
): Promise<RowDataPacket[] | null> {
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.execute<RowDataPacket[]>(statement, values);
|
||||||
|
console.log(`[permissions-diag] ${name}: ok (${rows.length} rows)`);
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
failed = true;
|
||||||
|
const dbError = error as DbError;
|
||||||
|
console.error(
|
||||||
|
`[permissions-diag] ${name}: failed code=${dbError.code ?? "unknown"} message=${dbError.message}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rankRows = await probe(
|
||||||
|
"select rank id",
|
||||||
|
"SELECT id FROM permission_ranks ORDER BY id DESC LIMIT 1",
|
||||||
|
);
|
||||||
|
const rankId = Number(rankRows?.[0]?.id ?? 11);
|
||||||
|
console.log(`[permissions-diag] probing rank id ${rankId}`);
|
||||||
|
|
||||||
|
await probe(
|
||||||
|
"rank detail",
|
||||||
|
`SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank,
|
||||||
|
log_commands, room_effect, auto_credits_amount, auto_pixels_amount,
|
||||||
|
auto_gotw_amount, auto_points_amount
|
||||||
|
FROM permission_ranks WHERE id = ?`,
|
||||||
|
[rankId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const definitionColumns = await probe(
|
||||||
|
"permission_definitions columns",
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'permission_definitions'`,
|
||||||
|
);
|
||||||
|
const permissionLimits = await probe(
|
||||||
|
"permission max_value limits",
|
||||||
|
`SELECT MIN(max_value) AS min_value,
|
||||||
|
MAX(max_value) AS max_value,
|
||||||
|
SUM(max_value > 20) AS values_over_20
|
||||||
|
FROM permission_definitions`,
|
||||||
|
);
|
||||||
|
if (permissionLimits?.[0]) {
|
||||||
|
console.log(
|
||||||
|
`[permissions-diag] max_value range ${permissionLimits[0].min_value}..${permissionLimits[0].max_value}; values_over_20=${permissionLimits[0].values_over_20}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankColumn = `rank_${rankId}`;
|
||||||
|
if (definitionColumns?.some((row) => row.column_name === rankColumn)) {
|
||||||
|
await probe(
|
||||||
|
"normalized permissions",
|
||||||
|
`SELECT permission_key, max_value, \`${rankColumn}\` AS value
|
||||||
|
FROM permission_definitions ORDER BY permission_key ASC`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await probe(
|
||||||
|
"legacy permissions",
|
||||||
|
"SELECT * FROM permissions WHERE id = ? LIMIT 1",
|
||||||
|
[rankId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await probe(
|
||||||
|
"rank users",
|
||||||
|
"SELECT id, username, look FROM users WHERE rank = ? ORDER BY username LIMIT 200",
|
||||||
|
[rankId],
|
||||||
|
);
|
||||||
|
await probe(
|
||||||
|
"rank user count",
|
||||||
|
"SELECT COUNT(*) AS total FROM users WHERE rank = ?",
|
||||||
|
[rankId],
|
||||||
|
);
|
||||||
|
await probe(
|
||||||
|
"CMS permissions",
|
||||||
|
"SELECT id, slug, title FROM acl_permissions ORDER BY slug",
|
||||||
|
);
|
||||||
|
const roles = await probe(
|
||||||
|
"CMS role",
|
||||||
|
"SELECT id FROM acl_roles WHERE slug = ? LIMIT 1",
|
||||||
|
[`rank_${rankId}`],
|
||||||
|
);
|
||||||
|
if (roles?.[0]?.id) {
|
||||||
|
await probe(
|
||||||
|
"CMS role permissions",
|
||||||
|
`SELECT p.slug FROM acl_model_permissions mp
|
||||||
|
INNER JOIN acl_permissions p ON mp.permission_id = p.id
|
||||||
|
WHERE mp.model_id = ?`,
|
||||||
|
[roles[0].id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) process.exitCode = 1;
|
||||||
@@ -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 { 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 { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
@@ -28,6 +29,17 @@ export default async function StudioSyncPage(_props: {
|
|||||||
</p>
|
</p>
|
||||||
<SyncAllClient />
|
<SyncAllClient />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getSource } from "@/lib/services/clone-sources";
|
|||||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||||
import {
|
import {
|
||||||
appendFurniEntriesBulk,
|
appendFurniEntriesBulk,
|
||||||
|
readFurniData,
|
||||||
rebuildCatalogOfferIds,
|
rebuildCatalogOfferIds,
|
||||||
reconcileFurniDataWithItemsBase,
|
reconcileFurniDataWithItemsBase,
|
||||||
verifyAndFixSpriteIds,
|
verifyAndFixSpriteIds,
|
||||||
@@ -311,6 +312,38 @@ export const GET = withAdmin(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, "");
|
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 enriched = items.map((item) => {
|
||||||
const imported = item.present;
|
const imported = item.present;
|
||||||
let nitroExists = false;
|
let nitroExists = false;
|
||||||
@@ -336,6 +369,8 @@ export const GET = withAdmin(
|
|||||||
category: String(item.category ?? ""),
|
category: String(item.category ?? ""),
|
||||||
alreadyImported: imported,
|
alreadyImported: imported,
|
||||||
nitroExists,
|
nitroExists,
|
||||||
|
hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined,
|
||||||
|
hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined,
|
||||||
iconUrl: sourceIconBase
|
iconUrl: sourceIconBase
|
||||||
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
|
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -364,6 +399,37 @@ export const GET = withAdmin(
|
|||||||
allImported.map((e) => e.itemName).filter((n): n is string => !!n),
|
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({
|
const result = await listFurniture({
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
page,
|
page,
|
||||||
@@ -386,7 +452,13 @@ export const GET = withAdmin(
|
|||||||
);
|
);
|
||||||
nitroExists = existsSync(/*turbopackIgnore: true*/ p);
|
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 });
|
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 type { ReactNode } from "react";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
|
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 (
|
if (
|
||||||
!isHousekeepingPreviewEnabled({
|
!isHousekeepingPreviewEnabled({
|
||||||
nodeEnv: env.NODE_ENV,
|
nodeEnv: env.NODE_ENV,
|
||||||
flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
|
flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
|
||||||
|
hasProductionAccess,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
notFound();
|
notFound();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
|
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
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({
|
export function BatchProgress({
|
||||||
progress,
|
progress,
|
||||||
done,
|
done,
|
||||||
@@ -39,8 +47,11 @@ export function BatchProgress({
|
|||||||
failed,
|
failed,
|
||||||
cancelling,
|
cancelling,
|
||||||
verification,
|
verification,
|
||||||
|
startTime,
|
||||||
|
failedClassnames,
|
||||||
onCancel,
|
onCancel,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
onRetry,
|
||||||
}: {
|
}: {
|
||||||
progress: Map<string, BatchItemStatus>;
|
progress: Map<string, BatchItemStatus>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
@@ -48,8 +59,11 @@ export function BatchProgress({
|
|||||||
failed: number;
|
failed: number;
|
||||||
cancelling?: boolean;
|
cancelling?: boolean;
|
||||||
verification?: VerificationSummary | null;
|
verification?: VerificationSummary | null;
|
||||||
|
startTime?: number;
|
||||||
|
failedClassnames?: string[];
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
|
onRetry?: (classnames: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const entries = [...progress.entries()];
|
const entries = [...progress.entries()];
|
||||||
const total = entries.length;
|
const total = entries.length;
|
||||||
@@ -58,6 +72,27 @@ export function BatchProgress({
|
|||||||
).length;
|
).length;
|
||||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
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 (
|
return (
|
||||||
<div className="border-t border-[var(--admin-border)] p-3">
|
<div className="border-t border-[var(--admin-border)] p-3">
|
||||||
<div className="mb-2 flex items-center justify-between gap-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)]">
|
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
|
||||||
{completed}/{total}
|
{completed}/{total}
|
||||||
</span>
|
</span>
|
||||||
|
{eta && (
|
||||||
|
<span className="text-xs font-mono text-[var(--admin-accent)]">
|
||||||
|
~{eta}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Loader2
|
<Loader2
|
||||||
size={14}
|
size={14}
|
||||||
className="animate-spin text-[var(--admin-accent)]"
|
className="animate-spin text-[var(--admin-accent)]"
|
||||||
@@ -90,11 +130,16 @@ export function BatchProgress({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!done && (
|
{!done && (
|
||||||
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<div
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
|
||||||
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
|
<div
|
||||||
style={{ width: `${percent}%` }}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -165,10 +210,22 @@ export function BatchProgress({
|
|||||||
|
|
||||||
<div className="mt-2 flex justify-end gap-2">
|
<div className="mt-2 flex justify-end gap-2">
|
||||||
{done ? (
|
{done ? (
|
||||||
<Button size="sm" variant="secondary" onClick={onDismiss}>
|
<>
|
||||||
<Check size={14} />
|
{failedClassnames && failedClassnames.length > 0 && onRetry && (
|
||||||
Dismiss
|
<Button
|
||||||
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
setFurnidataTranslateEnabled,
|
setFurnidataTranslateEnabled,
|
||||||
} from "@/actions/import-furni";
|
} from "@/actions/import-furni";
|
||||||
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
|
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
|
||||||
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -60,6 +61,7 @@ import {
|
|||||||
previewAutoCatalog,
|
previewAutoCatalog,
|
||||||
} from "@/lib/furni/auto-catalog";
|
} from "@/lib/furni/auto-catalog";
|
||||||
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||||
|
import { readSseStream } from "@/lib/sse-client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { TreeNode } from "@/types/catalog";
|
import type { TreeNode } from "@/types/catalog";
|
||||||
import { BatchProgress } from "./batch-progress";
|
import { BatchProgress } from "./batch-progress";
|
||||||
@@ -164,6 +166,20 @@ export function StudioClient({
|
|||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
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)
|
// Nitro regeneration (missing .nitro)
|
||||||
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
|
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
@@ -237,17 +253,22 @@ export function StudioClient({
|
|||||||
if (categoryFilter !== "all") {
|
if (categoryFilter !== "all") {
|
||||||
result = result.filter((i) => i.category === categoryFilter);
|
result = result.filter((i) => i.category === categoryFilter);
|
||||||
}
|
}
|
||||||
if (statusFilter !== "all") {
|
if (statusFilter === "imported")
|
||||||
if (statusFilter === "imported")
|
result = result.filter((i) => i.alreadyImported);
|
||||||
result = result.filter((i) => i.alreadyImported);
|
else if (statusFilter === "inCatalog")
|
||||||
else if (statusFilter === "inCatalog")
|
result = result.filter((i) => i.alreadyImported && i.nitroExists);
|
||||||
result = result.filter((i) => i.alreadyImported && i.nitroExists);
|
else if (statusFilter === "missingNitro")
|
||||||
else if (statusFilter === "missingNitro")
|
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
|
||||||
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
|
else if (statusFilter === "missingFurnidata")
|
||||||
}
|
result = result.filter(
|
||||||
// Not imported filter - works independently (also with statusFilter === "all")
|
(i) => i.alreadyImported && i.hasFurnidata === false,
|
||||||
if (statusFilter === "notImported" || statusFilter === "all")
|
);
|
||||||
result = result.filter((i) => i.alreadyImported === 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];
|
const sorted = [...result];
|
||||||
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
|
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
else if (sortBy === "classname")
|
else if (sortBy === "classname")
|
||||||
@@ -325,6 +346,9 @@ export function StudioClient({
|
|||||||
setItems([]);
|
setItems([]);
|
||||||
setActiveSearch("");
|
setActiveSearch("");
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
|
setCloneStats(null);
|
||||||
|
setBatchProgress(null);
|
||||||
|
fetchCloneStats(sourceId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSources = useCallback(async () => {
|
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(() => {
|
useEffect(() => {
|
||||||
fetchItems("", 1, activeSource);
|
fetchItems("", 1, activeSource);
|
||||||
fetchStats();
|
fetchStats();
|
||||||
@@ -492,6 +693,8 @@ export function StudioClient({
|
|||||||
setBatchSucceeded(0);
|
setBatchSucceeded(0);
|
||||||
setBatchFailed(0);
|
setBatchFailed(0);
|
||||||
setBatchCancelling(false);
|
setBatchCancelling(false);
|
||||||
|
setFailedClassnames([]);
|
||||||
|
batchStartTimeRef.current = Date.now();
|
||||||
|
|
||||||
const initial = new Map<string, BatchItemStatus>();
|
const initial = new Map<string, BatchItemStatus>();
|
||||||
for (const i of toImport) {
|
for (const i of toImport) {
|
||||||
@@ -565,6 +768,12 @@ export function StudioClient({
|
|||||||
: i,
|
: i,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else if (event.status === "failed") {
|
||||||
|
setFailedClassnames((prev) =>
|
||||||
|
prev.includes(event.classname)
|
||||||
|
? prev
|
||||||
|
: [...prev, event.classname],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (event.type === "batch_complete") {
|
} else if (event.type === "batch_complete") {
|
||||||
setBatchSucceeded(event.succeeded);
|
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() {
|
async function deleteItem() {
|
||||||
if (!deleting) return;
|
if (!deleting) return;
|
||||||
try {
|
try {
|
||||||
@@ -690,8 +996,11 @@ export function StudioClient({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const notImportedCount = useMemo(
|
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 (
|
return (
|
||||||
@@ -748,7 +1057,7 @@ export function StudioClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{stats && (
|
{stats && !activeSource && (
|
||||||
<>
|
<>
|
||||||
<Badge variant="secondary" className="gap-1">
|
<Badge variant="secondary" className="gap-1">
|
||||||
<Package size={11} /> {stats.totalInDb} in DB
|
<Package size={11} /> {stats.totalInDb} in DB
|
||||||
@@ -767,6 +1076,26 @@ export function StudioClient({
|
|||||||
</Badge>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 py-1">
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">
|
<SelectItem value="all">
|
||||||
All statuses {notImportedCount > 0 && notImportedCount}
|
All statuses{" "}
|
||||||
|
{notImportedCount > 0 && `(${notImportedCount} te importeren)`}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="notImported">Not imported</SelectItem>
|
||||||
<SelectItem value="imported">Imported</SelectItem>
|
<SelectItem value="imported">Imported</SelectItem>
|
||||||
<SelectItem value="inCatalog">In catalog</SelectItem>
|
<SelectItem value="inCatalog">In catalog</SelectItem>
|
||||||
<SelectItem value="missingNitro">Missing nitro</SelectItem>
|
<SelectItem value="missingNitro">Missing nitro</SelectItem>
|
||||||
|
<SelectItem value="missingFurnidata">Missing furnidata</SelectItem>
|
||||||
|
<SelectItem value="missingCatalog">
|
||||||
|
Missing catalog entry
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -1044,6 +1379,13 @@ export function StudioClient({
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<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 && (
|
{selectedNonImported > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1162,17 +1504,42 @@ export function StudioClient({
|
|||||||
{TYPE_LABELS[item.type] ?? item.type}
|
{TYPE_LABELS[item.type] ?? item.type}
|
||||||
</Badge>
|
</Badge>
|
||||||
{item.alreadyImported ? (
|
{item.alreadyImported ? (
|
||||||
<Badge
|
<>
|
||||||
variant="secondary"
|
{!item.nitroExists && (
|
||||||
className={cn(
|
<Badge
|
||||||
"h-4",
|
variant="secondary"
|
||||||
item.nitroExists
|
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
>
|
||||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
No nitro
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
>
|
{item.hasFurnidata === false && (
|
||||||
{item.nitroExists ? "Imported" : "No nitro"}
|
<Badge
|
||||||
</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)]">
|
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||||
New
|
New
|
||||||
@@ -1286,17 +1653,42 @@ export function StudioClient({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
{item.alreadyImported ? (
|
{item.alreadyImported ? (
|
||||||
<Badge
|
<div className="flex flex-wrap gap-0.5">
|
||||||
variant="secondary"
|
{!item.nitroExists && (
|
||||||
className={cn(
|
<Badge
|
||||||
"h-4",
|
variant="secondary"
|
||||||
item.nitroExists
|
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
>
|
||||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
No nitro
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
>
|
{item.hasFurnidata === false && (
|
||||||
{item.nitroExists ? "Imported" : "No nitro"}
|
<Badge
|
||||||
</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)]">
|
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||||
New
|
New
|
||||||
@@ -1401,14 +1793,20 @@ export function StudioClient({
|
|||||||
done={batchDone}
|
done={batchDone}
|
||||||
succeeded={batchSucceeded}
|
succeeded={batchSucceeded}
|
||||||
failed={batchFailed}
|
failed={batchFailed}
|
||||||
cancelling={batchCancelling}
|
cancelling={batchCancelling || cloneAllCancelling}
|
||||||
verification={verification}
|
verification={verification}
|
||||||
onCancel={cancelBatch}
|
startTime={batchStartTimeRef.current || undefined}
|
||||||
|
failedClassnames={
|
||||||
|
failedClassnames.length > 0 ? failedClassnames : undefined
|
||||||
|
}
|
||||||
|
onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
setBatchProgress(null);
|
setBatchProgress(null);
|
||||||
setBatchDone(false);
|
setBatchDone(false);
|
||||||
setVerification(null);
|
setVerification(null);
|
||||||
|
setFailedClassnames([]);
|
||||||
}}
|
}}
|
||||||
|
onRetry={activeSource ? retryFailed : undefined}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
@@ -1586,6 +1984,8 @@ export function StudioClient({
|
|||||||
onOpenChange={(open) => !open && setEditingClassname(null)}
|
onOpenChange={(open) => !open && setEditingClassname(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{confirmDialog}
|
||||||
|
|
||||||
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export interface FurniItem {
|
|||||||
category: string;
|
category: string;
|
||||||
alreadyImported: boolean;
|
alreadyImported: boolean;
|
||||||
nitroExists: 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. */
|
/** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,24 @@ import { isHousekeepingPreviewEnabled } from "./preview-gate";
|
|||||||
|
|
||||||
describe("isHousekeepingPreviewEnabled", () => {
|
describe("isHousekeepingPreviewEnabled", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["development", true, true],
|
["development", true, false, true],
|
||||||
["test", true, true],
|
["test", true, false, true],
|
||||||
["development", false, false],
|
["development", false, true, false],
|
||||||
["production", true, false],
|
["production", true, true, true],
|
||||||
["production", false, false],
|
["production", true, false, false],
|
||||||
] as const)("NODE_ENV=%s flag=%s => %s", (nodeEnv, flag, expected) => {
|
["production", false, true, false],
|
||||||
expect(isHousekeepingPreviewEnabled({ nodeEnv, flag })).toBe(expected);
|
] 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", () => {
|
describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
export function isHousekeepingPreviewEnabled(input: {
|
export function isHousekeepingPreviewEnabled(input: {
|
||||||
nodeEnv: "development" | "test" | "production";
|
nodeEnv: "development" | "test" | "production";
|
||||||
flag: boolean;
|
flag: boolean;
|
||||||
|
hasProductionAccess?: boolean;
|
||||||
}): 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";
|
import { afterEach, 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 { avatarImageUrl, excerpt, slugify } from "./format";
|
import { avatarImageUrl, excerpt, slugify } from "./format";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
describe("slugify", () => {
|
describe("slugify", () => {
|
||||||
it("lowercases and hyphenates", () => {
|
it("lowercases and hyphenates", () => {
|
||||||
expect(slugify("Hello World!")).toBe("hello-world");
|
expect(slugify("Hello World!")).toBe("hello-world");
|
||||||
@@ -26,7 +19,7 @@ describe("slugify", () => {
|
|||||||
|
|
||||||
describe("avatarImageUrl", () => {
|
describe("avatarImageUrl", () => {
|
||||||
beforeEach(() => {
|
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", () => {
|
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";
|
import { afterEach, 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 { getAvatarUrl, resolveImagerBase } from "./imager";
|
import { getAvatarUrl, resolveImagerBase } from "./imager";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveImagerBase", () => {
|
describe("resolveImagerBase", () => {
|
||||||
beforeEach(() => {
|
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", () => {
|
it("returns configured imager env var", () => {
|
||||||
@@ -22,12 +15,12 @@ describe("resolveImagerBase", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("strips trailing slashes", () => {
|
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");
|
expect(resolveImagerBase()).toBe("https://img.example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to epicnabbo.nl when env var is not set", () => {
|
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(
|
expect(resolveImagerBase()).toBe(
|
||||||
"https://epicnabbo.nl/imaging/avatarimage",
|
"https://epicnabbo.nl/imaging/avatarimage",
|
||||||
);
|
);
|
||||||
@@ -36,7 +29,7 @@ describe("resolveImagerBase", () => {
|
|||||||
|
|
||||||
describe("getAvatarUrl", () => {
|
describe("getAvatarUrl", () => {
|
||||||
beforeEach(() => {
|
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)", () => {
|
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", () => {
|
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");
|
const url = getAvatarUrl("hd-180-1");
|
||||||
expect(url.startsWith("https://epicnabbo.nl/imaging/avatarimage?")).toBe(
|
expect(url.startsWith("https://epicnabbo.nl/imaging/avatarimage?")).toBe(
|
||||||
true,
|
true,
|
||||||
|
|||||||
+1
-2
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
export type { AvatarOptions } from "@/types/admin";
|
export type { AvatarOptions } from "@/types/admin";
|
||||||
|
|
||||||
import { env } from "@/env";
|
|
||||||
import type { AvatarOptions } from "@/types/admin";
|
import type { AvatarOptions } from "@/types/admin";
|
||||||
|
|
||||||
const DEFAULT_IMAGER_URL = "https://epicnabbo.nl/imaging/avatarimage";
|
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.
|
* Falls back to epicnabbo.nl if not configured.
|
||||||
*/
|
*/
|
||||||
export function resolveImagerBase(): string {
|
export function resolveImagerBase(): string {
|
||||||
const fromEnv = env.NEXT_PUBLIC_IMAGER_URL?.trim();
|
const fromEnv = process.env.NEXT_PUBLIC_IMAGER_URL?.trim();
|
||||||
if (!fromEnv) {
|
if (!fromEnv) {
|
||||||
return DEFAULT_IMAGER_URL;
|
return DEFAULT_IMAGER_URL;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// (modules.ts → sidebar.tsx) without pulling in db/server-only deps.
|
// (modules.ts → sidebar.tsx) without pulling in db/server-only deps.
|
||||||
|
|
||||||
export const PERMS = {
|
export const PERMS = {
|
||||||
|
HOUSEKEEPING_PREVIEW_ACCESS: "housekeeping.preview.access",
|
||||||
ADMIN_DASHBOARD: "admin.dashboard",
|
ADMIN_DASHBOARD: "admin.dashboard",
|
||||||
USERS_VIEW: "admin.users.view",
|
USERS_VIEW: "admin.users.view",
|
||||||
USERS_EDIT: "admin.users.edit",
|
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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
fetchEmulatorRankForEdit,
|
||||||
getRankPermissionColumn,
|
getRankPermissionColumn,
|
||||||
RANK_GENERAL_FIELDS,
|
RANK_GENERAL_FIELDS,
|
||||||
splitRankUpdateFields,
|
splitRankUpdateFields,
|
||||||
toPermissionValue,
|
toPermissionValue,
|
||||||
} from "./permission-ranks";
|
} from "./permission-ranks";
|
||||||
|
|
||||||
|
const dialect = new MySqlDialect();
|
||||||
|
|
||||||
|
function queryText(query: SQL | string) {
|
||||||
|
return typeof query === "string" ? query : dialect.sqlToQuery(query).sql;
|
||||||
|
}
|
||||||
|
|
||||||
describe("getRankPermissionColumn", () => {
|
describe("getRankPermissionColumn", () => {
|
||||||
it("returns rank_N for valid rank", () => {
|
it("returns rank_N for valid rank", () => {
|
||||||
expect(getRankPermissionColumn(1)).toBe("rank_1");
|
expect(getRankPermissionColumn(1)).toBe("rank_1");
|
||||||
@@ -76,3 +85,54 @@ describe("RANK_GENERAL_FIELDS", () => {
|
|||||||
expect(RANK_GENERAL_FIELDS.has("nonexistent")).toBe(false);
|
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;
|
if (rankRows.length === 0) return null;
|
||||||
|
|
||||||
const rankColumn = getRankPermissionColumn(rankId);
|
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 permissions: Record<string, string> = {};
|
||||||
const permissionMaxValues: Record<string, number> = {};
|
const permissionMaxValues: Record<string, number> = {};
|
||||||
for (const row of permissionRows) {
|
const definitionColumns = await getTableColumns(db, "permission_definitions");
|
||||||
permissions[row.permission_key] = String(row.value ?? 0);
|
if (definitionColumns.has(rankColumn)) {
|
||||||
permissionMaxValues[row.permission_key] = Number(row.max_value ?? 1);
|
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];
|
const row = rankRows[0];
|
||||||
|
|||||||
Reference in new issue
Block a user