Remove 19 unused source files (no importers anywhere in src/):
- src/actions/admin-permissions.ts, admin-radio.ts, admin-user-edit.ts,
admin-users.ts (functionality lives in @/actions/users and
@/actions/permissions)
- src/components/admin/confirm-action.tsx, page-header.tsx
- src/components/motion-elements.tsx
- src/lib/format-date.ts
- src/lib/catalog-categories/* (incl. re-export barrel)
- src/lib/foundation/{index,database,middleware,validation}.ts (errors/action
kept, still imported directly)
- src/lib/services/imager/{avatar-renderer,memory-cache}.ts
- src/lib/services/nitro-assets.ts
Update admin-operations-contract test to drop the two removed action-file
gates (their permission coverage already exists in users.ts/permissions.ts).
Add knip.json for repeatable dead-code audits.
Verified: tsc --noEmit clean, next build succeeds, full test suite green
(301/301).
This commit is contained in:
1 parent
60eb45be73
commit
3c9c1311ec
23 files changed
+586
-2158
No files matched your search
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||||
|
"entry": [
|
||||||
|
"src/app/**/page.{ts,tsx}",
|
||||||
|
"src/app/**/layout.{ts,tsx}",
|
||||||
|
"src/app/**/route.{ts,tsx}",
|
||||||
|
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
||||||
|
"src/app/**/actions.{ts,tsx}",
|
||||||
|
"next.config.ts",
|
||||||
|
"src/middleware.ts"
|
||||||
|
],
|
||||||
|
"project": ["src/**/*.{ts,tsx}"],
|
||||||
|
"ignore": [
|
||||||
|
"src/**/*.test.{ts,tsx}",
|
||||||
|
"scripts/**"
|
||||||
|
],
|
||||||
|
"ignoreDependencies": ["@types/*", "knip"]
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@
|
|||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
|
"knip": "^6.27.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "^8.5.19",
|
"postcss": "^8.5.19",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
|
|||||||
Generated
+567
@@ -167,6 +167,9 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.4.0
|
specifier: ^16.4.0
|
||||||
version: 16.6.1
|
version: 16.6.1
|
||||||
|
knip:
|
||||||
|
specifier: ^6.27.0
|
||||||
|
version: 6.27.0
|
||||||
pino-pretty:
|
pino-pretty:
|
||||||
specifier: ^13.1.3
|
specifier: ^13.1.3
|
||||||
version: 13.1.3
|
version: 13.1.3
|
||||||
@@ -414,9 +417,21 @@ packages:
|
|||||||
'@electric-sql/[email protected]':
|
'@electric-sql/[email protected]':
|
||||||
resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==}
|
resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==}
|
||||||
|
|
||||||
|
'@emnapi/[email protected]':
|
||||||
|
resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==}
|
||||||
|
|
||||||
|
'@emnapi/[email protected]':
|
||||||
|
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
|
||||||
|
|
||||||
|
'@emnapi/[email protected]':
|
||||||
|
resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==}
|
||||||
|
|
||||||
'@emnapi/[email protected]':
|
'@emnapi/[email protected]':
|
||||||
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
|
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
|
||||||
|
|
||||||
|
'@emnapi/[email protected]':
|
||||||
|
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
|
||||||
|
|
||||||
'@esbuild/[email protected]':
|
'@esbuild/[email protected]':
|
||||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1085,6 +1100,12 @@ packages:
|
|||||||
'@kurkle/[email protected]':
|
'@kurkle/[email protected]':
|
||||||
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||||
|
|
||||||
|
'@napi-rs/[email protected]':
|
||||||
|
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@emnapi/core': ^1.7.1
|
||||||
|
'@emnapi/runtime': ^1.7.1
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==}
|
resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==}
|
||||||
|
|
||||||
@@ -1200,6 +1221,239 @@ packages:
|
|||||||
'@otplib/[email protected]':
|
'@otplib/[email protected]':
|
||||||
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
|
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
resolution: {integrity: sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxc-project/[email protected]':
|
||||||
|
resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==}
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
resolution: {integrity: sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@panva/[email protected]':
|
'@panva/[email protected]':
|
||||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||||
|
|
||||||
@@ -2134,6 +2388,9 @@ packages:
|
|||||||
'@tokenizer/[email protected]':
|
'@tokenizer/[email protected]':
|
||||||
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
|
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
|
||||||
|
|
||||||
|
'@tybys/[email protected]':
|
||||||
|
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
@@ -2620,6 +2877,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==}
|
resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -2641,6 +2901,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==}
|
||||||
|
engines: {node: '>=18.3.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==}
|
resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2674,6 +2939,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==}
|
resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==}
|
resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2815,6 +3083,11 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-CngYEYrD0n20N06FXA8n3u/0Wnnugoa+B9k14OP+iKIgkCHuzvIdsP3nfwjhByoc1WfogpxfiriMboAXFETDUw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==}
|
resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==}
|
||||||
|
|
||||||
@@ -3140,6 +3413,13 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
|
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3360,6 +3640,9 @@ packages:
|
|||||||
'@react-email/render':
|
'@react-email/render':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -3445,6 +3728,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||||
|
|
||||||
@@ -3555,6 +3842,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
@@ -3595,6 +3886,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-3cudTErfToSc4Ggv8XGXVNVli/xHKUtUZvaY5UVwhOcUPbQGz7PeaEnT/SAVgNziZtX67KEN9swMUYkLghxA1w==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
@@ -3709,6 +4004,10 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
|
||||||
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
|
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -3752,6 +4051,11 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||||
|
engines: {node: '>= 14.6'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3762,6 +4066,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alloc/[email protected]': {}
|
'@alloc/[email protected]': {}
|
||||||
@@ -3997,11 +4304,33 @@ snapshots:
|
|||||||
|
|
||||||
'@electric-sql/[email protected]': {}
|
'@electric-sql/[email protected]': {}
|
||||||
|
|
||||||
|
'@emnapi/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/wasi-threads': 1.2.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/wasi-threads': 1.2.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@emnapi/[email protected]':
|
'@emnapi/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/[email protected]':
|
'@esbuild/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4415,6 +4744,20 @@ snapshots:
|
|||||||
|
|
||||||
'@kurkle/[email protected]': {}
|
'@kurkle/[email protected]': {}
|
||||||
|
|
||||||
|
'@napi-rs/[email protected](@emnapi/[email protected])(@emnapi/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/core': 1.11.0
|
||||||
|
'@emnapi/runtime': 1.11.0
|
||||||
|
'@tybys/wasm-util': 0.10.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/[email protected](@emnapi/[email protected])(@emnapi/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/core': 1.11.1
|
||||||
|
'@emnapi/runtime': 1.11.1
|
||||||
|
'@tybys/wasm-util': 0.10.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@next/[email protected]': {}
|
'@next/[email protected]': {}
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
@@ -4507,6 +4850,133 @@ snapshots:
|
|||||||
'@otplib/plugin-crypto': 12.0.1
|
'@otplib/plugin-crypto': 12.0.1
|
||||||
'@otplib/plugin-thirty-two': 12.0.1
|
'@otplib/plugin-thirty-two': 12.0.1
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/core': 1.11.1
|
||||||
|
'@emnapi/runtime': 1.11.1
|
||||||
|
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/[email protected])(@emnapi/[email protected])
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-parser/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-project/[email protected]': {}
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/core': 1.11.0
|
||||||
|
'@emnapi/runtime': 1.11.0
|
||||||
|
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/[email protected])(@emnapi/[email protected])
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/[email protected]':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@panva/[email protected]': {}
|
'@panva/[email protected]': {}
|
||||||
|
|
||||||
'@parcel/[email protected]':
|
'@parcel/[email protected]':
|
||||||
@@ -5322,6 +5792,11 @@ snapshots:
|
|||||||
|
|
||||||
'@tokenizer/[email protected]': {}
|
'@tokenizer/[email protected]': {}
|
||||||
|
|
||||||
|
'@tybys/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
@@ -5823,6 +6298,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
walk-up-path: 4.0.0
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.5
|
picomatch: 4.0.5
|
||||||
@@ -5846,6 +6325,10 @@ snapshots:
|
|||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
fd-package-json: 2.0.0
|
||||||
|
|
||||||
[email protected]([email protected]([email protected]))([email protected]):
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
motion-dom: 12.42.2
|
motion-dom: 12.42.2
|
||||||
@@ -5868,6 +6351,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -6005,6 +6492,22 @@ snapshots:
|
|||||||
readable-stream: 2.3.8
|
readable-stream: 2.3.8
|
||||||
setimmediate: 1.0.5
|
setimmediate: 1.0.5
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
fdir: 6.5.0([email protected])
|
||||||
|
formatly: 0.3.0
|
||||||
|
get-tsconfig: 4.14.0
|
||||||
|
jiti: 2.7.0
|
||||||
|
oxc-parser: 0.137.0
|
||||||
|
oxc-resolver: 11.21.3
|
||||||
|
picomatch: 4.0.5
|
||||||
|
smol-toml: 1.7.0
|
||||||
|
strip-json-comments: 5.0.3
|
||||||
|
tinyglobby: 0.2.17
|
||||||
|
unbash: 4.0.3
|
||||||
|
yaml: 2.9.0
|
||||||
|
zod: 4.4.3
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
dayjs: 1.11.21
|
dayjs: 1.11.21
|
||||||
@@ -6266,6 +6769,53 @@ snapshots:
|
|||||||
'@otplib/preset-default': 12.0.1
|
'@otplib/preset-default': 12.0.1
|
||||||
'@otplib/preset-v11': 12.0.1
|
'@otplib/preset-v11': 12.0.1
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
'@oxc-project/types': 0.137.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@oxc-parser/binding-android-arm-eabi': 0.137.0
|
||||||
|
'@oxc-parser/binding-android-arm64': 0.137.0
|
||||||
|
'@oxc-parser/binding-darwin-arm64': 0.137.0
|
||||||
|
'@oxc-parser/binding-darwin-x64': 0.137.0
|
||||||
|
'@oxc-parser/binding-freebsd-x64': 0.137.0
|
||||||
|
'@oxc-parser/binding-linux-arm-gnueabihf': 0.137.0
|
||||||
|
'@oxc-parser/binding-linux-arm-musleabihf': 0.137.0
|
||||||
|
'@oxc-parser/binding-linux-arm64-gnu': 0.137.0
|
||||||
|
'@oxc-parser/binding-linux-arm64-musl': 0.137.0
|
||||||
|
'@oxc-parser/binding-linux-ppc64-gnu': 0.137.0
|
||||||
|
'@oxc-parser/binding-linux-riscv64-gnu': 0.137.0
|
||||||
|
'@oxc-parser/binding-linux-riscv64-musl': 0.137.0
|
||||||
|
'@oxc-parser/binding-linux-s390x-gnu': 0.137.0
|
||||||
|
'@oxc-parser/binding-linux-x64-gnu': 0.137.0
|
||||||
|
'@oxc-parser/binding-linux-x64-musl': 0.137.0
|
||||||
|
'@oxc-parser/binding-openharmony-arm64': 0.137.0
|
||||||
|
'@oxc-parser/binding-wasm32-wasi': 0.137.0
|
||||||
|
'@oxc-parser/binding-win32-arm64-msvc': 0.137.0
|
||||||
|
'@oxc-parser/binding-win32-ia32-msvc': 0.137.0
|
||||||
|
'@oxc-parser/binding-win32-x64-msvc': 0.137.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
optionalDependencies:
|
||||||
|
'@oxc-resolver/binding-android-arm-eabi': 11.21.3
|
||||||
|
'@oxc-resolver/binding-android-arm64': 11.21.3
|
||||||
|
'@oxc-resolver/binding-darwin-arm64': 11.21.3
|
||||||
|
'@oxc-resolver/binding-darwin-x64': 11.21.3
|
||||||
|
'@oxc-resolver/binding-freebsd-x64': 11.21.3
|
||||||
|
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.21.3
|
||||||
|
'@oxc-resolver/binding-linux-arm-musleabihf': 11.21.3
|
||||||
|
'@oxc-resolver/binding-linux-arm64-gnu': 11.21.3
|
||||||
|
'@oxc-resolver/binding-linux-arm64-musl': 11.21.3
|
||||||
|
'@oxc-resolver/binding-linux-ppc64-gnu': 11.21.3
|
||||||
|
'@oxc-resolver/binding-linux-riscv64-gnu': 11.21.3
|
||||||
|
'@oxc-resolver/binding-linux-riscv64-musl': 11.21.3
|
||||||
|
'@oxc-resolver/binding-linux-s390x-gnu': 11.21.3
|
||||||
|
'@oxc-resolver/binding-linux-x64-gnu': 11.21.3
|
||||||
|
'@oxc-resolver/binding-linux-x64-musl': 11.21.3
|
||||||
|
'@oxc-resolver/binding-openharmony-arm64': 11.21.3
|
||||||
|
'@oxc-resolver/binding-wasm32-wasi': 11.21.3
|
||||||
|
'@oxc-resolver/binding-win32-arm64-msvc': 11.21.3
|
||||||
|
'@oxc-resolver/binding-win32-x64-msvc': 11.21.3
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 0.1.0
|
yocto-queue: 0.1.0
|
||||||
@@ -6487,6 +7037,8 @@ snapshots:
|
|||||||
postal-mime: 2.7.4
|
postal-mime: 2.7.4
|
||||||
standardwebhooks: 1.0.0
|
standardwebhooks: 1.0.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -6636,6 +7188,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
atomic-sleep: 1.0.0
|
atomic-sleep: 1.0.0
|
||||||
@@ -6723,6 +7277,11 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
fdir: 6.5.0([email protected])
|
||||||
|
picomatch: 4.0.5
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -6751,6 +7310,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -6858,6 +7419,8 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -6924,6 +7487,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -6932,3 +7497,5 @@ snapshots:
|
|||||||
graphmatch: 1.1.1
|
graphmatch: 1.1.1
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logServerError } from "@/lib/server-log";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
|
|
||||||
// website_permissions (model WebsitePermissions) is the CMS-owned permission ->
|
|
||||||
// minimum-rank mapping. Editable columns are exactly: permission (unique name),
|
|
||||||
// min_rank (lowest rank that may use it), description. id is BigInt and
|
|
||||||
// created_at/updated_at are managed here.
|
|
||||||
|
|
||||||
function parseMinRank(formData: FormData): number {
|
|
||||||
const n = Number(
|
|
||||||
String(formData.get("minRank") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim(),
|
|
||||||
);
|
|
||||||
return Number.isInteger(n) && n >= 0 ? n : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPermission(formData: FormData): Promise<void> {
|
|
||||||
const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE);
|
|
||||||
const permission = String(formData.get("permission") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const minRank = parseMinRank(formData);
|
|
||||||
const description =
|
|
||||||
String(formData.get("description") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim() || null;
|
|
||||||
if (!permission) return;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
try {
|
|
||||||
await prisma.websitePermissions.upsert({
|
|
||||||
where: { permission },
|
|
||||||
update: { minRank, description, updatedAt: now },
|
|
||||||
create: {
|
|
||||||
permission,
|
|
||||||
minRank,
|
|
||||||
description,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "permission_create",
|
|
||||||
description: `Saved permission "${permission}" (min rank ${minRank})`,
|
|
||||||
targetType: "permission",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logServerError("admin.permission_create_failed", error, {
|
|
||||||
staffId: staff.id,
|
|
||||||
permission,
|
|
||||||
});
|
|
||||||
redirect("/admin/permissions?error=save");
|
|
||||||
// ignore (e.g. constraint failure) — page re-renders current state
|
|
||||||
}
|
|
||||||
revalidatePath("/admin/permissions");
|
|
||||||
redirect("/admin/permissions?saved=1");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updatePermission(formData: FormData): Promise<void> {
|
|
||||||
const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE);
|
|
||||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
|
||||||
if (!raw) return;
|
|
||||||
const id = BigInt(raw);
|
|
||||||
const permission = String(formData.get("permission") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const minRank = parseMinRank(formData);
|
|
||||||
const description =
|
|
||||||
String(formData.get("description") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim() || null;
|
|
||||||
if (!permission) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.websitePermissions.update({
|
|
||||||
where: { id },
|
|
||||||
data: { permission, minRank, description, updatedAt: new Date() },
|
|
||||||
});
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "permission_update",
|
|
||||||
description: `Updated permission #${id} ("${permission}" min rank ${minRank})`,
|
|
||||||
targetType: "permission",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logServerError("admin.permission_update_failed", error, {
|
|
||||||
staffId: staff.id,
|
|
||||||
permissionId: String(id),
|
|
||||||
});
|
|
||||||
redirect("/admin/permissions?error=save");
|
|
||||||
// ignore (e.g. duplicate) — page re-renders current state
|
|
||||||
}
|
|
||||||
revalidatePath("/admin/permissions");
|
|
||||||
redirect("/admin/permissions?saved=1");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePermission(formData: FormData): Promise<void> {
|
|
||||||
const staff = await requirePermission(PERMS.PERMISSIONS_MANAGE);
|
|
||||||
const id = formPositiveBigInt(formData, "id");
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deleted = await prisma.websitePermissions.delete({
|
|
||||||
where: { id },
|
|
||||||
select: { permission: true },
|
|
||||||
});
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "permission_delete",
|
|
||||||
description: `Deleted permission #${id} ("${deleted.permission}")`,
|
|
||||||
targetType: "permission",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logServerError("admin.permission_delete_failed", error, {
|
|
||||||
staffId: staff.id,
|
|
||||||
permissionId: String(id),
|
|
||||||
});
|
|
||||||
redirect("/admin/permissions?error=delete");
|
|
||||||
// ignore (e.g. already removed)
|
|
||||||
}
|
|
||||||
revalidatePath("/admin/permissions");
|
|
||||||
redirect("/admin/permissions?saved=1");
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"use server";
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
|
|
||||||
// users_currency.type values for the non-credits currencies (mirror send-currency.ts).
|
|
||||||
// Credits live on users.credits; pixels/points live on the users row too;
|
|
||||||
// duckets/diamonds live in users_currency keyed by (user_id, type).
|
|
||||||
const DUCKETS_TYPE = 0;
|
|
||||||
const DIAMONDS_TYPE = 5;
|
|
||||||
|
|
||||||
function toInt(value: FormDataEntryValue | null, min = 0): number | null {
|
|
||||||
if (value == null) return null;
|
|
||||||
const raw = String(value).trim();
|
|
||||||
if (raw === "") return null;
|
|
||||||
const n = Number(raw);
|
|
||||||
if (!Number.isFinite(n)) return null;
|
|
||||||
const i = Math.trunc(n);
|
|
||||||
return i < min ? min : i;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edit the SAFE website-managed fields of a users row (and the duckets/diamonds
|
|
||||||
* balances in users_currency). Never touches the password. Re-reads the staff
|
|
||||||
* user from the session and logs the action. emulator-owned users.id is Int.
|
|
||||||
*/
|
|
||||||
export async function updateUser(formData: FormData): Promise<void> {
|
|
||||||
// Never trust the client: re-check USERS_EDIT inside the action.
|
|
||||||
const staff = await requirePermission(PERMS.USERS_EDIT);
|
|
||||||
|
|
||||||
const userId = Number(formData.get("id"));
|
|
||||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!existing) return;
|
|
||||||
|
|
||||||
// users row — only existing, safe columns.
|
|
||||||
const mailRaw = String(formData.get("mail") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
const motto = String(formData.get("motto") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.slice(0, 127);
|
|
||||||
const look = String(formData.get("look") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.slice(0, 256);
|
|
||||||
const rank = toInt(formData.get("rank"), 1);
|
|
||||||
const credits = toInt(formData.get("credits"), 0);
|
|
||||||
const pixels = toInt(formData.get("pixels"), 0);
|
|
||||||
const points = toInt(formData.get("points"), 0);
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: {
|
|
||||||
mail: mailRaw === "" ? null : mailRaw.slice(0, 500),
|
|
||||||
motto,
|
|
||||||
look,
|
|
||||||
...(rank != null ? { rank } : {}),
|
|
||||||
...(credits != null ? { credits } : {}),
|
|
||||||
...(pixels != null ? { pixels } : {}),
|
|
||||||
...(points != null ? { points } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// users_currency — set exact balances for duckets / diamonds.
|
|
||||||
const duckets = toInt(formData.get("duckets"), 0);
|
|
||||||
const diamonds = toInt(formData.get("diamonds"), 0);
|
|
||||||
if (duckets != null) {
|
|
||||||
await prisma.usersCurrency.upsert({
|
|
||||||
where: { userId_type: { userId, type: DUCKETS_TYPE } },
|
|
||||||
update: { amount: duckets },
|
|
||||||
create: { userId, type: DUCKETS_TYPE, amount: duckets },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (diamonds != null) {
|
|
||||||
await prisma.usersCurrency.upsert({
|
|
||||||
where: { userId_type: { userId, type: DIAMONDS_TYPE } },
|
|
||||||
update: { amount: diamonds },
|
|
||||||
create: { userId, type: DIAMONDS_TYPE, amount: diamonds },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "user_edit",
|
|
||||||
description: `Edited account fields of user #${userId}`,
|
|
||||||
targetType: "user",
|
|
||||||
targetId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath(`/admin/users/${userId}`);
|
|
||||||
revalidatePath(`/admin/users/edit/${userId}`);
|
|
||||||
redirect(`/admin/users/show/${userId}`);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { requirePermission, requirePermissionRateLimited } from "@/lib/admin/guard";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
|
|
||||||
const CURRENCIES: ReadonlySet<string> = new Set([
|
|
||||||
"credits",
|
|
||||||
"duckets",
|
|
||||||
"diamonds",
|
|
||||||
"points",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export async function giveCurrency(formData: FormData): Promise<void> {
|
|
||||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
|
||||||
const userId = Number(formData.get("userId"));
|
|
||||||
const type = String(formData.get("type"));
|
|
||||||
const amount = Number(formData.get("amount"));
|
|
||||||
if (userId > 0 && amount > 0 && CURRENCIES.has(type)) {
|
|
||||||
await sendCurrency(
|
|
||||||
{ rcon, db: prisma },
|
|
||||||
userId,
|
|
||||||
type as CurrencyName,
|
|
||||||
amount,
|
|
||||||
);
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "give_currency",
|
|
||||||
description: `Gave ${amount} ${type} to user #${userId}`,
|
|
||||||
targetType: "user",
|
|
||||||
targetId: userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
revalidatePath(`/admin/users/${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setMotto(formData: FormData): Promise<void> {
|
|
||||||
await requirePermission(PERMS.USERS_EDIT);
|
|
||||||
const userId = Number(formData.get("userId"));
|
|
||||||
const motto = String(formData.get("motto") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.slice(0, 127);
|
|
||||||
if (userId > 0) {
|
|
||||||
await prisma.user.update({ where: { id: userId }, data: { motto } });
|
|
||||||
await rcon.setMotto(userId, motto);
|
|
||||||
}
|
|
||||||
revalidatePath(`/admin/users/${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setRank(formData: FormData): Promise<void> {
|
|
||||||
const staff = await requirePermissionRateLimited(PERMS.USERS_EDIT);
|
|
||||||
const userId = Number(formData.get("userId"));
|
|
||||||
const rank = Number(formData.get("rank"));
|
|
||||||
if (userId > 0 && rank > 0) {
|
|
||||||
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
|
||||||
await rcon.setRank(userId, rank);
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "rank_change",
|
|
||||||
description: `Set rank of user #${userId} to ${rank}`,
|
|
||||||
targetType: "user",
|
|
||||||
targetId: userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
revalidatePath(`/admin/users/${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function alertUser(formData: FormData): Promise<void> {
|
|
||||||
await requirePermission(PERMS.USERS_EDIT);
|
|
||||||
const userId = Number(formData.get("userId"));
|
|
||||||
const message = String(formData.get("message") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
if (userId > 0 && message) await rcon.alertUser(userId, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disconnectUser(formData: FormData): Promise<void> {
|
|
||||||
await requirePermission(PERMS.USERS_EDIT);
|
|
||||||
const userId = Number(formData.get("userId"));
|
|
||||||
const username = String(formData.get("username") ?? "").normalize("NFC");
|
|
||||||
if (userId > 0) await rcon.disconnectUser(userId, username);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export function ConfirmAction({
|
|
||||||
message,
|
|
||||||
action,
|
|
||||||
userId,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
message: string;
|
|
||||||
action: string;
|
|
||||||
userId: number;
|
|
||||||
className?: string;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
action="/api/admin/users/actions"
|
|
||||||
method="post"
|
|
||||||
style={{ display: "inline-block" }}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="userId" value={userId} />
|
|
||||||
<input type="hidden" name="action" value={action} />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className={className}
|
|
||||||
style={style}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (!confirm(message)) e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
interface AdminPageHeaderProps {
|
|
||||||
icon: LucideIcon;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminPageHeader({
|
|
||||||
icon: Icon,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
}: AdminPageHeaderProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
|
||||||
<Icon size={20} className="text-[var(--admin-accent)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { HTMLMotionProps } from "framer-motion";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import {
|
|
||||||
fadeIn,
|
|
||||||
fadeInUp,
|
|
||||||
hoverLift,
|
|
||||||
hoverScale,
|
|
||||||
scaleIn,
|
|
||||||
staggerContainer,
|
|
||||||
staggerItem,
|
|
||||||
} from "@/lib/motion";
|
|
||||||
|
|
||||||
type DivProps = HTMLMotionProps<"div">;
|
|
||||||
|
|
||||||
export function AnimatedSection({ children, className, ...props }: DivProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={className}
|
|
||||||
variants={staggerContainer}
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnimatedChild({ children, className, ...props }: DivProps) {
|
|
||||||
return (
|
|
||||||
<motion.div className={className} variants={staggerItem} {...props}>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FadeIn({ children, className, ...props }: DivProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={className}
|
|
||||||
variants={fadeIn}
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FadeInUp({ children, className, ...props }: DivProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={className}
|
|
||||||
variants={fadeInUp}
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScaleIn({ children, className, ...props }: DivProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={className}
|
|
||||||
variants={scaleIn}
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HoverCard({ children, className, ...props }: DivProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className={className}
|
|
||||||
{...hoverLift}
|
|
||||||
whileHover={{ y: -4, transition: { duration: 0.2 } }}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HoverButton({ children, className, ...props }: DivProps) {
|
|
||||||
return (
|
|
||||||
<motion.div className={className} {...hoverScale} {...props}>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -48,12 +48,10 @@ const ACTION_GATES: Array<[string, string]> = [
|
|||||||
["admin-radio-autodj.ts", "PERMS.RADIO_EDIT"],
|
["admin-radio-autodj.ts", "PERMS.RADIO_EDIT"],
|
||||||
["catalog.ts", "PERMS.CATALOG_EDIT"],
|
["catalog.ts", "PERMS.CATALOG_EDIT"],
|
||||||
["rooms.ts", "PERMS.ROOMS_EDIT"],
|
["rooms.ts", "PERMS.ROOMS_EDIT"],
|
||||||
["admin-users.ts", "PERMS.USERS_EDIT"],
|
|
||||||
["admin-vpn.ts", "PERMS.SETTINGS_EDIT"],
|
["admin-vpn.ts", "PERMS.SETTINGS_EDIT"],
|
||||||
["admin-ads.ts", "PERMS.PAGES_EDIT"],
|
["admin-ads.ts", "PERMS.PAGES_EDIT"],
|
||||||
["admin-vouchers.ts", "PERMS.SHOP_EDIT"],
|
["admin-vouchers.ts", "PERMS.SHOP_EDIT"],
|
||||||
["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"],
|
["admin-alerts.ts", "PERMS.NOTIFICATIONS_EDIT"],
|
||||||
["admin-permissions.ts", "PERMS.PERMISSIONS_MANAGE"],
|
|
||||||
["commandocentrum.ts", "PERMS.RCON_EXECUTE"],
|
["commandocentrum.ts", "PERMS.RCON_EXECUTE"],
|
||||||
["translations.ts", "PERMS.SETTINGS_EDIT"],
|
["translations.ts", "PERMS.SETTINGS_EDIT"],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "./catalog-categories/index";
|
|
||||||
@@ -1,695 +0,0 @@
|
|||||||
/**
|
|
||||||
* Supreme catalog categories for organizing Habbo furniture.
|
|
||||||
*
|
|
||||||
* Each category belongs to a root: furni, costruire, animali, vestiti, or staff.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Root Categories ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type RootCategory =
|
|
||||||
| "furni"
|
|
||||||
| "costruire"
|
|
||||||
| "animali"
|
|
||||||
| "vestiti"
|
|
||||||
| "staff";
|
|
||||||
|
|
||||||
export const ROOT_PAGES: Record<
|
|
||||||
RootCategory,
|
|
||||||
{ label: string; labelEn: string; icon: number }
|
|
||||||
> = {
|
|
||||||
furni: { label: "Mobili", labelEn: "Furniture", icon: 263 },
|
|
||||||
costruire: { label: "Costruire", labelEn: "Building", icon: 69991 },
|
|
||||||
animali: { label: "Animali", labelEn: "Pets", icon: 8 },
|
|
||||||
vestiti: { label: "Vestiti", labelEn: "Clothing", icon: 369 },
|
|
||||||
staff: { label: "Staff", labelEn: "Staff", icon: 65 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Supreme Categories ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface SupremeCategory {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
labelEn: string;
|
|
||||||
icon: number;
|
|
||||||
root: RootCategory;
|
|
||||||
keywords: string[];
|
|
||||||
patterns: RegExp[];
|
|
||||||
interactionTypes?: string[];
|
|
||||||
layout: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SUPREME_CATEGORIES: SupremeCategory[] = [
|
|
||||||
// ─── Staff ───
|
|
||||||
{
|
|
||||||
key: "staff_items",
|
|
||||||
label: "Staff",
|
|
||||||
labelEn: "Staff",
|
|
||||||
icon: 65,
|
|
||||||
root: "staff",
|
|
||||||
keywords: ["staff_", "admin_", "mod_", "moderator_"],
|
|
||||||
patterns: [/^staff_/, /^admin_/, /^mod_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
// ─── Vestiti ───
|
|
||||||
{
|
|
||||||
key: "fashion",
|
|
||||||
label: "Moda",
|
|
||||||
labelEn: "Fashion",
|
|
||||||
icon: 74,
|
|
||||||
root: "vestiti",
|
|
||||||
keywords: ["clothing_", "shirt_", "hat_", "dress_", "skirt_", "shoes_"],
|
|
||||||
patterns: [/^clothing_/, /^shirt_/, /^hat_/],
|
|
||||||
interactionTypes: ["clothing"],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
// ─── Animali ───
|
|
||||||
{
|
|
||||||
key: "pets",
|
|
||||||
label: "Animali",
|
|
||||||
labelEn: "Pets",
|
|
||||||
icon: 8,
|
|
||||||
root: "animali",
|
|
||||||
keywords: [
|
|
||||||
"pet_",
|
|
||||||
"petfood",
|
|
||||||
"pet_toy",
|
|
||||||
"pet_nest",
|
|
||||||
"pet_breeding",
|
|
||||||
"horse_",
|
|
||||||
"monsterplant",
|
|
||||||
"waterbowl",
|
|
||||||
"petfood_",
|
|
||||||
],
|
|
||||||
patterns: [
|
|
||||||
/^pet_/,
|
|
||||||
/^petfood/,
|
|
||||||
/^pet_toy/,
|
|
||||||
/^pet_nest/,
|
|
||||||
/^pet_breeding/,
|
|
||||||
/^horse_/,
|
|
||||||
/^monsterplant_/,
|
|
||||||
/^waterbowl/,
|
|
||||||
],
|
|
||||||
interactionTypes: [
|
|
||||||
"pet_food",
|
|
||||||
"pet_toy",
|
|
||||||
"pet_breeding_box",
|
|
||||||
"pet_nest",
|
|
||||||
"pet_drink",
|
|
||||||
],
|
|
||||||
layout: "pets",
|
|
||||||
},
|
|
||||||
// ─── Furni ───
|
|
||||||
{
|
|
||||||
key: "rare",
|
|
||||||
label: "Rari",
|
|
||||||
labelEn: "Rares",
|
|
||||||
icon: 145,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["rare_", "ultra_rare", "super_rare", "ltd_", "bonusrare"],
|
|
||||||
patterns: [/^rare_/, /^ultra_rare/, /^super_rare/, /^ltd_/, /^bonusrare/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "wired",
|
|
||||||
label: "Wired",
|
|
||||||
labelEn: "Wired",
|
|
||||||
icon: 5,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["wf_", "wired_"],
|
|
||||||
patterns: [
|
|
||||||
/^wf_/,
|
|
||||||
/^wired_/,
|
|
||||||
/^wf_act_/,
|
|
||||||
/^wf_cnd_/,
|
|
||||||
/^wf_trg_/,
|
|
||||||
/^wf_xtra/,
|
|
||||||
],
|
|
||||||
interactionTypes: [
|
|
||||||
"wired_condition",
|
|
||||||
"wired_trigger",
|
|
||||||
"wired_effect",
|
|
||||||
"wired_extra",
|
|
||||||
"wired_addon",
|
|
||||||
],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "teleports",
|
|
||||||
label: "Teletrasporti",
|
|
||||||
labelEn: "Teleports",
|
|
||||||
icon: 120,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["teleport"],
|
|
||||||
patterns: [/^teleport_/, /_teleport$/],
|
|
||||||
interactionTypes: ["teleport", "teleport_tile"],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "rollers",
|
|
||||||
label: "Rulli",
|
|
||||||
labelEn: "Rollers",
|
|
||||||
icon: 222,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["roller_", "queue_tile"],
|
|
||||||
patterns: [/^roller_/, /^queue_tile/],
|
|
||||||
interactionTypes: ["roller"],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "games",
|
|
||||||
label: "Giochi",
|
|
||||||
labelEn: "Games",
|
|
||||||
icon: 78,
|
|
||||||
root: "furni",
|
|
||||||
keywords: [
|
|
||||||
"freeze_",
|
|
||||||
"banzai",
|
|
||||||
"football_",
|
|
||||||
"snowb_",
|
|
||||||
"bb_gate",
|
|
||||||
"bb_patch",
|
|
||||||
"sporttrack",
|
|
||||||
],
|
|
||||||
patterns: [
|
|
||||||
/^bb_/,
|
|
||||||
/^freeze_/,
|
|
||||||
/^football_/,
|
|
||||||
/^snowb_/,
|
|
||||||
/^banzai_/,
|
|
||||||
/^battle_banzai/,
|
|
||||||
/^game_/,
|
|
||||||
/^sporttrack/,
|
|
||||||
],
|
|
||||||
interactionTypes: [
|
|
||||||
"battle_banzai_tile",
|
|
||||||
"battle_banzai_scoreboard",
|
|
||||||
"battle_banzai_puck",
|
|
||||||
"battle_banzai_sphere",
|
|
||||||
"battle_banzai_teleport",
|
|
||||||
"freeze_tile",
|
|
||||||
"freeze_exit",
|
|
||||||
"football_gate",
|
|
||||||
"football_goal",
|
|
||||||
"football",
|
|
||||||
],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "music",
|
|
||||||
label: "Musica",
|
|
||||||
labelEn: "Music",
|
|
||||||
icon: 11,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["sound_", "jukebox", "trax_", "song_disk"],
|
|
||||||
patterns: [/^sound_/, /^jukebox/, /^trax_/, /^song_disk/],
|
|
||||||
interactionTypes: ["jukebox", "soundmachine", "music_disc"],
|
|
||||||
layout: "soundmachine",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "trophies",
|
|
||||||
label: "Trofei",
|
|
||||||
labelEn: "Trophies",
|
|
||||||
icon: 221,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["trophy", "prizetrophy"],
|
|
||||||
patterns: [
|
|
||||||
/^trophy_/,
|
|
||||||
/^prizetrophy/,
|
|
||||||
/^gold_trophy/,
|
|
||||||
/^silver_trophy/,
|
|
||||||
/^bronze_trophy/,
|
|
||||||
],
|
|
||||||
interactionTypes: ["trophy"],
|
|
||||||
layout: "trophies",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "effects",
|
|
||||||
label: "Effetti",
|
|
||||||
labelEn: "Effects",
|
|
||||||
icon: 61,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["effect_", "fx_", "avatar_effect"],
|
|
||||||
patterns: [/^effect_/, /^fx_/, /^avatar_effect/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "credits",
|
|
||||||
label: "Crediti",
|
|
||||||
labelEn: "Credits",
|
|
||||||
icon: 27,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["cf_", "credit_furni", "deal_"],
|
|
||||||
patterns: [/^cf_/, /^credit_furni/, /^deal_/],
|
|
||||||
interactionTypes: ["exchange"],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "hc",
|
|
||||||
label: "Club Habbo",
|
|
||||||
labelEn: "Habbo Club",
|
|
||||||
icon: 172,
|
|
||||||
root: "furni",
|
|
||||||
keywords: [
|
|
||||||
"hc_",
|
|
||||||
"club_",
|
|
||||||
"vip_",
|
|
||||||
"hcsohva",
|
|
||||||
"hcamme",
|
|
||||||
"edicehc",
|
|
||||||
"mocchamaster",
|
|
||||||
],
|
|
||||||
patterns: [/^hc_/, /^club_/, /^vip_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "bots",
|
|
||||||
label: "Bot",
|
|
||||||
labelEn: "Bots",
|
|
||||||
icon: 36,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["bot_", "rentable_bot"],
|
|
||||||
patterns: [/^bot_/, /^rentable_bot/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "groups",
|
|
||||||
label: "Gruppi",
|
|
||||||
labelEn: "Groups",
|
|
||||||
icon: 203,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["guild_", "group_"],
|
|
||||||
patterns: [/^guild_/, /^group_/],
|
|
||||||
interactionTypes: ["guild_gate", "guild_forum"],
|
|
||||||
layout: "guilds",
|
|
||||||
},
|
|
||||||
// ─── Furniture Lines (alphabetical) ───
|
|
||||||
{
|
|
||||||
key: "area",
|
|
||||||
label: "Area",
|
|
||||||
labelEn: "Area",
|
|
||||||
icon: 14,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["area_"],
|
|
||||||
patterns: [/^area_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "asian",
|
|
||||||
label: "Asiatico",
|
|
||||||
labelEn: "Asian",
|
|
||||||
icon: 15,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["asian_", "china_", "jade_", "jp_", "wooden_screen"],
|
|
||||||
patterns: [/^jp_/, /^asian_/, /^china_/, /^jade_/, /^wooden_screen/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "bathroom",
|
|
||||||
label: "Bagno",
|
|
||||||
labelEn: "Bathroom",
|
|
||||||
icon: 17,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["bath_", "shower_", "toilet_"],
|
|
||||||
patterns: [/^bath_/, /^shower_/, /^toilet_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "candy",
|
|
||||||
label: "Caramelle",
|
|
||||||
labelEn: "Candy",
|
|
||||||
icon: 19,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["candy_", "sweet_"],
|
|
||||||
patterns: [/^candy_/, /^sweet_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "country",
|
|
||||||
label: "Country",
|
|
||||||
labelEn: "Country",
|
|
||||||
icon: 21,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["country_", "barn_", "hay_"],
|
|
||||||
patterns: [/^country_/, /^barn_/, /^hay_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cubie",
|
|
||||||
label: "Cubie",
|
|
||||||
labelEn: "Cubie",
|
|
||||||
icon: 100,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["cubie_"],
|
|
||||||
patterns: [/^cubie_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "diner",
|
|
||||||
label: "Diner",
|
|
||||||
labelEn: "Diner",
|
|
||||||
icon: 204,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["diner_", "dsk_"],
|
|
||||||
patterns: [/^diner_/, /^dsk_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "executive",
|
|
||||||
label: "Executive",
|
|
||||||
labelEn: "Executive",
|
|
||||||
icon: 27,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["executive_", "office_"],
|
|
||||||
patterns: [/^exe_/, /^executive_/, /^office_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "gothic",
|
|
||||||
label: "Gotico",
|
|
||||||
labelEn: "Gothic",
|
|
||||||
icon: 30,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["gothic_", "goth_"],
|
|
||||||
patterns: [/^gothic_/, /^goth_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "iced",
|
|
||||||
label: "Ghiacciato",
|
|
||||||
labelEn: "Iced",
|
|
||||||
icon: 72,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["iced_", "norja"],
|
|
||||||
patterns: [/^iced_/, /^norja/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "kitchen",
|
|
||||||
label: "Cucina",
|
|
||||||
labelEn: "Kitchen",
|
|
||||||
icon: 217,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["kitchen_", "fridge_", "stove_", "oven_", "sink_"],
|
|
||||||
patterns: [/^kitchen_/, /^fridge_/, /^stove_/, /^oven_/, /^sink_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "lodge",
|
|
||||||
label: "Baita",
|
|
||||||
labelEn: "Lodge",
|
|
||||||
icon: 37,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["lodge_"],
|
|
||||||
patterns: [/^lodge_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "maya",
|
|
||||||
label: "Maya",
|
|
||||||
labelEn: "Maya",
|
|
||||||
icon: 171,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["maya_"],
|
|
||||||
patterns: [/^maya_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "medieval",
|
|
||||||
label: "Medievale",
|
|
||||||
labelEn: "Medieval",
|
|
||||||
icon: 39,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["medieval", "castle_", "throne_", "knight_", "dragon_"],
|
|
||||||
patterns: [/^medieval_/, /^castle_/, /^throne_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "mode",
|
|
||||||
label: "Mode",
|
|
||||||
labelEn: "Mode",
|
|
||||||
icon: 39,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["polyfon", "bardesk"],
|
|
||||||
patterns: [/^bardesk/, /polyfon/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "mystics",
|
|
||||||
label: "Mistici",
|
|
||||||
labelEn: "Mystics",
|
|
||||||
icon: 185,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["mystic"],
|
|
||||||
patterns: [/^mystic/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "plastic",
|
|
||||||
label: "Plastica",
|
|
||||||
labelEn: "Plastic",
|
|
||||||
icon: 46,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["plasto", "plasty", "plastic_"],
|
|
||||||
patterns: [/plasto/, /plasty/, /^plastic_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "pura",
|
|
||||||
label: "Pura",
|
|
||||||
labelEn: "Pura",
|
|
||||||
icon: 48,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["pura_"],
|
|
||||||
patterns: [/^pura_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "relax",
|
|
||||||
label: "Relax",
|
|
||||||
labelEn: "Relax",
|
|
||||||
icon: 49,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["relax_"],
|
|
||||||
patterns: [/^relax_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "romantique",
|
|
||||||
label: "Romantique",
|
|
||||||
labelEn: "Romantique",
|
|
||||||
icon: 50,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["romantique", "romant_"],
|
|
||||||
patterns: [/^romantique/, /^romant_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "scifi",
|
|
||||||
label: "Sci-Fi",
|
|
||||||
labelEn: "Sci-Fi",
|
|
||||||
icon: 53,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["scifi"],
|
|
||||||
patterns: [/^scifi/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "steampunk",
|
|
||||||
label: "Steampunk",
|
|
||||||
labelEn: "Steampunk",
|
|
||||||
icon: 180,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["steampunk"],
|
|
||||||
patterns: [/^steampunk/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "tropical",
|
|
||||||
label: "Tropicale",
|
|
||||||
labelEn: "Tropical",
|
|
||||||
icon: 38,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["tropic", "tropical", "palm_", "tiki_", "coconut"],
|
|
||||||
patterns: [/^tropic_/, /^tropical_/, /^palm_/, /^tiki_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "usva",
|
|
||||||
label: "USVA",
|
|
||||||
labelEn: "USVA",
|
|
||||||
icon: 129,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["usva_"],
|
|
||||||
patterns: [/^usva_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "waasa",
|
|
||||||
label: "Waasa",
|
|
||||||
labelEn: "Waasa",
|
|
||||||
icon: 103,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["waasa_"],
|
|
||||||
patterns: [/^waasa_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
// ─── Seasonal ───
|
|
||||||
{
|
|
||||||
key: "christmas",
|
|
||||||
label: "Natale",
|
|
||||||
labelEn: "Christmas",
|
|
||||||
icon: 168,
|
|
||||||
root: "furni",
|
|
||||||
keywords: [
|
|
||||||
"xmas",
|
|
||||||
"christmas",
|
|
||||||
"santa",
|
|
||||||
"snowman",
|
|
||||||
"advent",
|
|
||||||
"jouluank",
|
|
||||||
"joulutahti",
|
|
||||||
"hyasintti",
|
|
||||||
"wcandle",
|
|
||||||
],
|
|
||||||
patterns: [/^xmas_/, /^christmas_/, /^xmas\d/, /^santai/, /^snow_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "halloween",
|
|
||||||
label: "Halloween",
|
|
||||||
labelEn: "Halloween",
|
|
||||||
icon: 34,
|
|
||||||
root: "furni",
|
|
||||||
keywords: [
|
|
||||||
"halloween",
|
|
||||||
"hween",
|
|
||||||
"ghost_",
|
|
||||||
"skull_",
|
|
||||||
"pumpkin",
|
|
||||||
"zombie",
|
|
||||||
"skeleton",
|
|
||||||
"spider_",
|
|
||||||
"witch_",
|
|
||||||
"deadduck",
|
|
||||||
"kallo",
|
|
||||||
],
|
|
||||||
patterns: [/^hween_/, /^halloween_/, /^ghost_/, /^skull_/, /^pumpkin/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "valentine",
|
|
||||||
label: "San Valentino",
|
|
||||||
labelEn: "Valentine",
|
|
||||||
icon: 62,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["valentine", "heart_", "love_", "cupid", "cupido"],
|
|
||||||
patterns: [/^val_/, /^valentine_/, /^heart_/, /^love_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "easter",
|
|
||||||
label: "Pasqua",
|
|
||||||
labelEn: "Easter",
|
|
||||||
icon: 181,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["easter", "bunny_", "easterduck"],
|
|
||||||
patterns: [/^easter_/, /^egg_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
// ─── Lighting ───
|
|
||||||
{
|
|
||||||
key: "lighting",
|
|
||||||
label: "Illuminazione",
|
|
||||||
labelEn: "Lighting",
|
|
||||||
icon: 115,
|
|
||||||
root: "furni",
|
|
||||||
keywords: ["lamp_", "light_", "candle_", "torch_", "neon_"],
|
|
||||||
patterns: [/^lamp_/, /^light_/, /^candle_/, /^torch_/, /^neon_/],
|
|
||||||
interactionTypes: ["dimmer"],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
// ─── Costruire ───
|
|
||||||
{
|
|
||||||
key: "builders_club",
|
|
||||||
label: "Builders Club",
|
|
||||||
labelEn: "Builders Club",
|
|
||||||
icon: 28,
|
|
||||||
root: "costruire",
|
|
||||||
keywords: ["bc_"],
|
|
||||||
patterns: [/^bc_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "walls",
|
|
||||||
label: "Pareti",
|
|
||||||
labelEn: "Walls",
|
|
||||||
icon: 122,
|
|
||||||
root: "costruire",
|
|
||||||
keywords: ["wallpaper", "poster_"],
|
|
||||||
patterns: [/^wall_/, /^wallpaper/, /^poster_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "floors",
|
|
||||||
label: "Pavimenti",
|
|
||||||
labelEn: "Floors",
|
|
||||||
icon: 41,
|
|
||||||
root: "costruire",
|
|
||||||
keywords: ["carpet_", "rug_", "doormat", "floortile"],
|
|
||||||
patterns: [/^floor_/, /^carpet_/, /^rug_/, /^doormat/, /^floortile/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "landscapes",
|
|
||||||
label: "Paesaggi",
|
|
||||||
labelEn: "Landscapes",
|
|
||||||
icon: 206,
|
|
||||||
root: "costruire",
|
|
||||||
keywords: ["landscape_"],
|
|
||||||
patterns: [/^landscape_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "windows",
|
|
||||||
label: "Finestre",
|
|
||||||
labelEn: "Windows",
|
|
||||||
icon: 218,
|
|
||||||
root: "costruire",
|
|
||||||
keywords: ["window_", "curtain_"],
|
|
||||||
patterns: [/^window_/, /^curtain_/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "doors_gates",
|
|
||||||
label: "Porte e Cancelli",
|
|
||||||
labelEn: "Doors & Gates",
|
|
||||||
icon: 55,
|
|
||||||
root: "costruire",
|
|
||||||
keywords: ["door_", "gate_", "marquee"],
|
|
||||||
patterns: [/^door_/, /^gate_/, /^marquee/],
|
|
||||||
interactionTypes: ["gate"],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "plants",
|
|
||||||
label: "Piante",
|
|
||||||
labelEn: "Plants",
|
|
||||||
icon: 220,
|
|
||||||
root: "costruire",
|
|
||||||
keywords: ["plant_", "tree_", "flower_", "bush_", "cactus"],
|
|
||||||
patterns: [/^plant_/, /^tree_/, /^flower_/, /^bush_/, /^cactus/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
// Fallback
|
|
||||||
{
|
|
||||||
key: "uncategorized",
|
|
||||||
label: "Non Categorizzati",
|
|
||||||
labelEn: "Uncategorized",
|
|
||||||
icon: 1,
|
|
||||||
root: "furni",
|
|
||||||
keywords: [],
|
|
||||||
patterns: [/.*/],
|
|
||||||
layout: "default_3x3",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/**
|
|
||||||
* Release eras for organizing Habbo furniture by revision number.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ── Release Eras ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ReleaseEra {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
labelEn: string;
|
|
||||||
revisionMin: number;
|
|
||||||
revisionMax: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RELEASE_ERAS: ReleaseEra[] = [
|
|
||||||
{
|
|
||||||
key: "classic",
|
|
||||||
label: "Classici 2000-2003",
|
|
||||||
labelEn: "Classic 2000-2003",
|
|
||||||
revisionMin: 0,
|
|
||||||
revisionMax: 19999,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "mid",
|
|
||||||
label: "Era HC 2004-2006",
|
|
||||||
labelEn: "HC Era 2004-2006",
|
|
||||||
revisionMin: 20000,
|
|
||||||
revisionMax: 39999,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "modern",
|
|
||||||
label: "Moderni 2007-2009",
|
|
||||||
labelEn: "Modern 2007-2009",
|
|
||||||
revisionMin: 40000,
|
|
||||||
revisionMax: 59999,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "recent",
|
|
||||||
label: "Recenti 2010-2012",
|
|
||||||
labelEn: "Recent 2010-2012",
|
|
||||||
revisionMin: 60000,
|
|
||||||
revisionMax: 79999,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "latest",
|
|
||||||
label: "Ultimi 2013+",
|
|
||||||
labelEn: "Latest 2013+",
|
|
||||||
revisionMin: 80000,
|
|
||||||
revisionMax: Infinity,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match a revision number to a release era.
|
|
||||||
*/
|
|
||||||
export function matchEra(revision: number): ReleaseEra {
|
|
||||||
for (const era of RELEASE_ERAS) {
|
|
||||||
if (revision >= era.revisionMin && revision <= era.revisionMax) {
|
|
||||||
return era;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return RELEASE_ERAS[RELEASE_ERAS.length - 1];
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
/**
|
|
||||||
* Supreme catalog categories + release eras for organizing Habbo furniture.
|
|
||||||
*
|
|
||||||
* Matching priority (first match wins within each level):
|
|
||||||
* 1. Interaction type (most specific)
|
|
||||||
* 2. Keywords anywhere in item name (normalized, *N stripped)
|
|
||||||
* 3. Regex prefix patterns (on normalized name)
|
|
||||||
* 4. Auto-prefix grouping (handled in route.ts)
|
|
||||||
* 5. Uncategorized fallback
|
|
||||||
*
|
|
||||||
* Each category belongs to a root: furni, costruire, animali, vestiti, or staff.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./categories";
|
|
||||||
export * from "./eras";
|
|
||||||
|
|
||||||
import { SUPREME_CATEGORIES, type SupremeCategory } from "./categories";
|
|
||||||
|
|
||||||
// ── Name Normalization ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a Habbo item classname for matching.
|
|
||||||
* Strips the *N color suffix (e.g. "chair_plasto*5" -> "chair_plasto")
|
|
||||||
* and lowercases the result.
|
|
||||||
*/
|
|
||||||
export function normalizeName(itemName: string): string {
|
|
||||||
return itemName.replace(/\*\d+$/, "").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Category Detection ──────────────────────────────────────────
|
|
||||||
|
|
||||||
export const UNCATEGORIZED_KEY = "uncategorized";
|
|
||||||
export const AUTO_PREFIX_MIN_ITEMS = 5;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the supreme category for an item by classname and interaction type.
|
|
||||||
*
|
|
||||||
* Priority: interaction type -> keywords (anywhere in normalized name) -> regex patterns.
|
|
||||||
* First match wins within each priority level.
|
|
||||||
* Item names are normalized (strip *N suffix, lowercase) before matching.
|
|
||||||
*/
|
|
||||||
export function matchCategory(
|
|
||||||
itemName: string,
|
|
||||||
interactionType?: string,
|
|
||||||
): SupremeCategory {
|
|
||||||
const lower = normalizeName(itemName);
|
|
||||||
|
|
||||||
// Pass 1: Interaction type (most specific)
|
|
||||||
for (const cat of SUPREME_CATEGORIES) {
|
|
||||||
if (interactionType && cat.interactionTypes?.includes(interactionType)) {
|
|
||||||
return cat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 2: Keywords anywhere in name (user priority)
|
|
||||||
for (const cat of SUPREME_CATEGORIES) {
|
|
||||||
if (cat.key === UNCATEGORIZED_KEY) continue;
|
|
||||||
for (const kw of cat.keywords) {
|
|
||||||
if (lower.includes(kw)) return cat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass 3: Regex prefix patterns (fallback)
|
|
||||||
for (const cat of SUPREME_CATEGORIES) {
|
|
||||||
if (cat.key === UNCATEGORIZED_KEY) continue;
|
|
||||||
for (const pattern of cat.patterns) {
|
|
||||||
if (pattern.test(lower)) return cat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SUPREME_CATEGORIES[SUPREME_CATEGORIES.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auto-Prefix Grouping ────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface AutoDetectedCategory {
|
|
||||||
key: string;
|
|
||||||
prefix: string;
|
|
||||||
label: string;
|
|
||||||
labelEn: string;
|
|
||||||
icon: number;
|
|
||||||
layout: string;
|
|
||||||
autoDetected: true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract prefix from an item name for auto-grouping.
|
|
||||||
* - Strips *N color suffix first ("chair_plasto*5" -> "chair_plasto")
|
|
||||||
* - If name contains '_', take everything before the first '_'
|
|
||||||
* - If no underscore, take the alphabetic prefix (letters before first digit)
|
|
||||||
* - Lowercase the result; return null if prefix is empty or 1 character
|
|
||||||
*/
|
|
||||||
export function extractPrefix(itemName: string): string | null {
|
|
||||||
const normalized = normalizeName(itemName);
|
|
||||||
const idx = normalized.indexOf("_");
|
|
||||||
if (idx > 1) return normalized.substring(0, idx);
|
|
||||||
const match = normalized.match(/^([a-z]{2,})/);
|
|
||||||
return match ? match[1] : null;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/** Format a Date as 'YYYY-MM-DD HH:MM:SS' — used across admin and radio pages. */
|
|
||||||
export function formatDateTime(d: Date | null | undefined): string {
|
|
||||||
return d ? d.toISOString().slice(0, 19).replace("T", " ") : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format a Date as 'YYYY-MM-DD' — used across radio list pages. */
|
|
||||||
export function formatDate(d: Date | null | undefined): string {
|
|
||||||
return d ? d.toISOString().slice(0, 10) : "";
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
|
||||||
import { env } from "@/env";
|
|
||||||
import { PrismaClient } from "@/generated/prisma/client";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { DatabaseError } from "./errors";
|
|
||||||
import { getRequestId } from "./request-context";
|
|
||||||
|
|
||||||
const globalForDb = globalThis as unknown as { _db?: DbService };
|
|
||||||
|
|
||||||
interface HealthStatus {
|
|
||||||
ok: boolean;
|
|
||||||
latencyMs: number;
|
|
||||||
poolSize: number;
|
|
||||||
activeQueries: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DbService {
|
|
||||||
private readonly client: PrismaClient;
|
|
||||||
private lastHealthCheck = 0;
|
|
||||||
private healthCache: HealthStatus | null = null;
|
|
||||||
private readonly healthTtlMs = 10_000;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const url = new URL(env.DATABASE_URL);
|
|
||||||
const adapter = new PrismaMariaDb({
|
|
||||||
host: url.hostname,
|
|
||||||
port: Number(url.port) || 3306,
|
|
||||||
user: decodeURIComponent(url.username),
|
|
||||||
password: decodeURIComponent(url.password),
|
|
||||||
database: url.pathname.replace(/^\//, ""),
|
|
||||||
connectionLimit: env.DATABASE_POOL_SIZE,
|
|
||||||
connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
|
||||||
acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
|
||||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.client = new PrismaClient({
|
|
||||||
adapter,
|
|
||||||
log:
|
|
||||||
env.NODE_ENV === "development"
|
|
||||||
? [
|
|
||||||
{ emit: "event", level: "query" },
|
|
||||||
{ emit: "event", level: "error" },
|
|
||||||
]
|
|
||||||
: [{ emit: "event", level: "error" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (env.NODE_ENV === "development") {
|
|
||||||
this.client.$on("query" as never, (e: unknown) => {
|
|
||||||
const ev = e as { query: string; duration: number };
|
|
||||||
logger.debug("DB query", {
|
|
||||||
query: ev.query.slice(0, 200),
|
|
||||||
durationMs: ev.duration,
|
|
||||||
requestId: getRequestId(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.client.$on("error" as never, (e: unknown) => {
|
|
||||||
const ev = e as { message: string };
|
|
||||||
logger.error("DB error", {
|
|
||||||
message: ev.message,
|
|
||||||
requestId: getRequestId(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get prisma(): PrismaClient {
|
|
||||||
return this.client;
|
|
||||||
}
|
|
||||||
|
|
||||||
async health(): Promise<HealthStatus> {
|
|
||||||
const now = Date.now();
|
|
||||||
if (this.healthCache && now - this.lastHealthCheck < this.healthTtlMs) {
|
|
||||||
return this.healthCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = performance.now();
|
|
||||||
try {
|
|
||||||
await this.client.$queryRaw`SELECT 1`;
|
|
||||||
const latencyMs = Math.round(performance.now() - start);
|
|
||||||
this.healthCache = {
|
|
||||||
ok: true,
|
|
||||||
latencyMs,
|
|
||||||
poolSize: env.DATABASE_POOL_SIZE,
|
|
||||||
activeQueries: 0,
|
|
||||||
};
|
|
||||||
this.lastHealthCheck = now;
|
|
||||||
return this.healthCache;
|
|
||||||
} catch (cause) {
|
|
||||||
const latencyMs = Math.round(performance.now() - start);
|
|
||||||
const message =
|
|
||||||
cause instanceof Error ? cause.message : "Unknown database error";
|
|
||||||
this.healthCache = {
|
|
||||||
ok: false,
|
|
||||||
latencyMs,
|
|
||||||
poolSize: env.DATABASE_POOL_SIZE,
|
|
||||||
activeQueries: 0,
|
|
||||||
error: message,
|
|
||||||
};
|
|
||||||
this.lastHealthCheck = now;
|
|
||||||
return this.healthCache;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute<T>(fn: (client: PrismaClient) => Promise<T>): Promise<T> {
|
|
||||||
try {
|
|
||||||
return await fn(this.client);
|
|
||||||
} catch (cause) {
|
|
||||||
throw new DatabaseError("Query failed", cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async transaction<T>(
|
|
||||||
fn: (
|
|
||||||
tx: Omit<
|
|
||||||
PrismaClient,
|
|
||||||
"$connect" | "$disconnect" | "$on" | "$use" | "$extends"
|
|
||||||
>,
|
|
||||||
) => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
try {
|
|
||||||
return await this.client.$transaction(fn);
|
|
||||||
} catch (cause) {
|
|
||||||
throw new DatabaseError("Transaction failed", cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async rawQuery<T>(
|
|
||||||
strings: TemplateStringsArray,
|
|
||||||
...values: unknown[]
|
|
||||||
): Promise<T> {
|
|
||||||
try {
|
|
||||||
return await this.client.$queryRaw<T>(strings, ...values);
|
|
||||||
} catch (cause) {
|
|
||||||
throw new DatabaseError("Raw query failed", cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a raw SQL string with parameterized ? placeholders.
|
|
||||||
* Named "Unsafe" because the caller is responsible for using ? placeholders
|
|
||||||
* and never interpolating user input directly into the query string.
|
|
||||||
*/
|
|
||||||
async executeRawUnsafe(query: string, ...values: unknown[]): Promise<number> {
|
|
||||||
try {
|
|
||||||
return await this.client.$executeRawUnsafe(query, ...values);
|
|
||||||
} catch (cause) {
|
|
||||||
throw new DatabaseError("Execute raw failed", cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const db = globalForDb._db ?? new DbService();
|
|
||||||
if (env.NODE_ENV !== "production") globalForDb._db = db;
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
export {
|
|
||||||
actionError,
|
|
||||||
actionOk,
|
|
||||||
adminAction,
|
|
||||||
authAction,
|
|
||||||
handleActionError,
|
|
||||||
} from "./action";
|
|
||||||
|
|
||||||
export { DbService, db } from "./database";
|
|
||||||
export {
|
|
||||||
ConflictError,
|
|
||||||
DatabaseError,
|
|
||||||
DomainError,
|
|
||||||
ForbiddenError,
|
|
||||||
NotFoundError,
|
|
||||||
RateLimitError,
|
|
||||||
UnauthorizedError,
|
|
||||||
ValidationError,
|
|
||||||
} from "./errors";
|
|
||||||
export {
|
|
||||||
addSecurityHeaders,
|
|
||||||
chain,
|
|
||||||
protectAdminRoutes,
|
|
||||||
withRequestContext,
|
|
||||||
} from "./middleware";
|
|
||||||
|
|
||||||
export {
|
|
||||||
createStore,
|
|
||||||
elapsed,
|
|
||||||
getClientIp,
|
|
||||||
getRequestId,
|
|
||||||
getRequestStore,
|
|
||||||
runWithStore,
|
|
||||||
setContextUserId,
|
|
||||||
} from "./request-context";
|
|
||||||
export {
|
|
||||||
canonicalize,
|
|
||||||
canonicalizeFormData,
|
|
||||||
canonicalizeFormValue,
|
|
||||||
extractClientIpAsync,
|
|
||||||
redirectSafe,
|
|
||||||
safeRedirect,
|
|
||||||
sanitizeFilename,
|
|
||||||
setCsrfCookie,
|
|
||||||
validateCsrfToken,
|
|
||||||
} from "./security";
|
|
||||||
export type {
|
|
||||||
ActionContext,
|
|
||||||
ActionFailure,
|
|
||||||
ActionResult,
|
|
||||||
ActionSuccess,
|
|
||||||
AdminActionContext,
|
|
||||||
AppSession,
|
|
||||||
IpAddress,
|
|
||||||
PaginatedQuery,
|
|
||||||
PaginatedResult,
|
|
||||||
PermissionSet,
|
|
||||||
RankId,
|
|
||||||
RequestContext,
|
|
||||||
RequestId,
|
|
||||||
SessionUser,
|
|
||||||
UserId,
|
|
||||||
} from "./types";
|
|
||||||
export {
|
|
||||||
bigIntString,
|
|
||||||
boolString,
|
|
||||||
buildSearchQuery,
|
|
||||||
email,
|
|
||||||
hexColor,
|
|
||||||
idParam,
|
|
||||||
look,
|
|
||||||
nonNegativeInt,
|
|
||||||
pagination,
|
|
||||||
password,
|
|
||||||
positiveInt,
|
|
||||||
slug,
|
|
||||||
url,
|
|
||||||
username,
|
|
||||||
} from "./validation";
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { createStore, getRequestId, runWithStore } from "./request-context";
|
|
||||||
import { extractClientIpAsync, safeRedirect } from "./security";
|
|
||||||
|
|
||||||
type MiddlewareHandler = (req: NextRequest) => Promise<NextResponse | null>;
|
|
||||||
|
|
||||||
export function chain(...handlers: MiddlewareHandler[]): MiddlewareHandler {
|
|
||||||
return async (req: NextRequest) => {
|
|
||||||
for (const handler of handlers) {
|
|
||||||
const result = await handler(req);
|
|
||||||
if (result) return result;
|
|
||||||
}
|
|
||||||
return NextResponse.next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withRequestContext(
|
|
||||||
handler: MiddlewareHandler,
|
|
||||||
): MiddlewareHandler {
|
|
||||||
return async (req: NextRequest) => {
|
|
||||||
const ip = await extractClientIpAsync();
|
|
||||||
const store = createStore(ip);
|
|
||||||
return runWithStore(store, async () => {
|
|
||||||
const start = Date.now();
|
|
||||||
try {
|
|
||||||
const result = await handler(req);
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
logger.info("Request completed", {
|
|
||||||
method: req.method,
|
|
||||||
path: req.nextUrl.pathname,
|
|
||||||
status: result?.status ?? 200,
|
|
||||||
durationMs: duration,
|
|
||||||
requestId: getRequestId(),
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
logger.error("Request failed", {
|
|
||||||
method: req.method,
|
|
||||||
path: req.nextUrl.pathname,
|
|
||||||
durationMs: duration,
|
|
||||||
requestId: getRequestId(),
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
const safeUrl = new URL(
|
|
||||||
safeRedirect(req.nextUrl.pathname, "/"),
|
|
||||||
req.url,
|
|
||||||
);
|
|
||||||
return NextResponse.redirect(safeUrl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function protectAdminRoutes(req: NextRequest): NextResponse | null {
|
|
||||||
const { pathname } = req.nextUrl;
|
|
||||||
|
|
||||||
if (!pathname.startsWith("/admin")) return null;
|
|
||||||
|
|
||||||
const authToken =
|
|
||||||
req.cookies.get("next-auth.session-token")?.value ??
|
|
||||||
req.cookies.get("__Secure-next-auth.session-token")?.value;
|
|
||||||
|
|
||||||
if (!authToken) {
|
|
||||||
const loginUrl = new URL("/login", req.url);
|
|
||||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
|
||||||
return NextResponse.redirect(loginUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addSecurityHeaders(req: NextRequest): NextResponse | null {
|
|
||||||
if (req.method === "OPTIONS") return null;
|
|
||||||
|
|
||||||
const response = NextResponse.next();
|
|
||||||
const csp = [
|
|
||||||
"default-src 'self'",
|
|
||||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
|
||||||
"style-src 'self' 'unsafe-inline'",
|
|
||||||
"img-src 'self' data: https: http:",
|
|
||||||
"font-src 'self' https:",
|
|
||||||
"connect-src 'self' https: wss:",
|
|
||||||
"frame-src 'self'",
|
|
||||||
"object-src 'none'",
|
|
||||||
"base-uri 'self'",
|
|
||||||
"form-action 'self'",
|
|
||||||
].join("; ");
|
|
||||||
|
|
||||||
response.headers.set("Content-Security-Policy", csp);
|
|
||||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
||||||
response.headers.set("X-Frame-Options", "DENY");
|
|
||||||
response.headers.set("X-XSS-Protection", "0");
|
|
||||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
||||||
response.headers.set(
|
|
||||||
"Permissions-Policy",
|
|
||||||
"camera=(), microphone=(), geolocation=()",
|
|
||||||
);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const USERNAME_RE = /^[a-zA-Z0-9\-_.]+$/;
|
|
||||||
|
|
||||||
export const username = z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, "Username is required")
|
|
||||||
.max(32, "Username must be at most 32 characters")
|
|
||||||
.regex(
|
|
||||||
USERNAME_RE,
|
|
||||||
"Username may only contain letters, numbers, hyphens, underscores, and dots",
|
|
||||||
)
|
|
||||||
.transform((v) => v.normalize("NFC"));
|
|
||||||
|
|
||||||
export const password = z
|
|
||||||
.string()
|
|
||||||
.min(8, "Password must be at least 8 characters")
|
|
||||||
.max(128, "Password must be at most 128 characters");
|
|
||||||
|
|
||||||
export const email = z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.email("Invalid email address")
|
|
||||||
.max(255, "Email must be at most 255 characters")
|
|
||||||
.transform((v) => v.normalize("NFC").toLowerCase());
|
|
||||||
|
|
||||||
const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
|
|
||||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
||||||
|
|
||||||
export const hexColor = z
|
|
||||||
.string()
|
|
||||||
.length(7, "Must be exactly 7 characters (e.g. #ff0000)")
|
|
||||||
.regex(HEX_COLOR_RE, "Must be a valid hex color (e.g. #ff0000)");
|
|
||||||
|
|
||||||
export const slug = z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, "Slug is required")
|
|
||||||
.max(64, "Slug must be at most 64 characters")
|
|
||||||
.regex(
|
|
||||||
SLUG_RE,
|
|
||||||
"Slug must be lowercase alphanumeric with hyphens only between characters",
|
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(v) => !v.startsWith("-") && !v.endsWith("-"),
|
|
||||||
"Slug must not start or end with a hyphen",
|
|
||||||
)
|
|
||||||
.transform((v) => v.normalize("NFC"));
|
|
||||||
|
|
||||||
export const url = z
|
|
||||||
.string()
|
|
||||||
.url("Invalid URL")
|
|
||||||
.max(2048, "URL must be at most 2048 characters");
|
|
||||||
|
|
||||||
export const look = z
|
|
||||||
.string()
|
|
||||||
.max(512, "Look string must be at most 512 characters")
|
|
||||||
.regex(/^[a-zA-Z0-9.\-_\s]+$/, "Invalid look format")
|
|
||||||
.optional();
|
|
||||||
|
|
||||||
export const positiveInt = z
|
|
||||||
.number()
|
|
||||||
.int("Must be a whole number")
|
|
||||||
.positive("Must be positive");
|
|
||||||
|
|
||||||
export const nonNegativeInt = z
|
|
||||||
.number()
|
|
||||||
.int("Must be a whole number")
|
|
||||||
.nonnegative("Must not be negative");
|
|
||||||
|
|
||||||
export const bigIntString = z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d+$/, "Must be a numeric string")
|
|
||||||
.transform(BigInt);
|
|
||||||
|
|
||||||
export const idParam = z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d+$/, "ID must be numeric")
|
|
||||||
.transform(Number);
|
|
||||||
|
|
||||||
export const pagination = z.object({
|
|
||||||
page: z.coerce.number().int().positive().default(1),
|
|
||||||
perPage: z.coerce.number().int().min(1).max(250).default(50),
|
|
||||||
sort: z.string().optional(),
|
|
||||||
order: z.enum(["asc", "desc"]).optional(),
|
|
||||||
search: z.string().max(256).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const boolString = z
|
|
||||||
.string()
|
|
||||||
.transform((v) => v === "true" || v === "1")
|
|
||||||
.or(z.boolean());
|
|
||||||
|
|
||||||
export function buildSearchQuery(fields: string[], search: string | undefined) {
|
|
||||||
if (!search?.trim()) return undefined;
|
|
||||||
const sanitized = search.normalize("NFC").trim().slice(0, 256);
|
|
||||||
return fields.map((f) => ({ [f]: { contains: sanitized } }));
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/**
|
|
||||||
* Self-hosted avatar imager — Phase 1.
|
|
||||||
*
|
|
||||||
* Pipeline (cascade):
|
|
||||||
* 1. In-memory LRU cache → instant
|
|
||||||
* 2. Disk cache → fast
|
|
||||||
* 3. In-flight dedup → coalesce thundering herd
|
|
||||||
* 4. Upstream Habbo proxy → save to disk + memory
|
|
||||||
* 5. Transparent fallback → never break the UI
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
|
||||||
import { InFlight, LruCache, NegativeCache } from "./memory-cache";
|
|
||||||
|
|
||||||
const CACHE_DIR = join(process.cwd(), "public", "cache", "avatars");
|
|
||||||
const DEFAULT_UPSTREAM = "https://www.habbo.com/habbo-imaging/avatarimage";
|
|
||||||
|
|
||||||
const MEM_CACHE_CAPACITY = 500;
|
|
||||||
const NEGATIVE_CACHE_TTL_MS = 60_000;
|
|
||||||
const UPSTREAM_TIMEOUT_MS = 10_000;
|
|
||||||
|
|
||||||
// 1×1 transparent PNG (67 bytes) — served when both local and upstream fail.
|
|
||||||
const TRANSPARENT_PNG = Buffer.from(
|
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
|
|
||||||
"base64",
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface AvatarRenderOptions {
|
|
||||||
figure: string;
|
|
||||||
size?: "s" | "m" | "l";
|
|
||||||
direction?: number;
|
|
||||||
headDirection?: number;
|
|
||||||
headOnly?: boolean;
|
|
||||||
gesture?: string;
|
|
||||||
action?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AvatarRenderResult {
|
|
||||||
buffer: Buffer;
|
|
||||||
etag: string;
|
|
||||||
source: "memory" | "disk" | "upstream" | "fallback";
|
|
||||||
}
|
|
||||||
|
|
||||||
const memCache = new LruCache<Buffer>(MEM_CACHE_CAPACITY);
|
|
||||||
const inFlight = new InFlight<AvatarRenderResult>();
|
|
||||||
const negativeCache = new NegativeCache(NEGATIVE_CACHE_TTL_MS);
|
|
||||||
|
|
||||||
function getCacheKey(opts: AvatarRenderOptions): string {
|
|
||||||
const raw = [
|
|
||||||
opts.figure,
|
|
||||||
opts.size ?? "m",
|
|
||||||
opts.direction ?? 2,
|
|
||||||
opts.headDirection ?? 3,
|
|
||||||
opts.headOnly ? "h" : "f",
|
|
||||||
opts.gesture ?? "",
|
|
||||||
opts.action ?? "",
|
|
||||||
].join("_");
|
|
||||||
return createHash("md5").update(raw).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCachePath(key: string): string {
|
|
||||||
return join(CACHE_DIR, key.slice(0, 2), `${key}.png`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEtag(key: string): string {
|
|
||||||
return `W/"${key}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveToCache(cachePath: string, data: Buffer): void {
|
|
||||||
try {
|
|
||||||
const dir = dirname(cachePath);
|
|
||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
||||||
writeFileSync(cachePath, data);
|
|
||||||
} catch {
|
|
||||||
/* non-fatal */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPng(buf: Buffer): boolean {
|
|
||||||
return (
|
|
||||||
buf.length > 8 &&
|
|
||||||
buf[0] === 0x89 &&
|
|
||||||
buf[1] === 0x50 &&
|
|
||||||
buf[2] === 0x4e &&
|
|
||||||
buf[3] === 0x47
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveUpstreamBase(): Promise<string> {
|
|
||||||
const fromEnv = process.env.IMAGING_UPSTREAM_URL?.trim();
|
|
||||||
if (fromEnv) return fromEnv.replace(/\/+$/, "");
|
|
||||||
const fromSettings =
|
|
||||||
(await siteSettings.get("habbo_imaging_url", DEFAULT_UPSTREAM)) ??
|
|
||||||
DEFAULT_UPSTREAM;
|
|
||||||
return fromSettings.replace(/\/+$/, "") || DEFAULT_UPSTREAM;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchUpstream(opts: AvatarRenderOptions): Promise<Buffer | null> {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
figure: opts.figure,
|
|
||||||
direction: String(opts.direction ?? 2),
|
|
||||||
head_direction: String(opts.headDirection ?? 3),
|
|
||||||
size: opts.size ?? "m",
|
|
||||||
});
|
|
||||||
if (opts.headOnly) params.set("headonly", "1");
|
|
||||||
if (opts.gesture) params.set("gesture", opts.gesture);
|
|
||||||
if (opts.action) params.set("action", opts.action);
|
|
||||||
|
|
||||||
const base = await resolveUpstreamBase();
|
|
||||||
const url = `${base}?${params.toString()}`;
|
|
||||||
const res = await fetch(url, {
|
|
||||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
|
||||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" },
|
|
||||||
});
|
|
||||||
if (!res.ok) return null;
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
|
||||||
return isPng(buffer) ? buffer : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an avatar image. Always returns a result — never throws, never returns
|
|
||||||
* null. Worst case is a 1×1 transparent PNG so the UI never shows broken
|
|
||||||
* images.
|
|
||||||
*/
|
|
||||||
export async function renderAvatar(
|
|
||||||
opts: AvatarRenderOptions,
|
|
||||||
): Promise<AvatarRenderResult> {
|
|
||||||
const key = getCacheKey(opts);
|
|
||||||
const etag = makeEtag(key);
|
|
||||||
|
|
||||||
const fromMem = memCache.get(key);
|
|
||||||
if (fromMem) return { buffer: fromMem, etag, source: "memory" };
|
|
||||||
|
|
||||||
const cachePath = getCachePath(key);
|
|
||||||
if (existsSync(cachePath)) {
|
|
||||||
try {
|
|
||||||
const buf = readFileSync(cachePath);
|
|
||||||
memCache.set(key, buf);
|
|
||||||
return { buffer: buf, etag, source: "disk" };
|
|
||||||
} catch {
|
|
||||||
/* fall through to fetch */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (negativeCache.isMarked(key)) {
|
|
||||||
return { buffer: TRANSPARENT_PNG, etag, source: "fallback" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return inFlight.dedup(key, async () => {
|
|
||||||
if (existsSync(cachePath)) {
|
|
||||||
try {
|
|
||||||
const buf = readFileSync(cachePath);
|
|
||||||
memCache.set(key, buf);
|
|
||||||
return { buffer: buf, etag, source: "disk" };
|
|
||||||
} catch {
|
|
||||||
/* fall through */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const useUpstream =
|
|
||||||
(await siteSettings.get("imaging_use_habbo_fallback", "1")) === "1";
|
|
||||||
if (useUpstream) {
|
|
||||||
const upstream = await fetchUpstream(opts);
|
|
||||||
if (upstream) {
|
|
||||||
saveToCache(cachePath, upstream);
|
|
||||||
memCache.set(key, upstream);
|
|
||||||
return { buffer: upstream, etag, source: "upstream" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
negativeCache.mark(key);
|
|
||||||
return { buffer: TRANSPARENT_PNG, etag, source: "fallback" };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getImagerStats() {
|
|
||||||
return {
|
|
||||||
memCacheSize: memCache.size,
|
|
||||||
memCacheCapacity: MEM_CACHE_CAPACITY,
|
|
||||||
cacheDir: CACHE_DIR,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tiny LRU + in-flight dedup utilities for the avatar imager.
|
|
||||||
* Pure in-house, zero deps. Map preserves insertion order, so we
|
|
||||||
* implement LRU by deleting + re-inserting on access.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class LruCache<V> {
|
|
||||||
private map = new Map<string, V>();
|
|
||||||
|
|
||||||
constructor(private readonly capacity: number) {}
|
|
||||||
|
|
||||||
get(key: string): V | undefined {
|
|
||||||
const v = this.map.get(key);
|
|
||||||
if (v === undefined) return undefined;
|
|
||||||
this.map.delete(key);
|
|
||||||
this.map.set(key, v);
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key: string, value: V): void {
|
|
||||||
if (this.map.has(key)) this.map.delete(key);
|
|
||||||
this.map.set(key, value);
|
|
||||||
if (this.map.size > this.capacity) {
|
|
||||||
const oldest = this.map.keys().next().value;
|
|
||||||
if (oldest !== undefined) this.map.delete(oldest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
has(key: string): boolean {
|
|
||||||
return this.map.has(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(key: string): void {
|
|
||||||
this.map.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.map.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
get size(): number {
|
|
||||||
return this.map.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-flight request deduplication. If N callers ask for the same key
|
|
||||||
* concurrently, only the first one runs `loader` and the rest await
|
|
||||||
* the same Promise.
|
|
||||||
*/
|
|
||||||
export class InFlight<V> {
|
|
||||||
private pending = new Map<string, Promise<V>>();
|
|
||||||
|
|
||||||
async dedup(key: string, loader: () => Promise<V>): Promise<V> {
|
|
||||||
const existing = this.pending.get(key);
|
|
||||||
if (existing) return existing;
|
|
||||||
const p = loader().finally(() => {
|
|
||||||
this.pending.delete(key);
|
|
||||||
});
|
|
||||||
this.pending.set(key, p);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Negative cache: marks a key as "known to fail" for `ttlMs` so we don't
|
|
||||||
* hammer the upstream / re-attempt expensive renders for broken figures.
|
|
||||||
*/
|
|
||||||
export class NegativeCache {
|
|
||||||
private map = new Map<string, number>();
|
|
||||||
|
|
||||||
constructor(private readonly ttlMs: number) {}
|
|
||||||
|
|
||||||
isMarked(key: string): boolean {
|
|
||||||
const exp = this.map.get(key);
|
|
||||||
if (exp === undefined) return false;
|
|
||||||
if (Date.now() > exp) {
|
|
||||||
this.map.delete(key);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
mark(key: string): void {
|
|
||||||
this.map.set(key, Date.now() + this.ttlMs);
|
|
||||||
if (this.map.size > 1000) {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [k, exp] of this.map) {
|
|
||||||
if (now > exp) this.map.delete(k);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.map.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { promises as fs } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { matchCategory, normalizeName } from "@/lib/catalog-categories";
|
|
||||||
import { getAncestors } from "@/lib/services/catalog-tree";
|
|
||||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Categorize a file using matchCategory logic.
|
|
||||||
* Returns the category key.
|
|
||||||
*/
|
|
||||||
export function categorizeFile(filename: string): string {
|
|
||||||
const name = filename.replace(/\.nitro$/, "");
|
|
||||||
const cat = matchCategory(normalizeName(name));
|
|
||||||
return cat.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a nested folder path from catalog page ancestors.
|
|
||||||
* Example: page "HC Rares" under "Rares" under "Furni" → "furni/rares/hc-rares"
|
|
||||||
* Includes the root page in the path.
|
|
||||||
*/
|
|
||||||
export async function buildCatalogPath(pageId: number): Promise<string> {
|
|
||||||
const ancestors = await getAncestors(pageId);
|
|
||||||
if (ancestors.length === 0) return "uncategorized";
|
|
||||||
|
|
||||||
return ancestors.map((a) => slugifyPath(a.caption)).join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sanitize a catalog page name into a valid folder segment */
|
|
||||||
export function slugifyPath(name: string): string {
|
|
||||||
return (
|
|
||||||
name
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "") || "uncategorized"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan the flat furniture folder for .nitro files.
|
|
||||||
*/
|
|
||||||
export async function scanFlatFolder(): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const { nitroDir } = await getFurniAssetDirs();
|
|
||||||
const entries = await fs.readdir(nitroDir);
|
|
||||||
return entries.filter((e) => e.endsWith(".nitro"));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan ALL .nitro files recursively (flat + subfolders).
|
|
||||||
* Returns a Set of classnames (filename without .nitro extension).
|
|
||||||
*/
|
|
||||||
export async function scanAllNitroFiles(): Promise<Set<string>> {
|
|
||||||
try {
|
|
||||||
const { nitroDir } = await getFurniAssetDirs();
|
|
||||||
const entries = await fs.readdir(nitroDir, { recursive: true });
|
|
||||||
const classnames = new Set<string>();
|
|
||||||
for (const entry of entries) {
|
|
||||||
const name = typeof entry === "string" ? entry : String(entry);
|
|
||||||
if (name.endsWith(".nitro")) {
|
|
||||||
const basename = path.basename(name, ".nitro");
|
|
||||||
classnames.add(basename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return classnames;
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user