Compare commits
14
Commits
316c699601
...
8a75e7e5b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a75e7e5b6 | ||
|
|
635abfbc9f | ||
|
|
b4968a9967 | ||
|
|
b4021f6b42 | ||
|
|
bcd24bfca4 | ||
|
|
99e77d9872 | ||
|
|
18825115a4 | ||
|
|
c95ad4a37f | ||
|
|
305a0c42ff | ||
|
|
7257a7b0f4 | ||
|
|
8a6c596727 | ||
|
|
f18735cf3e | ||
|
|
7c9602c5cd | ||
|
|
3b8bf74e35 |
No files matched your search
+11
-11
@@ -30,9 +30,9 @@
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@hookform/resolvers": "5.8.0",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@next/bundle-analyzer": "16.3.1",
|
||||
"@tanstack/react-virtual": "3.14.9",
|
||||
"@tanstack/react-virtual": "3.14.10",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
@@ -45,14 +45,14 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"jszip": "3.10.1",
|
||||
"lenis": "1.3.26",
|
||||
"lucide-react": "1.31.0",
|
||||
"lucide-react": "1.32.0",
|
||||
"lzma": "^2.3.2",
|
||||
"motion": "13.1.0",
|
||||
"music-metadata": "11.14.0",
|
||||
"music-metadata": "11.15.0",
|
||||
"mysql2": "3.23.3",
|
||||
"next": "16.3.1",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.13.6",
|
||||
"next-intl": "4.13.7",
|
||||
"otplib": "13.4.1",
|
||||
"pino": "10.3.1",
|
||||
"react": "19.2.8",
|
||||
@@ -66,24 +66,24 @@
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.8",
|
||||
"@biomejs/biome": "2.5.9",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"husky": "^9.1.7",
|
||||
"knip": "6.32.2",
|
||||
"lint-staged": "^17.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.5.25",
|
||||
"postcss": "^8.5.26",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.5",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "8.2.1",
|
||||
"vitest": "4.1.10"
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
}
|
||||
Generated
+142
-142
@@ -35,14 +35,14 @@ importers:
|
||||
specifier: 3.2.2
|
||||
version: 3.2.2([email protected])
|
||||
'@hookform/resolvers':
|
||||
specifier: 5.8.0
|
||||
version: 5.8.0(@standard-schema/[email protected])([email protected]([email protected]))([email protected])
|
||||
specifier: 5.9.1
|
||||
version: 5.9.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@next/bundle-analyzer':
|
||||
specifier: 16.3.1
|
||||
version: 16.3.1
|
||||
'@tanstack/react-virtual':
|
||||
specifier: 3.14.9
|
||||
version: 3.14.9([email protected]([email protected]))([email protected])
|
||||
specifier: 3.14.10
|
||||
version: 3.14.10([email protected]([email protected]))([email protected])
|
||||
class-variance-authority:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
@@ -80,8 +80,8 @@ importers:
|
||||
specifier: 1.3.26
|
||||
version: 1.3.26([email protected])
|
||||
lucide-react:
|
||||
specifier: 1.31.0
|
||||
version: 1.31.0([email protected])
|
||||
specifier: 1.32.0
|
||||
version: 1.32.0([email protected])
|
||||
lzma:
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2
|
||||
@@ -89,8 +89,8 @@ importers:
|
||||
specifier: 13.1.0
|
||||
version: 13.1.0([email protected]([email protected]))([email protected])
|
||||
music-metadata:
|
||||
specifier: 11.14.0
|
||||
version: 11.14.0([email protected])
|
||||
specifier: 11.15.0
|
||||
version: 11.15.0([email protected])
|
||||
mysql2:
|
||||
specifier: 3.23.3
|
||||
version: 3.23.3(@types/[email protected])
|
||||
@@ -101,8 +101,8 @@ importers:
|
||||
specifier: 5.0.0-beta.32
|
||||
version: 5.0.0-beta.32([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
|
||||
next-intl:
|
||||
specifier: 4.13.6
|
||||
version: 4.13.6(@swc/[email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
|
||||
specifier: 4.13.7
|
||||
version: 4.13.7(@swc/[email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
|
||||
otplib:
|
||||
specifier: 13.4.1
|
||||
version: 13.4.1
|
||||
@@ -138,8 +138,8 @@ importers:
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.8
|
||||
version: 2.5.8
|
||||
specifier: 2.5.9
|
||||
version: 2.5.9
|
||||
'@tailwindcss/forms':
|
||||
specifier: ^0.5.11
|
||||
version: 0.5.11([email protected])
|
||||
@@ -150,7 +150,7 @@ importers:
|
||||
specifier: ^0.5.20
|
||||
version: 0.5.20([email protected])
|
||||
'@types/node':
|
||||
specifier: ^26.1.2
|
||||
specifier: ^26.2.0
|
||||
version: 26.2.0
|
||||
'@types/react':
|
||||
specifier: 19.2.18
|
||||
@@ -159,8 +159,8 @@ importers:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4(@types/[email protected])
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10([email protected]0)
|
||||
specifier: 4.1.11
|
||||
version: 4.1.11([email protected]1)
|
||||
drizzle-kit:
|
||||
specifier: ^0.31.10
|
||||
version: 0.31.10
|
||||
@@ -183,7 +183,7 @@ importers:
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3
|
||||
tsx:
|
||||
specifier: ^4.23.5
|
||||
specifier: ^4.23.12
|
||||
version: 4.23.12
|
||||
typescript:
|
||||
specifier: ^7.0.2
|
||||
@@ -192,8 +192,8 @@ importers:
|
||||
specifier: 8.2.1
|
||||
version: 8.2.1(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
|
||||
vitest:
|
||||
specifier: 4.1.10
|
||||
version: 4.1.10(@types/[email protected])(@vitest/[email protected]0)([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
specifier: 4.1.11
|
||||
version: 4.1.11(@types/[email protected])(@vitest/[email protected]1)([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -267,59 +267,59 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
resolution: {integrity: sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw==}
|
||||
'@biomejs/[email protected].9':
|
||||
resolution: {integrity: sha512-KkgCvdHB4IhtpHpF564plA9jo6fDOwWGQ/3jvreLzgOtRLEDoPqr7QO9qejNA8jKwDsSkAKr77hqBHnyUbIw4g==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
resolution: {integrity: sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA==}
|
||||
'@biomejs/[email protected].9':
|
||||
resolution: {integrity: sha512-am22pX2aBqznqq1eMyIj/bZ++riF3Lk6ct7cbv+gQK0csFhr+d8O0RkOi2FF2qSgFgANbqNkIZ0/PxlnW2pLFg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
resolution: {integrity: sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w==}
|
||||
'@biomejs/[email protected].9':
|
||||
resolution: {integrity: sha512-l44KWDHLDvEnD0N/XcrVs7VXb3A18xL7QS3WB0eL93wbmk529ffIG55vleGCqaunpRUjLrdnjK05Qki1dsjylg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
resolution: {integrity: sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw==}
|
||||
'@biomejs/[email protected].9':
|
||||
resolution: {integrity: sha512-7ImVPwBLCtkmpR5esd8RHhTqW94f0JLJQum6AneYcy94jRm18TaPPm7slaigGzFhfgt3QiD1Vj52LKmBAnKizA==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
resolution: {integrity: sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ==}
|
||||
'@biomejs/[email protected].9':
|
||||
resolution: {integrity: sha512-ICaK+IYaVZvKbBxX2rwrPT0DdUDMnE9Vm3nQGe+mltQPmUg19pONzkPWGdY4FCsoreDETWDynvdt4ysCbF5gNQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
resolution: {integrity: sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w==}
|
||||
'@biomejs/[email protected].9':
|
||||
resolution: {integrity: sha512-RXGaD0o1/pTTguYw1aeDJh9ad6Lfrui0fI7mBderTyGr7WuUJkBIttgLkR3XJyoxOkkgfBDspaUT8wXArTqLZw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
resolution: {integrity: sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ==}
|
||||
'@biomejs/[email protected].9':
|
||||
resolution: {integrity: sha512-z22Q/zFYSvbIJfW1CbfZPu4X8PddS6Qd2ORbc6h+aT6EcwAxUF3m6fA4HjNvA3TU4X0dTJRwNPB165ES3PJXzg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
resolution: {integrity: sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA==}
|
||||
'@biomejs/[email protected].9':
|
||||
resolution: {integrity: sha512-nHK+/HHC+D0ogAHUxomgoSTdjImb6fmNNVTKmf0tyu4eDL1DqPKIHc+i+UL8+b0RnAu8224qo8F2tCVnaT0A3w==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
resolution: {integrity: sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA==}
|
||||
'@biomejs/[email protected].9':
|
||||
resolution: {integrity: sha512-Yiq0H56LjXSSw/hd9YkXgSLQfzyDJzbzU2TezozxyNw+uKWAqOtqGVvBfzKRRDiaFF5avGAhHdWKx7LtDOShUw==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -559,8 +559,8 @@ packages:
|
||||
'@formatjs/[email protected]':
|
||||
resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==}
|
||||
|
||||
'@hookform/resolvers@5.8.0':
|
||||
resolution: {integrity: sha512-2m6GvRLmYYK1Fwt093lGMf7db9l/+8pNuAtwoNkpBntJT4xcA5lNthYGWKViOc3z2SuaPD0HjE81pyXmqc1JyA==}
|
||||
'@hookform/resolvers@5.9.1':
|
||||
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
|
||||
peerDependencies:
|
||||
'@sinclair/typebox': '>=0.25.24'
|
||||
'@standard-schema/spec': ^1.0.0
|
||||
@@ -578,7 +578,7 @@ packages:
|
||||
fluentvalidation-ts: ^3.0.0
|
||||
fp-ts: ^2.7.0
|
||||
io-ts: ^2.0.0
|
||||
joi: ^17.0.0
|
||||
joi: ^17.0.0 || ^18.0.0
|
||||
nope-validator: '>=0.12.0'
|
||||
react-hook-form: ^7.55.0
|
||||
superstruct: '>=0.12.0'
|
||||
@@ -1692,14 +1692,14 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || >=4.0.0 || insiders'
|
||||
|
||||
'@tanstack/[email protected].9':
|
||||
resolution: {integrity: sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==}
|
||||
'@tanstack/[email protected].10':
|
||||
resolution: {integrity: sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/[email protected].7':
|
||||
resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==}
|
||||
'@tanstack/[email protected].8':
|
||||
resolution: {integrity: sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==}
|
||||
|
||||
'@tokenizer/[email protected]':
|
||||
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
|
||||
@@ -1854,20 +1854,20 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==}
|
||||
'@vitest/[email protected]1':
|
||||
resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.1.10
|
||||
vitest: 4.1.10
|
||||
'@vitest/browser': 4.1.11
|
||||
vitest: 4.1.11
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
|
||||
'@vitest/[email protected]1':
|
||||
resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==}
|
||||
'@vitest/[email protected]1':
|
||||
resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -1877,20 +1877,20 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==}
|
||||
'@vitest/[email protected]1':
|
||||
resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==}
|
||||
'@vitest/[email protected]1':
|
||||
resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==}
|
||||
'@vitest/[email protected]1':
|
||||
resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==}
|
||||
'@vitest/[email protected]1':
|
||||
resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
|
||||
'@vitest/[email protected]1':
|
||||
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
|
||||
@@ -1962,8 +1962,8 @@ packages:
|
||||
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
content-type@2.0.0:
|
||||
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
|
||||
content-type@2.1.0:
|
||||
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
@@ -2223,8 +2223,8 @@ packages:
|
||||
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-iYZGCJZ+kX6o7GrxpVe2sOSdW86AvEqh8RQBvWeBd9jqmuABsMc2B6xongACfItLOogyIWH6GuBslNHr79OU8Q==}
|
||||
[email protected].7:
|
||||
resolution: {integrity: sha512-X9gLFtipsP4HHbmy9urh+palImTR9P6lyhvmgbP6iym8i0IwhcsS4Z6KMjkEoCU6O16OJT5JIZkd8xfDROYo/A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
@@ -2477,8 +2477,8 @@ packages:
|
||||
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||
|
||||
[email protected]1.0:
|
||||
resolution: {integrity: sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==}
|
||||
[email protected]2.0:
|
||||
resolution: {integrity: sha512-txX56hMFnRxPi1f9/nH69YN8uvAO6a7Y1KSWKjCDAtdD9+soEgmWuCt6iRm1pkxUZo2+YntSdsE1L6bIuKoY8Q==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
@@ -2531,8 +2531,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-RyOSq98kuVfXB1emJ+NjBF0av8Ph3oBuqNy+Z5sFFfLhjYrkBQEB53V8u+U0RNTVwNo20WoPUwNkfKwZfrOqmQ==}
|
||||
[email protected]5.0:
|
||||
resolution: {integrity: sha512-TN+kO1/oOc8UzDW5N3vSDncBcv9WyNnQQe/NFMcFWq6G1+zVeYUkGvkYpQ/S8wb8vKtUD7i78dIaY0cv+cmPRA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
[email protected]:
|
||||
@@ -2570,11 +2570,11 @@ packages:
|
||||
nodemailer:
|
||||
optional: true
|
||||
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-M2L8jtPEAXj0CPmXbiW66THdr3OnDqA9IsU1hqv3CdxtVow3Bl9eXPdT9Opeji7L4AFrUZ016dJOs+CoTw66OA==}
|
||||
[email protected].7:
|
||||
resolution: {integrity: sha512-MxOUMGKncc/D6rofu0O80I6Ebr3gqlH8XiEb0Uu5TZmX7DqY3NVHs70Uy4bvtgWmHeDwsra6KU8EBtfRVGRv7Q==}
|
||||
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-loS6tjWWkr/IP+EV1yXUm9URB54QmZOp4+ZsMZNmeYxY8IZxLvO2esUegnXIDxj5DpK/4BsxwDGfGhlqodpkCQ==}
|
||||
[email protected].7:
|
||||
resolution: {integrity: sha512-j7KnGWt4Ih6TnW1x714R8bX3H+DYP25fqLTYTfUzAFXh0Od57WuQYM/Sf58yalvIXhE6y8sBYHlrCFmr0jPy3g==}
|
||||
peerDependencies:
|
||||
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
|
||||
@@ -2969,8 +2969,8 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-RLej84qL6PGTDp/PSG3tqRpwr7IvJfOu4Qfv/uyy8CrnYn1oEOQb6osNJb++jZ7FQxqN3aQ5BI7wTIerUgrgMA==}
|
||||
[email protected].7:
|
||||
resolution: {integrity: sha512-vWapep/2GESovKEmkxaG1Bkt6AWwANCWrM4kwOYbMmvQ4IsBGJFx9l66h8DNCcU0jSOzNtSFyRXFcYvgAak/Cg==}
|
||||
peerDependencies:
|
||||
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
|
||||
|
||||
@@ -3035,20 +3035,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
[email protected]0:
|
||||
resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==}
|
||||
[email protected]1:
|
||||
resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.10
|
||||
'@vitest/browser-preview': 4.1.10
|
||||
'@vitest/browser-webdriverio': 4.1.10
|
||||
'@vitest/coverage-istanbul': 4.1.10
|
||||
'@vitest/coverage-v8': 4.1.10
|
||||
'@vitest/ui': 4.1.10
|
||||
'@vitest/browser-playwright': 4.1.11
|
||||
'@vitest/browser-preview': 4.1.11
|
||||
'@vitest/browser-webdriverio': 4.1.11
|
||||
'@vitest/coverage-istanbul': 4.1.11
|
||||
'@vitest/coverage-v8': 4.1.11
|
||||
'@vitest/ui': 4.1.11
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -3168,39 +3168,39 @@ snapshots:
|
||||
|
||||
'@bcoe/[email protected]': {}
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
'@biomejs/[email protected].9':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.5.8
|
||||
'@biomejs/cli-darwin-x64': 2.5.8
|
||||
'@biomejs/cli-linux-arm64': 2.5.8
|
||||
'@biomejs/cli-linux-arm64-musl': 2.5.8
|
||||
'@biomejs/cli-linux-x64': 2.5.8
|
||||
'@biomejs/cli-linux-x64-musl': 2.5.8
|
||||
'@biomejs/cli-win32-arm64': 2.5.8
|
||||
'@biomejs/cli-win32-x64': 2.5.8
|
||||
'@biomejs/cli-darwin-arm64': 2.5.9
|
||||
'@biomejs/cli-darwin-x64': 2.5.9
|
||||
'@biomejs/cli-linux-arm64': 2.5.9
|
||||
'@biomejs/cli-linux-arm64-musl': 2.5.9
|
||||
'@biomejs/cli-linux-x64': 2.5.9
|
||||
'@biomejs/cli-linux-x64-musl': 2.5.9
|
||||
'@biomejs/cli-win32-arm64': 2.5.9
|
||||
'@biomejs/cli-win32-x64': 2.5.9
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
'@biomejs/[email protected].9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
'@biomejs/[email protected].9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
'@biomejs/[email protected].9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
'@biomejs/[email protected].9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
'@biomejs/[email protected].9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
'@biomejs/[email protected].9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
'@biomejs/[email protected].9':
|
||||
optional: true
|
||||
|
||||
'@biomejs/[email protected].8':
|
||||
'@biomejs/[email protected].9':
|
||||
optional: true
|
||||
|
||||
'@borewit/[email protected]': {}
|
||||
@@ -3372,7 +3372,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 3.1.7
|
||||
|
||||
'@hookform/resolvers@5.8.0(@standard-schema/[email protected])([email protected]([email protected]))([email protected])':
|
||||
'@hookform/resolvers@5.9.1(@standard-schema/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.85.0([email protected])
|
||||
@@ -4093,13 +4093,13 @@ snapshots:
|
||||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 4.3.3
|
||||
|
||||
'@tanstack/[email protected].9([email protected]([email protected]))([email protected])':
|
||||
'@tanstack/[email protected].10([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.7
|
||||
'@tanstack/virtual-core': 3.17.8
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8([email protected])
|
||||
|
||||
'@tanstack/[email protected].7': {}
|
||||
'@tanstack/[email protected].8': {}
|
||||
|
||||
'@tokenizer/[email protected]([email protected])':
|
||||
dependencies:
|
||||
@@ -4199,10 +4199,10 @@ snapshots:
|
||||
'@typescript/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected]0([email protected]0)':
|
||||
'@vitest/[email protected]1([email protected]1)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.10
|
||||
'@vitest/utils': 4.1.11
|
||||
ast-v8-to-istanbul: 1.0.5
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
@@ -4211,46 +4211,46 @@ snapshots:
|
||||
obug: 2.1.4
|
||||
std-env: 4.2.0
|
||||
tinyrainbow: 3.1.1
|
||||
vitest: 4.1.10(@types/[email protected])(@vitest/[email protected]0)([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
vitest: 4.1.11(@types/[email protected])(@vitest/[email protected]1)([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
'@vitest/[email protected]1':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
'@vitest/spy': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vitest/[email protected]0([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
|
||||
'@vitest/[email protected]1([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.10
|
||||
'@vitest/spy': 4.1.11
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.2.1(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
'@vitest/[email protected]1':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
'@vitest/[email protected]1':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.10
|
||||
'@vitest/utils': 4.1.11
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
'@vitest/[email protected]1':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/[email protected]0': {}
|
||||
'@vitest/[email protected]1': {}
|
||||
|
||||
'@vitest/[email protected]0':
|
||||
'@vitest/[email protected]1':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
@@ -4310,7 +4310,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
content-type@2.0.0: {}
|
||||
content-type@2.1.0: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -4472,7 +4472,7 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
[email protected].6:
|
||||
[email protected].7:
|
||||
dependencies:
|
||||
'@formatjs/icu-messageformat-parser': 3.5.16
|
||||
|
||||
@@ -4676,7 +4676,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]1.0([email protected]):
|
||||
[email protected]2.0([email protected]):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
|
||||
@@ -4720,11 +4720,11 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]4.0([email protected]):
|
||||
[email protected]5.0([email protected]):
|
||||
dependencies:
|
||||
'@borewit/text-codec': 0.2.2
|
||||
'@tokenizer/token': 0.3.0
|
||||
content-type: 2.0.0
|
||||
content-type: 2.1.0
|
||||
debug: 4.4.3([email protected])
|
||||
file-type: 21.3.4([email protected])
|
||||
media-typer: 2.0.0
|
||||
@@ -4760,20 +4760,20 @@ snapshots:
|
||||
next: 16.3.1(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
react: 19.2.8
|
||||
|
||||
[email protected].6: {}
|
||||
[email protected].7: {}
|
||||
|
||||
[email protected].6(@swc/[email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]):
|
||||
[email protected].7(@swc/[email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]):
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.8.13
|
||||
'@parcel/watcher': 2.6.0
|
||||
'@swc/core': 1.15.47(@swc/[email protected])
|
||||
icu-minify: 4.13.6
|
||||
icu-minify: 4.13.7
|
||||
negotiator: 1.0.0
|
||||
next: 16.3.1(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
next-intl-swc-plugin-extractor: 4.13.6
|
||||
next-intl-swc-plugin-extractor: 4.13.7
|
||||
po-parser: 2.1.1
|
||||
react: 19.2.8
|
||||
use-intl: 4.13.6([email protected])
|
||||
use-intl: 4.13.7([email protected])
|
||||
optionalDependencies:
|
||||
typescript: 7.0.2
|
||||
transitivePeerDependencies:
|
||||
@@ -5220,11 +5220,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.18
|
||||
|
||||
[email protected].6([email protected]):
|
||||
[email protected].7([email protected]):
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 3.1.7
|
||||
'@schummar/icu-type-parser': 1.21.5
|
||||
icu-minify: 4.13.6
|
||||
icu-minify: 4.13.7
|
||||
intl-messageformat: 11.2.13
|
||||
react: 19.2.8
|
||||
|
||||
@@ -5257,15 +5257,15 @@ snapshots:
|
||||
tsx: 4.23.12
|
||||
yaml: 2.9.0
|
||||
|
||||
[email protected]0(@types/[email protected])(@vitest/[email protected]0)([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
|
||||
[email protected]1(@types/[email protected])(@vitest/[email protected]1)([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/runner': 4.1.10
|
||||
'@vitest/snapshot': 4.1.10
|
||||
'@vitest/spy': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
'@vitest/expect': 4.1.11
|
||||
'@vitest/mocker': 4.1.11([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
'@vitest/runner': 4.1.11
|
||||
'@vitest/snapshot': 4.1.11
|
||||
'@vitest/spy': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
es-module-lexer: 2.3.1
|
||||
expect-type: 1.4.0
|
||||
magic-string: 0.30.21
|
||||
@@ -5281,7 +5281,7 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.2.0
|
||||
'@vitest/coverage-v8': 4.1.10([email protected]0)
|
||||
'@vitest/coverage-v8': 4.1.11([email protected]1)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
|
||||
@@ -10,8 +10,14 @@ import {
|
||||
appendFurniEntriesBatch,
|
||||
buildFurniEntry,
|
||||
readFurniData,
|
||||
reconcileFurniDataWithItemsBase,
|
||||
} from "@/lib/services/furni-data";
|
||||
import { downloadFile, ensureDirectories } from "@/lib/services/furni-import";
|
||||
import {
|
||||
downloadFile,
|
||||
ensureDirectories,
|
||||
ensureFurniOwnership,
|
||||
syncAssetsToGamedataBundle,
|
||||
} from "@/lib/services/furni-import";
|
||||
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
||||
@@ -254,12 +260,27 @@ export const POST = withAdmin(
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
// Keep regenerated assets readable by the web server
|
||||
const ownership = await ensureFurniOwnership();
|
||||
|
||||
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
|
||||
const assetsSynced = await syncAssetsToGamedataBundle();
|
||||
|
||||
// Verify the furnidata matches the DB ids
|
||||
const furniReconcile = await reconcileFurniDataWithItemsBase();
|
||||
|
||||
send({
|
||||
type: "regen_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
skipped,
|
||||
furniDataAdded: furniDataEntries.length,
|
||||
ownershipFixed: ownership.fixed,
|
||||
nitrosSynced: assetsSynced.copiedNitros,
|
||||
iconsSynced: assetsSynced.copiedIcons,
|
||||
furniDataFixedIds: furniReconcile.fixedIds,
|
||||
furniDataMissing: furniReconcile.missing,
|
||||
furniDataConflicts: furniReconcile.conflicts,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,10 +3,16 @@ import { apiError } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { getSource } from "@/lib/services/clone-sources";
|
||||
import { appendFurniEntriesBatch } from "@/lib/services/furni-data";
|
||||
import {
|
||||
appendFurniEntriesBatch,
|
||||
reconcileFurniDataWithItemsBase,
|
||||
} from "@/lib/services/furni-data";
|
||||
import {
|
||||
ensureDirectories,
|
||||
ensureFurniOwnership,
|
||||
importSingleFurni,
|
||||
reconcileImportedOfferIds,
|
||||
syncAssetsToGamedataBundle,
|
||||
} from "@/lib/services/furni-import";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import type { ImportSingleResult } from "@/types/furni";
|
||||
@@ -211,11 +217,43 @@ export const POST = withAdmin(
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile offer_id and fix asset ownership across the whole tree
|
||||
let offerIdsFixed = 0;
|
||||
let ownershipFixed: string[] = [];
|
||||
let nitrosSynced: string[] = [];
|
||||
let iconsSynced: string[] = [];
|
||||
let furniDataFixedIds = 0;
|
||||
let furniDataMissing = 0;
|
||||
let furniDataConflicts = 0;
|
||||
try {
|
||||
offerIdsFixed = (await reconcileImportedOfferIds()).fixed;
|
||||
ownershipFixed = (await ensureFurniOwnership()).fixed;
|
||||
const assetsSynced = await syncAssetsToGamedataBundle();
|
||||
nitrosSynced = assetsSynced.copiedNitros;
|
||||
iconsSynced = assetsSynced.copiedIcons;
|
||||
const furniReconcile = await reconcileFurniDataWithItemsBase();
|
||||
furniDataFixedIds = furniReconcile.fixedIds;
|
||||
furniDataMissing = furniReconcile.missing;
|
||||
furniDataConflicts = furniReconcile.conflicts;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[import-furni] Post-import reconcile/ownership failed:",
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
|
||||
send({
|
||||
type: "batch_complete",
|
||||
succeeded,
|
||||
failed,
|
||||
warnings: withWarnings,
|
||||
offerIdsFixed,
|
||||
ownershipFixed,
|
||||
nitrosSynced,
|
||||
iconsSynced,
|
||||
furniDataFixedIds,
|
||||
furniDataMissing,
|
||||
furniDataConflicts,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { logAudit } from "@/lib/services/audit";
|
||||
import { getCloneList } from "@/lib/services/clone-import";
|
||||
import { getSource } from "@/lib/services/clone-sources";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { reconcileFurniDataWithItemsBase } from "@/lib/services/furni-data";
|
||||
import {
|
||||
autoPriceFurni,
|
||||
CATEGORY_PAGE,
|
||||
@@ -17,9 +18,12 @@ import {
|
||||
deleteImportedItem,
|
||||
downloadFile,
|
||||
ensureDirectories,
|
||||
ensureFurniOwnership,
|
||||
IMPORTED_PAGE_CAPTION_SAVE,
|
||||
importSingleFurni,
|
||||
reconcileImportedOfferIds,
|
||||
resetCatalogPageCache,
|
||||
syncAssetsToGamedataBundle,
|
||||
} from "@/lib/services/furni-import";
|
||||
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { listFurniture } from "@/lib/services/habbofurni";
|
||||
@@ -403,10 +407,34 @@ export const POST = withAdmin(
|
||||
);
|
||||
}
|
||||
|
||||
// Reconcile offer_id with the furnidata sprite id across the tree
|
||||
const reconciled = await reconcileImportedOfferIds();
|
||||
|
||||
// Keep asset + furnidata folders readable by the web server
|
||||
const ownership = await ensureFurniOwnership();
|
||||
|
||||
// Backfill any missing nitros into the emulator's Gamedata bundle
|
||||
const assetsSynced = await syncAssetsToGamedataBundle();
|
||||
|
||||
// Verify the furnidata matches the DB ids
|
||||
const furniReconcile = await reconcileFurniDataWithItemsBase();
|
||||
|
||||
const succeeded = results.filter((r) => r.ok).length;
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
|
||||
return apiOk({ batch: true, succeeded, failed, results });
|
||||
return apiOk({
|
||||
batch: true,
|
||||
succeeded,
|
||||
failed,
|
||||
results,
|
||||
offerIdsFixed: reconciled.fixed,
|
||||
ownershipFixed: ownership.fixed,
|
||||
nitrosSynced: assetsSynced.copiedNitros,
|
||||
iconsSynced: assetsSynced.copiedIcons,
|
||||
furniDataFixedIds: furniReconcile.fixedIds,
|
||||
furniDataMissing: furniReconcile.missing,
|
||||
furniDataConflicts: furniReconcile.conflicts,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Single import ────────────────────────────────────────────────
|
||||
@@ -455,6 +483,18 @@ export const POST = withAdmin(
|
||||
result.warnings.push("RCON cache refresh failed");
|
||||
}
|
||||
|
||||
// Reconcile offer_id with the furnidata sprite id across the tree
|
||||
const reconciled = await reconcileImportedOfferIds();
|
||||
|
||||
// Keep asset + furnidata folders readable by the web server
|
||||
const ownership = await ensureFurniOwnership();
|
||||
|
||||
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
|
||||
const assetsSynced = await syncAssetsToGamedataBundle();
|
||||
|
||||
// Verify the furnidata matches the DB ids
|
||||
const furniReconcile = await reconcileFurniDataWithItemsBase();
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_import",
|
||||
@@ -475,9 +515,15 @@ export const POST = withAdmin(
|
||||
return apiOk({
|
||||
id: result.itemId,
|
||||
catalogItemId: result.catalogItemId,
|
||||
dimensions: result.dimensions,
|
||||
offerIdsFixed: reconciled.fixed,
|
||||
ownershipFixed: ownership.fixed,
|
||||
nitrosSynced: assetsSynced.copiedNitros,
|
||||
iconsSynced: assetsSynced.copiedIcons,
|
||||
furniDataFixedIds: furniReconcile.fixedIds,
|
||||
furniDataMissing: furniReconcile.missing,
|
||||
furniDataConflicts: furniReconcile.conflicts,
|
||||
spriteCount: result.spriteCount,
|
||||
warnings: result.warnings.length > 0 ? result.warnings : undefined,
|
||||
warnings: result.warnings,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -211,6 +211,26 @@ summary,
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Theme-aware scrollbars inside the admin panel */
|
||||
[data-admin] ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
[data-admin] ::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--admin-text-muted) 32%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
[data-admin] ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--admin-text-muted) 55%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Atom theme components ────────────────────────────────── */
|
||||
@layer components {
|
||||
.nav-item {
|
||||
|
||||
@@ -1,27 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BatchItemStatus } from "./studio-types";
|
||||
import type { BatchItemStatus, VerificationSummary } from "./studio-types";
|
||||
|
||||
function SummaryRow({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
tone?: "ok" | "warn";
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-[var(--admin-text-muted)]">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono font-semibold",
|
||||
tone === "warn"
|
||||
? "text-[var(--admin-warning)]"
|
||||
: "text-[var(--admin-success)]",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BatchProgress({
|
||||
progress,
|
||||
done,
|
||||
succeeded,
|
||||
failed,
|
||||
cancelling,
|
||||
verification,
|
||||
onCancel,
|
||||
onDismiss,
|
||||
}: {
|
||||
progress: Map<string, BatchItemStatus>;
|
||||
done: boolean;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
cancelling?: boolean;
|
||||
verification?: VerificationSummary | null;
|
||||
onCancel?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
const entries = [...progress.entries()];
|
||||
const total = entries.length;
|
||||
const completed = entries.filter(
|
||||
([, s]) => s.status === "done" || s.status === "failed",
|
||||
).length;
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="border-t border-[var(--admin-border)] p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
Importing…
|
||||
{done ? "Import finished" : "Importing…"}
|
||||
</span>
|
||||
{done ? (
|
||||
<span
|
||||
@@ -32,15 +73,31 @@ export function BatchProgress({
|
||||
: "text-[var(--admin-success)]",
|
||||
)}
|
||||
>
|
||||
✓ {succeeded} ok{failed > 0 ? ` · ${failed} failed` : ""}
|
||||
{completed}/{total} · {succeeded} ok
|
||||
{failed > 0 ? ` · ${failed} failed` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<Loader2
|
||||
size={14}
|
||||
className="animate-spin text-[var(--admin-accent)]"
|
||||
/>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
|
||||
{completed}/{total}
|
||||
</span>
|
||||
<Loader2
|
||||
size={14}
|
||||
className="animate-spin text-[var(--admin-accent)]"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!done && (
|
||||
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-44 space-y-1 overflow-y-auto pr-1">
|
||||
{entries.map(([classname, s]) => (
|
||||
<div key={classname} className="flex items-center gap-2 text-xs">
|
||||
@@ -66,6 +123,68 @@ export function BatchProgress({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{done && verification && (
|
||||
<div className="mt-2 space-y-1.5 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-2.5">
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
<ShieldCheck size={13} className="text-[var(--admin-success)]" />
|
||||
Post-import verification
|
||||
</div>
|
||||
<SummaryRow
|
||||
label="Catalog offer_id fixed"
|
||||
value={verification.offerIdsFixed}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Furnidata ids fixed"
|
||||
value={verification.furniDataFixedIds}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Furnidata entries missing"
|
||||
value={verification.furniDataMissing}
|
||||
tone={verification.furniDataMissing > 0 ? "warn" : "ok"}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Furnidata id conflicts"
|
||||
value={verification.furniDataConflicts}
|
||||
tone={verification.furniDataConflicts > 0 ? "warn" : "ok"}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Nitros synced"
|
||||
value={verification.nitrosSynced.length}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Icons synced"
|
||||
value={verification.iconsSynced.length}
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Folders chowned"
|
||||
value={verification.ownershipFixed.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
{done ? (
|
||||
<Button size="sm" variant="secondary" onClick={onDismiss}>
|
||||
<Check size={14} />
|
||||
Dismiss
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={cancelling}
|
||||
>
|
||||
{cancelling ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Ban size={14} />
|
||||
)}
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function CatalogRail({
|
||||
onSelect(selectedId === node.id ? null : node.id);
|
||||
}}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition-colors",
|
||||
"group flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
|
||||
selectedId === node.id
|
||||
? "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]"
|
||||
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]",
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
CircleAlert,
|
||||
CloudDownload,
|
||||
Download,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Loader2,
|
||||
Package,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
@@ -21,6 +27,7 @@ import { deleteImportedFurni } from "@/actions/import-furni";
|
||||
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -57,6 +64,7 @@ import type {
|
||||
FurniItem,
|
||||
FurniStats,
|
||||
Meta,
|
||||
VerificationSummary,
|
||||
} from "./studio-types";
|
||||
|
||||
export function StudioClient({
|
||||
@@ -69,6 +77,7 @@ export function StudioClient({
|
||||
// Catalog tree
|
||||
const [tree] = useState<TreeNode[]>(initialTree);
|
||||
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
||||
const [railOpen, setRailOpen] = useState(true);
|
||||
|
||||
// Search & list
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -87,7 +96,12 @@ export function StudioClient({
|
||||
|
||||
// Filters
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("all");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [sortBy, setSortBy] = useState<string>("default");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Stats
|
||||
const [stats, setStats] = useState<FurniStats | null>(null);
|
||||
@@ -105,6 +119,10 @@ export function StudioClient({
|
||||
const [batchDone, setBatchDone] = useState(false);
|
||||
const [batchSucceeded, setBatchSucceeded] = useState(0);
|
||||
const [batchFailed, setBatchFailed] = useState(0);
|
||||
const [batchCancelling, setBatchCancelling] = useState(false);
|
||||
const [verification, setVerification] = useState<VerificationSummary | null>(
|
||||
null,
|
||||
);
|
||||
const batchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Nitro editor
|
||||
@@ -114,11 +132,24 @@ export function StudioClient({
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Nitro regeneration (missing .nitro)
|
||||
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set(items.map((i) => i.category).filter(Boolean));
|
||||
return ["all", ...Array.from(cats).sort()];
|
||||
}, [items]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
let result = items;
|
||||
if (typeFilter !== "all") {
|
||||
result = result.filter((i) => i.type === typeFilter);
|
||||
}
|
||||
if (categoryFilter !== "all") {
|
||||
result = result.filter((i) => i.category === categoryFilter);
|
||||
}
|
||||
if (statusFilter !== "all") {
|
||||
if (statusFilter === "new")
|
||||
result = result.filter((i) => !i.alreadyImported);
|
||||
@@ -129,8 +160,22 @@ export function StudioClient({
|
||||
else if (statusFilter === "missingNitro")
|
||||
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
|
||||
}
|
||||
return result;
|
||||
}, [items, typeFilter, statusFilter]);
|
||||
const sorted = [...result];
|
||||
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||
else if (sortBy === "classname")
|
||||
sorted.sort((a, b) => a.classname.localeCompare(b.classname));
|
||||
else if (sortBy === "category")
|
||||
sorted.sort((a, b) => (a.category || "").localeCompare(b.category || ""));
|
||||
else if (sortBy === "imported-first")
|
||||
sorted.sort(
|
||||
(a, b) => (b.alreadyImported ? 1 : 0) - (a.alreadyImported ? 1 : 0),
|
||||
);
|
||||
else if (sortBy === "not-imported-first")
|
||||
sorted.sort(
|
||||
(a, b) => (a.alreadyImported ? 1 : 0) - (b.alreadyImported ? 1 : 0),
|
||||
);
|
||||
return sorted;
|
||||
}, [items, typeFilter, categoryFilter, statusFilter, sortBy]);
|
||||
|
||||
const allSelected = useMemo(
|
||||
() =>
|
||||
@@ -152,13 +197,13 @@ export function StudioClient({
|
||||
}, []);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async (search = activeSearch, page = 1) => {
|
||||
async (search: string, page: number, source?: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const qs = new URLSearchParams({ page: String(page) });
|
||||
if (search) qs.set("search", search);
|
||||
if (activeSource) qs.set("source", activeSource);
|
||||
if (source) qs.set("source", source);
|
||||
const res = await adminFetch(
|
||||
`/api/admin/import/furni?${qs.toString()}`,
|
||||
);
|
||||
@@ -180,7 +225,7 @@ export function StudioClient({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[activeSearch, activeSource],
|
||||
[],
|
||||
);
|
||||
|
||||
const changeSource = (sourceId: string) => {
|
||||
@@ -192,7 +237,6 @@ export function StudioClient({
|
||||
setItems([]);
|
||||
setActiveSearch("");
|
||||
setSearchTerm("");
|
||||
fetchItems("", 1);
|
||||
};
|
||||
|
||||
const fetchSources = useCallback(async () => {
|
||||
@@ -208,10 +252,24 @@ export function StudioClient({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
fetchItems("", 1, activeSource);
|
||||
fetchStats();
|
||||
fetchSources();
|
||||
}, [fetchItems, fetchStats, fetchSources]);
|
||||
}, [fetchItems, fetchStats, fetchSources, activeSource]);
|
||||
|
||||
// Debounced auto-search (also triggered by the "/" shortcut focus).
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
if (searchTerm !== activeSearch) {
|
||||
setActiveSearch(searchTerm);
|
||||
fetchItems(searchTerm, 1, activeSource);
|
||||
}
|
||||
}, 400);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [searchTerm, fetchItems, activeSearch, activeSource]);
|
||||
|
||||
// Keep detail in sync with the refreshed list.
|
||||
useEffect(() => {
|
||||
@@ -220,9 +278,15 @@ export function StudioClient({
|
||||
if (updated) setDetail(updated);
|
||||
}, [items, detail]);
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchTerm("");
|
||||
setActiveSearch("");
|
||||
fetchItems("", 1, activeSource);
|
||||
};
|
||||
|
||||
const submitSearch = () => {
|
||||
setActiveSearch(searchTerm);
|
||||
fetchItems(searchTerm, 1);
|
||||
fetchItems(searchTerm, 1, activeSource);
|
||||
};
|
||||
|
||||
function toggleSelect(classname: string) {
|
||||
@@ -234,7 +298,7 @@ export function StudioClient({
|
||||
});
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
const toggleAll = useCallback(() => {
|
||||
if (allSelected) {
|
||||
const toRemove = new Set(filteredItems.map((i) => i.classname));
|
||||
setSelected((prev) => {
|
||||
@@ -249,7 +313,29 @@ export function StudioClient({
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [allSelected, filteredItems]);
|
||||
|
||||
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
||||
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) {
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
}
|
||||
if (e.key === "a" && (e.metaKey || e.ctrlKey) && !isInput) {
|
||||
e.preventDefault();
|
||||
toggleAll();
|
||||
}
|
||||
if (e.key === "Escape" && !isInput) {
|
||||
setSelected(new Set());
|
||||
searchRef.current?.blur();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [toggleAll]);
|
||||
|
||||
async function importSingle(item: FurniItem) {
|
||||
setImportingId(item.classname);
|
||||
@@ -281,7 +367,16 @@ export function StudioClient({
|
||||
setDetail({ ...item, alreadyImported: true, nitroExists: true });
|
||||
}
|
||||
fetchStats();
|
||||
toast.success(`${item.classname} imported into the catalog`);
|
||||
const parts = [`${item.classname} imported into the catalog`];
|
||||
if (typeof data.furniDataFixedIds === "number") {
|
||||
parts.push(
|
||||
`verified: ${data.offerIdsFixed} offer_id, ${data.furniDataFixedIds} furnidata id`,
|
||||
);
|
||||
}
|
||||
if (data.furniDataMissing > 0) {
|
||||
parts.push(`${data.furniDataMissing} furnidata missing`);
|
||||
}
|
||||
toast.success(parts.join(" · "));
|
||||
} else {
|
||||
toast.error(data.error || `Import failed for ${item.classname}`);
|
||||
}
|
||||
@@ -305,6 +400,7 @@ export function StudioClient({
|
||||
setBatchDone(false);
|
||||
setBatchSucceeded(0);
|
||||
setBatchFailed(0);
|
||||
setBatchCancelling(false);
|
||||
|
||||
const initial = new Map<string, BatchItemStatus>();
|
||||
for (const i of toImport) {
|
||||
@@ -379,6 +475,15 @@ export function StudioClient({
|
||||
} else if (event.type === "batch_complete") {
|
||||
setBatchSucceeded(event.succeeded);
|
||||
setBatchFailed(event.failed);
|
||||
setVerification({
|
||||
offerIdsFixed: event.offerIdsFixed ?? 0,
|
||||
furniDataFixedIds: event.furniDataFixedIds ?? 0,
|
||||
furniDataMissing: event.furniDataMissing ?? 0,
|
||||
furniDataConflicts: event.furniDataConflicts ?? 0,
|
||||
nitrosSynced: event.nitrosSynced ?? [],
|
||||
iconsSynced: event.iconsSynced ?? [],
|
||||
ownershipFixed: event.ownershipFixed ?? [],
|
||||
});
|
||||
setBatchDone(true);
|
||||
}
|
||||
} catch {
|
||||
@@ -394,12 +499,20 @@ export function StudioClient({
|
||||
}
|
||||
} finally {
|
||||
batchAbortRef.current = null;
|
||||
setBatchCancelling(false);
|
||||
setSelected(new Set());
|
||||
fetchStats();
|
||||
if (!batchDone) setBatchProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
const cancelBatch = () => {
|
||||
if (batchAbortRef.current) {
|
||||
setBatchCancelling(true);
|
||||
batchAbortRef.current.abort();
|
||||
}
|
||||
};
|
||||
|
||||
async function deleteItem() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
@@ -426,6 +539,40 @@ export function StudioClient({
|
||||
}
|
||||
}
|
||||
|
||||
async function regenNitro(item: FurniItem) {
|
||||
setRegeneratingNitro((prev) => new Set(prev).add(item.classname));
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/import/furni", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
items: [{ classname: item.classname, revision: item.revision }],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.succeeded > 0) {
|
||||
setItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.classname === item.classname ? { ...i, nitroExists: true } : i,
|
||||
),
|
||||
);
|
||||
toast.success(`Nitro file regenerated for ${item.classname}`);
|
||||
} else {
|
||||
toast.error(
|
||||
data.results?.[0]?.warning || data.error || "Regeneration failed",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Network error");
|
||||
} finally {
|
||||
setRegeneratingNitro((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(item.classname);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const detailPreview = useMemo<AutoCatalogPreview | null>(() => {
|
||||
if (!detail) return null;
|
||||
return previewAutoCatalog(
|
||||
@@ -442,9 +589,15 @@ export function StudioClient({
|
||||
[filteredItems, selected],
|
||||
);
|
||||
|
||||
const missingNitroCount = useMemo(
|
||||
() =>
|
||||
filteredItems.filter((i) => i.alreadyImported && !i.nitroExists).length,
|
||||
[filteredItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[calc(100vh-6.5rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
|
||||
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
|
||||
style={{ backgroundColor: "var(--admin-canvas)" }}
|
||||
>
|
||||
{/* ── Header ─────────────────────────────────────────── */}
|
||||
@@ -470,16 +623,32 @@ export function StudioClient({
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)]"
|
||||
/>
|
||||
<Input
|
||||
ref={searchRef}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submitSearch()}
|
||||
placeholder="Search furni, classnames, keywords…"
|
||||
className="pl-8"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitSearch();
|
||||
if (e.key === "Escape") clearSearch();
|
||||
}}
|
||||
placeholder="Search furni, classnames, keywords… (/)"
|
||||
className="pl-8 pr-8"
|
||||
/>
|
||||
{searchTerm ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 grid h-5 w-5 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" onClick={submitSearch}>
|
||||
Search
|
||||
</Button>
|
||||
{meta && (
|
||||
<span className="shrink-0 text-xs font-semibold text-[var(--admin-text-muted)]">
|
||||
{meta.total.toLocaleString()} results
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
@@ -507,6 +676,26 @@ export function StudioClient({
|
||||
|
||||
{/* ── Filters + actions ─────────────────────────────── */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRailOpen((v) => !v)}
|
||||
aria-label={railOpen ? "Hide catalog tree" : "Show catalog tree"}
|
||||
title={railOpen ? "Hide catalog tree" : "Show catalog tree"}
|
||||
className={cn(
|
||||
"flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors",
|
||||
railOpen
|
||||
? "border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||
: "border-[var(--admin-accent)]/40 bg-[var(--admin-accent)]/10 text-[var(--admin-accent)]",
|
||||
)}
|
||||
>
|
||||
{railOpen ? (
|
||||
<PanelLeftClose size={13} />
|
||||
) : (
|
||||
<PanelLeftOpen size={13} />
|
||||
)}
|
||||
Catalog
|
||||
</button>
|
||||
|
||||
<Select value={activeSource} onValueChange={changeSource}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<SelectValue placeholder="Furni source" />
|
||||
@@ -532,6 +721,19 @@ export function StudioClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="h-8 w-40 text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat === "all" ? "All categories" : cat}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue placeholder="Status" />
|
||||
@@ -545,6 +747,68 @@ export function StudioClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="h-8 w-44 text-xs">
|
||||
<ArrowUpDown size={12} className="mr-1" />
|
||||
<SelectValue placeholder="Sort" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default order</SelectItem>
|
||||
<SelectItem value="name">Name A–Z</SelectItem>
|
||||
<SelectItem value="classname">Classname A–Z</SelectItem>
|
||||
<SelectItem value="category">Category</SelectItem>
|
||||
<SelectItem value="imported-first">Imported first</SelectItem>
|
||||
<SelectItem value="not-imported-first">
|
||||
Not imported first
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{missingNitroCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter("missingNitro")}
|
||||
className={cn(
|
||||
"flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors",
|
||||
statusFilter === "missingNitro"
|
||||
? "border-[var(--admin-warning)] bg-[var(--admin-warning)]/10 text-[var(--admin-warning)]"
|
||||
: "border-[var(--admin-border)] text-[var(--admin-warning)] hover:bg-[var(--admin-warning)]/10",
|
||||
)}
|
||||
>
|
||||
<CircleAlert size={12} />
|
||||
{missingNitroCount} no nitro
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex h-8 items-center overflow-hidden rounded-md border border-[var(--admin-border)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("grid")}
|
||||
aria-label="Grid view"
|
||||
className={cn(
|
||||
"grid h-8 w-8 place-items-center transition-colors",
|
||||
viewMode === "grid"
|
||||
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
|
||||
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
|
||||
)}
|
||||
>
|
||||
<LayoutGrid size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("list")}
|
||||
aria-label="List view"
|
||||
className={cn(
|
||||
"grid h-8 w-8 place-items-center transition-colors",
|
||||
viewMode === "list"
|
||||
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
|
||||
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
|
||||
)}
|
||||
>
|
||||
<List size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
@@ -554,6 +818,23 @@ export function StudioClient({
|
||||
Select all
|
||||
</button>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<span className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] bg-[var(--admin-accent)]/5 px-2.5 text-xs font-medium text-[var(--admin-text)]">
|
||||
{selected.size} selected
|
||||
</span>
|
||||
)}
|
||||
|
||||
{selected.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="flex h-8 items-center gap-1 rounded-md px-2 text-xs font-medium text-[var(--admin-text-muted)] transition-colors hover:text-[var(--admin-warning)]"
|
||||
>
|
||||
<X size={12} />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{selectedNonImported > 0 && (
|
||||
@@ -574,12 +855,14 @@ export function StudioClient({
|
||||
</div>
|
||||
|
||||
{/* ── Body: rail + grid + drawer ────────────────────── */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<CatalogRail
|
||||
tree={tree}
|
||||
selectedId={selectedPage}
|
||||
onSelect={setSelectedPage}
|
||||
/>
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
{railOpen ? (
|
||||
<CatalogRail
|
||||
tree={tree}
|
||||
selectedId={selectedPage}
|
||||
onSelect={setSelectedPage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
@@ -588,8 +871,14 @@ export function StudioClient({
|
||||
{Array.from({ length: 10 }, (_, i) => i).map((n) => (
|
||||
<div
|
||||
key={`skeleton-${n}`}
|
||||
className="h-40 animate-pulse rounded-lg bg-[var(--admin-accent)]/10"
|
||||
/>
|
||||
className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-surface)]"
|
||||
>
|
||||
<div className="h-24 animate-pulse bg-[var(--admin-accent)]/10" />
|
||||
<div className="space-y-2 border-t border-[var(--admin-border)] p-2.5">
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-[var(--admin-accent)]/10" />
|
||||
<div className="h-2.5 w-1/2 animate-pulse rounded bg-[var(--admin-accent)]/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
@@ -597,100 +886,291 @@ export function StudioClient({
|
||||
{error}
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<Search className="mx-auto mb-2 text-[var(--admin-text-muted)]" />
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
<div className="flex flex-col items-center py-16 text-center">
|
||||
<div className="mb-3 grid h-14 w-14 place-items-center rounded-2xl border border-[var(--admin-border)] bg-[var(--admin-surface)]">
|
||||
<Search
|
||||
size={22}
|
||||
className="text-[var(--admin-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-[var(--admin-text)]">
|
||||
{activeSearch
|
||||
? `No furni found for "${activeSearch}"`
|
||||
: "No furni to show yet"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||
{activeSearch
|
||||
? "Try a different search or clear your filters."
|
||||
: "Search a furni library to get started."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{filteredItems.map((item) => {
|
||||
const isSel = selected.has(item.classname);
|
||||
return (
|
||||
<button
|
||||
key={item.classname}
|
||||
type="button"
|
||||
onClick={() => setDetail(item)}
|
||||
onDoubleClick={() => toggleSelect(item.classname)}
|
||||
className={cn(
|
||||
"group relative flex flex-col overflow-hidden rounded-xl border text-left transition-all",
|
||||
isSel
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10"
|
||||
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
|
||||
)}
|
||||
>
|
||||
<div className="relative flex h-24 items-center justify-center overflow-hidden bg-gradient-to-br from-[var(--admin-surface)] to-[var(--admin-canvas)]">
|
||||
<img
|
||||
src={getFurniImageUrl(item)}
|
||||
alt={item.name}
|
||||
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
|
||||
loading="lazy"
|
||||
onError={(e) => handleImgError(e, item)}
|
||||
/>
|
||||
{viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{filteredItems.map((item) => {
|
||||
const isSel = selected.has(item.classname);
|
||||
return (
|
||||
<div key={item.classname} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetail(item)}
|
||||
onDoubleClick={() => toggleSelect(item.classname)}
|
||||
className={cn(
|
||||
"flex w-full flex-col overflow-hidden rounded-xl border text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
|
||||
isSel
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10"
|
||||
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:-translate-y-0.5 hover:border-[var(--admin-accent)]/60 hover:shadow-lg hover:shadow-[var(--admin-accent)]/10",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative flex h-24 items-center justify-center overflow-hidden"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
|
||||
backgroundSize: "14px 14px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getFurniImageUrl(item)}
|
||||
alt={item.name}
|
||||
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
loading="lazy"
|
||||
onError={(e) => handleImgError(e, item)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-1 border-t border-[var(--admin-border)] px-2 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold text-[var(--admin-text)]">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="truncate font-mono text-[0.6rem] text-[var(--admin-text-muted)]">
|
||||
{item.classname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 pb-1.5">
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
{TYPE_LABELS[item.type] ?? item.type}
|
||||
</Badge>
|
||||
{item.alreadyImported ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-4",
|
||||
item.nitroExists
|
||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
||||
)}
|
||||
>
|
||||
{item.nitroExists ? "Imported" : "No nitro"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(item.classname);
|
||||
}}
|
||||
className="absolute right-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)]"
|
||||
aria-label="Select"
|
||||
className={cn(
|
||||
"absolute right-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-md border transition-colors",
|
||||
isSel
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
|
||||
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
|
||||
)}
|
||||
aria-label={isSel ? "Deselect" : "Select"}
|
||||
>
|
||||
{isSel ? (
|
||||
<Check
|
||||
size={12}
|
||||
className="text-[var(--admin-accent)]"
|
||||
className="text-[var(--admin-accent-foreground)]"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-1 border-t border-[var(--admin-border)] px-2 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-semibold text-[var(--admin-text)]">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="truncate font-mono text-[0.6rem] text-[var(--admin-text-muted)]">
|
||||
{item.classname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 pb-1.5">
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
{TYPE_LABELS[item.type] ?? item.type}
|
||||
</Badge>
|
||||
{item.alreadyImported ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-4",
|
||||
item.nitroExists
|
||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--admin-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--admin-accent)]/8">
|
||||
<tr>
|
||||
<th className="w-9 px-2 py-2 text-left">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</th>
|
||||
<th className="w-12 px-1 py-2" />
|
||||
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
|
||||
Classname
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
|
||||
Category
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
|
||||
Status
|
||||
</th>
|
||||
<th className="w-28 px-2 py-2 text-right font-semibold text-[var(--admin-text-muted)]">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item) => {
|
||||
const isSel = selected.has(item.classname);
|
||||
return (
|
||||
<tr
|
||||
key={item.classname}
|
||||
className="border-t border-[var(--admin-border)] transition-colors hover:bg-[var(--admin-accent)]/5"
|
||||
>
|
||||
{item.nitroExists ? "Imported" : "No nitro"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<td className="px-2 py-1.5">
|
||||
<Checkbox
|
||||
checked={isSel}
|
||||
onCheckedChange={() =>
|
||||
toggleSelect(item.classname)
|
||||
}
|
||||
aria-label={`Select ${item.classname}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetail(item)}
|
||||
className="rounded outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
|
||||
aria-label={`View ${item.name}`}
|
||||
>
|
||||
<img
|
||||
src={getFurniImageUrl(item)}
|
||||
alt=""
|
||||
className="h-8 w-8 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
loading="lazy"
|
||||
onError={(e) => handleImgError(e, item)}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td className="max-w-[220px] truncate px-2 py-1.5 font-medium text-[var(--admin-text)]">
|
||||
{item.name}
|
||||
</td>
|
||||
<td className="max-w-[180px] truncate px-2 py-1.5 font-mono text-[0.7rem] text-[var(--admin-text-muted)]">
|
||||
{item.classname}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--admin-text-muted)]">
|
||||
{TYPE_LABELS[item.type] ?? item.type}
|
||||
</td>
|
||||
<td className="max-w-[140px] truncate px-2 py-1.5 text-[var(--admin-text-muted)]">
|
||||
{item.category || "—"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{item.alreadyImported ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-4",
|
||||
item.nitroExists
|
||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
||||
)}
|
||||
>
|
||||
{item.nitroExists ? "Imported" : "No nitro"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{!item.alreadyImported && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => importSingle(item)}
|
||||
disabled={importingId !== null}
|
||||
aria-label={`Import ${item.classname}`}
|
||||
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)] disabled:opacity-50"
|
||||
>
|
||||
{importingId === item.classname ? (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<CloudDownload size={12} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{item.alreadyImported && item.nitroExists && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setEditingClassname(item.classname)
|
||||
}
|
||||
aria-label={`Edit nitro ${item.classname}`}
|
||||
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
{item.alreadyImported &&
|
||||
!item.nitroExists && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => regenNitro(item)}
|
||||
disabled={regeneratingNitro.has(
|
||||
item.classname,
|
||||
)}
|
||||
aria-label={`Regenerate nitro ${item.classname}`}
|
||||
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-warning)] transition-colors hover:bg-[var(--admin-warning)]/10 disabled:opacity-50"
|
||||
>
|
||||
{regeneratingNitro.has(
|
||||
item.classname,
|
||||
) ? (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<RefreshCw size={12} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta && meta.currentPage < meta.lastPage && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
fetchItems(activeSearch, meta.currentPage + 1)
|
||||
fetchItems(
|
||||
activeSearch,
|
||||
meta.currentPage + 1,
|
||||
activeSource,
|
||||
)
|
||||
}
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -714,13 +1194,21 @@ export function StudioClient({
|
||||
done={batchDone}
|
||||
succeeded={batchSucceeded}
|
||||
failed={batchFailed}
|
||||
cancelling={batchCancelling}
|
||||
verification={verification}
|
||||
onCancel={cancelBatch}
|
||||
onDismiss={() => {
|
||||
setBatchProgress(null);
|
||||
setBatchDone(false);
|
||||
setVerification(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
{/* ── Detail drawer ─────────────────────────────── */}
|
||||
{detail ? (
|
||||
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)]">
|
||||
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-80 shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
||||
Furni detail
|
||||
@@ -735,11 +1223,19 @@ export function StudioClient({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="mb-4 grid h-40 place-items-center rounded-xl border border-[var(--admin-border)] bg-gradient-to-br from-[var(--admin-surface)] to-[var(--admin-canvas)]">
|
||||
<div
|
||||
className="mb-4 grid h-40 place-items-center overflow-hidden rounded-xl border border-[var(--admin-border)]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
|
||||
backgroundSize: "14px 14px",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getFurniImageUrl(detail)}
|
||||
alt={detail.name}
|
||||
className="h-28 w-28 object-contain"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
onError={(e) => handleImgError(e, detail)}
|
||||
/>
|
||||
</div>
|
||||
@@ -806,7 +1302,9 @@ export function StudioClient({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
|
||||
<div className="space-y-2">
|
||||
{!detail.alreadyImported ? (
|
||||
<Button
|
||||
@@ -824,9 +1322,25 @@ export function StudioClient({
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{!detail.nitroExists && (
|
||||
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
|
||||
This furni is in the DB but the .nitro file is missing.
|
||||
</p>
|
||||
<>
|
||||
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
|
||||
This furni is in the DB but the .nitro file is
|
||||
missing.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[var(--admin-warning)]"
|
||||
disabled={regeneratingNitro.has(detail.classname)}
|
||||
onClick={() => regenNitro(detail)}
|
||||
>
|
||||
{regeneratingNitro.has(detail.classname) ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Regenerate .nitro
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -40,3 +40,14 @@ export interface BatchItemStatus {
|
||||
warnings?: string[];
|
||||
itemId?: number;
|
||||
}
|
||||
|
||||
/** Post-import verification results reported by the import API. */
|
||||
export interface VerificationSummary {
|
||||
offerIdsFixed: number;
|
||||
furniDataFixedIds: number;
|
||||
furniDataMissing: number;
|
||||
furniDataConflicts: number;
|
||||
nitrosSynced: string[];
|
||||
iconsSynced: string[];
|
||||
ownershipFixed: string[];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { db, ItemsBase } from "@/lib/db";
|
||||
import {
|
||||
getGamedataRoot,
|
||||
getNitroFilesRoot,
|
||||
@@ -35,7 +36,7 @@ export async function getFurnitureDataPath(): Promise<string> {
|
||||
return path.join(/*turbopackIgnore: true*/ process.cwd(), "public", relative);
|
||||
}
|
||||
|
||||
async function getFurnitureDataWritePaths(): Promise<string[]> {
|
||||
export async function getFurnitureDataWritePaths(): Promise<string[]> {
|
||||
const primary = await getFurnitureDataPath();
|
||||
const configuredMirror =
|
||||
(await siteSettings.get("furni_data_mirror_path", "")) ?? "";
|
||||
@@ -442,3 +443,86 @@ export async function appendFurniEntriesBatch(
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
});
|
||||
}
|
||||
|
||||
export interface FurniDataReconcileResult {
|
||||
/** Entries whose stale spriteId was corrected to the DB id. */
|
||||
fixedIds: number;
|
||||
/** items_base rows with no matching furnitype entry (need re-import). */
|
||||
missing: number;
|
||||
/** spriteIds used by a different classname in the file; left untouched. */
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-check every items_base row against the local FurnitureData.json and
|
||||
* repair drift:
|
||||
* - an entry matching the classname but carrying a stale spriteId is updated
|
||||
* to the DB id (items_base is authoritative);
|
||||
* - rows missing entirely from the file are counted (they need a re-import
|
||||
* or batch-regen, since building an entry requires the SWF/nitro metadata);
|
||||
* - genuine spriteId conflicts (id used by a different classname) are counted
|
||||
* but never auto-fixed.
|
||||
*/
|
||||
export async function reconcileFurniDataWithItemsBase(): Promise<FurniDataReconcileResult> {
|
||||
return withFurniDataLock(async () => {
|
||||
const rows = await db
|
||||
.select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
|
||||
.from(ItemsBase);
|
||||
|
||||
const furniData = (await readFurniData()) as Record<
|
||||
string,
|
||||
{ furnitype: Array<Record<string, unknown>> }
|
||||
>;
|
||||
|
||||
const byId = new Map<
|
||||
number,
|
||||
{ section: string; index: number; entry: Record<string, unknown> }
|
||||
>();
|
||||
const byClass = new Map<
|
||||
string,
|
||||
{ section: string; index: number; entry: Record<string, unknown> }
|
||||
>();
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
const arr = furniData[section]?.furnitype ?? [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const e = arr[i];
|
||||
const id = Number(e?.id);
|
||||
if (Number.isFinite(id) && id > 0 && !byId.has(id)) {
|
||||
byId.set(id, { section, index: i, entry: e });
|
||||
}
|
||||
const cn = typeof e?.classname === "string" ? e.classname : "";
|
||||
if (cn && !byClass.has(cn)) {
|
||||
byClass.set(cn, { section, index: i, entry: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fixedIds = 0;
|
||||
let missing = 0;
|
||||
let conflicts = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const spriteId = row.id;
|
||||
const existing = byId.get(spriteId);
|
||||
if (existing) {
|
||||
if (existing.entry.classname !== row.itemName) {
|
||||
conflicts++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const byName = byClass.get(row.itemName);
|
||||
if (byName) {
|
||||
byName.entry.id = spriteId;
|
||||
byId.set(spriteId, byName);
|
||||
fixedIds++;
|
||||
} else {
|
||||
missing++;
|
||||
}
|
||||
}
|
||||
|
||||
if (fixedIds > 0) {
|
||||
await writeFurniData(furniData as Record<string, unknown>);
|
||||
}
|
||||
return { fixedIds, missing, conflicts };
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { and, eq, type SQL, sql } from "drizzle-orm";
|
||||
import { CatalogPages, db, ItemsBase, LogsFurniImports } from "@/lib/db";
|
||||
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
||||
@@ -10,11 +12,13 @@ import {
|
||||
DEFAULT_FURNI_SWF_DIR,
|
||||
getFurniAssetDirs,
|
||||
getFurniAssetWriteTargets,
|
||||
getGamedataRoot,
|
||||
} from "@/lib/services/furni-asset-dirs";
|
||||
import {
|
||||
appendFurniEntry,
|
||||
buildFurniEntry,
|
||||
findFurniDataIdConflict,
|
||||
getFurnitureDataWritePaths,
|
||||
removeFurniEntry,
|
||||
} from "@/lib/services/furni-data";
|
||||
import {
|
||||
@@ -126,6 +130,29 @@ export function buildItemsBaseEnrichmentUpdate(params: {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Keep the lowest-id row for a catalog page and delete any duplicate created
|
||||
* concurrently by a racing import. Returns the surviving page id. */
|
||||
async function dedupeCatalogPage(
|
||||
captionSave: string,
|
||||
parentId: number,
|
||||
justCreatedId: number,
|
||||
): Promise<number> {
|
||||
const [rows] = (await db.execute(sql`
|
||||
SELECT MIN(id) AS id FROM catalog_pages
|
||||
WHERE caption_save = ${captionSave} AND parent_id = ${parentId}
|
||||
`)) as unknown as [Array<{ id: number }>, unknown];
|
||||
const winner = rows[0]?.id ?? justCreatedId;
|
||||
if (winner !== justCreatedId) {
|
||||
// Never delete a page that already received catalog items.
|
||||
await db.execute(sql`
|
||||
DELETE FROM catalog_pages
|
||||
WHERE id = ${justCreatedId}
|
||||
AND NOT EXISTS (SELECT 1 FROM catalog_items WHERE page_id = ${justCreatedId})
|
||||
`);
|
||||
}
|
||||
return winner;
|
||||
}
|
||||
|
||||
/** Get or create the root "Imported Furniture" parent page (no items directly on this page).
|
||||
* Caches the page ID in-memory for the process lifetime to avoid redundant DB reads
|
||||
* during batch imports */
|
||||
@@ -158,8 +185,12 @@ export async function getOrCreateImportedParentPage(): Promise<number> {
|
||||
'Failed to create "Imported Furniture" parent catalog page',
|
||||
);
|
||||
}
|
||||
parentIdCache = created.id;
|
||||
return created.id;
|
||||
parentIdCache = await dedupeCatalogPage(
|
||||
IMPORTED_PAGE_CAPTION_SAVE,
|
||||
-1,
|
||||
created.id,
|
||||
);
|
||||
return parentIdCache;
|
||||
}
|
||||
|
||||
/** Determine the catalog category for a furniture item based on its classname and type. */
|
||||
@@ -246,8 +277,9 @@ export async function getOrCreateCategoryPage(
|
||||
);
|
||||
}
|
||||
if (!categoryPageCache) categoryPageCache = new Map();
|
||||
categoryPageCache.set(captionSave, created.id);
|
||||
return created.id;
|
||||
const winner = await dedupeCatalogPage(captionSave, parentId, created.id);
|
||||
categoryPageCache.set(captionSave, winner);
|
||||
return winner;
|
||||
}
|
||||
|
||||
export function resetCatalogPageCache(): void {
|
||||
@@ -256,6 +288,152 @@ export function resetCatalogPageCache(): void {
|
||||
categoryCacheTimestamp = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every catalog item under the "Imported Furniture" tree and make its
|
||||
* offer_id match the furnidata sprite id (item_ids). Fixes any stale values
|
||||
* (e.g. legacy -1 rows) after an import.
|
||||
*/
|
||||
export async function reconcileImportedOfferIds(): Promise<{
|
||||
fixed: number;
|
||||
}> {
|
||||
const parentId = await getOrCreateImportedParentPage();
|
||||
const [result] = (await db.execute(sql`
|
||||
UPDATE catalog_items ci
|
||||
JOIN catalog_pages cp ON cp.id = ci.page_id
|
||||
SET ci.offer_id = CAST(ci.item_ids AS UNSIGNED)
|
||||
WHERE cp.parent_id = ${parentId}
|
||||
AND ci.offer_id <> CAST(ci.item_ids AS UNSIGNED)
|
||||
`)) as unknown as [Record<string, unknown>, unknown];
|
||||
return { fixed: Number(result.affectedRows ?? 0) };
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Ensure the furni asset directories (icons, nitros, swf) and the folders
|
||||
* holding FurnitureData.json are owned by www-data:www-data, so nginx and the
|
||||
* emulator can always read freshly imported files. Best-effort: if chown is
|
||||
* unavailable or fails (e.g. running unprivileged), a warning is logged and
|
||||
* import continues.
|
||||
*/
|
||||
export async function ensureFurniOwnership(): Promise<{ fixed: string[] }> {
|
||||
if (process.platform === "win32") return { fixed: [] };
|
||||
|
||||
const { swfDir, iconDir, nitroDir, mirrorDirs } =
|
||||
await getFurniAssetWriteTargets();
|
||||
const furniDataPaths = await getFurnitureDataWritePaths();
|
||||
|
||||
const dirs = [
|
||||
swfDir,
|
||||
iconDir,
|
||||
nitroDir,
|
||||
...mirrorDirs.flatMap((m) => [m.swfDir, m.iconDir, m.nitroDir]),
|
||||
...furniDataPaths.map((p) => path.dirname(p)),
|
||||
]
|
||||
.map((d) => path.normalize(d))
|
||||
.filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
|
||||
|
||||
if (dirs.length === 0) return { fixed: [] };
|
||||
|
||||
try {
|
||||
await execFileAsync("chown", ["-R", "www-data:www-data", ...dirs]);
|
||||
return { fixed: dirs };
|
||||
} catch (err) {
|
||||
logger.warn("[import-furni] chown to www-data failed", {
|
||||
dirs,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
return { fixed: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy a single missing file from source dir into target dir; logs on failure. */
|
||||
async function copyIfMissing(
|
||||
sourceDir: string,
|
||||
targetDir: string,
|
||||
entry: string,
|
||||
copied: string[],
|
||||
): Promise<void> {
|
||||
const dest = path.join(targetDir, entry);
|
||||
if (existsSync(dest)) return;
|
||||
try {
|
||||
await fs.copyFile(path.join(sourceDir, entry), dest);
|
||||
copied.push(entry);
|
||||
} catch (err) {
|
||||
logger.warn("[import-furni] gamedata bundle sync copy failed", {
|
||||
entry,
|
||||
error: (err as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Scan a source dir and copy every file that is missing from the target dir. */
|
||||
async function syncDirMissingFiles(
|
||||
sourceDir: string,
|
||||
targetDir: string,
|
||||
predicate: (entry: string) => boolean,
|
||||
): Promise<string[]> {
|
||||
if (!existsSync(sourceDir)) return [];
|
||||
try {
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const copied: string[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(sourceDir);
|
||||
for (const entry of entries) {
|
||||
if (!predicate(entry)) continue;
|
||||
await copyIfMissing(sourceDir, targetDir, entry, copied);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[import-furni] gamedata bundle sync scan failed", {
|
||||
error: (err as Error).message,
|
||||
});
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill any nitro and icon files missing from the emulator's Gamedata
|
||||
* bundle (/var/www/Gamedata/bundled/furniture and /var/www/Gamedata/icons)
|
||||
* so all imported furniture stays renderable in-game even when a mirror
|
||||
* write was skipped.
|
||||
*/
|
||||
export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
copiedNitros: string[];
|
||||
copiedIcons: string[];
|
||||
}> {
|
||||
if (process.platform === "win32")
|
||||
return { copiedNitros: [], copiedIcons: [] };
|
||||
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (!gamedataRoot) return { copiedNitros: [], copiedIcons: [] };
|
||||
|
||||
const { nitroDir, iconDir } = await getFurniAssetDirs();
|
||||
|
||||
const copiedNitros: string[] = [];
|
||||
const copiedIcons: string[] = [];
|
||||
|
||||
const nitroTarget = path.join(gamedataRoot, "bundled/furniture");
|
||||
if (nitroDir !== nitroTarget) {
|
||||
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
|
||||
entry.endsWith(".nitro"),
|
||||
);
|
||||
copiedNitros.push(...copied);
|
||||
}
|
||||
|
||||
const iconTarget = path.join(gamedataRoot, "icons");
|
||||
if (iconDir !== iconTarget) {
|
||||
const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) =>
|
||||
entry.endsWith("_icon.png"),
|
||||
);
|
||||
copiedIcons.push(...copied);
|
||||
}
|
||||
|
||||
return { copiedNitros, copiedIcons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate the next `catalog_items.id` and run the INSERT through a
|
||||
* process-level async mutex so concurrent imports in the same Node process
|
||||
@@ -755,7 +933,7 @@ export async function importSingleFurni(params: {
|
||||
const price = autoPriceFurni(classname);
|
||||
await db.execute(sql`
|
||||
UPDATE catalog_items
|
||||
SET page_id = ${pageIdStr}, cost_credits = ${price.credits}, cost_points = ${price.points}, points_type = ${price.pointsType}
|
||||
SET page_id = ${pageIdStr}, cost_credits = ${price.credits}, cost_points = ${price.points}, points_type = ${price.pointsType}, offer_id = ${spriteId}
|
||||
WHERE id = ${catalogItemId}
|
||||
`);
|
||||
catalogUpdated = true;
|
||||
@@ -775,7 +953,7 @@ export async function importSingleFurni(params: {
|
||||
catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => {
|
||||
await db.execute(sql`
|
||||
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
|
||||
VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')
|
||||
VALUES (${nextCatalogId}, ${pageIdStr}, ${itemIdStr}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${spriteId}, '')
|
||||
`);
|
||||
return nextCatalogId;
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user