feat: comprehensive CMS improvements
- Fix DOMPurify SSR crash (use isomorphic-dompurify) - Fix SanitizedHtml to sanitize by default - Add auth guards to studio/catalog maintenance pages - Add update/edit to vouchers CRUD - Add update/edit to rare-values CRUD - Add approve workflow to applications page - Add edit form to guilds detail page - Add SEO metadata to all public pages (21 pages) - Fix mobile nav accessibility (focus trap, aria attributes) - Fix missing labels and table accessibility - Add dynamic imports for heavy client components (6 components) - Fix silent error swallowing (40+ locations) - Add content scheduling for articles (publishAt, status) - Wire up 12 missing webhook notification triggers - Add global search to admin panel - Add bulk actions to admin users table - Fix JSON formatting and a11y issues
This commit is contained in:
1 parent
b810b16672
commit
30c95b1a5c
78 files changed
+2252
-134
No files matched your search
@@ -33,3 +33,4 @@ public/tmp/
|
|||||||
|
|
||||||
# Test coverage reports
|
# Test coverage reports
|
||||||
coverage/
|
coverage/
|
||||||
|
.aider*
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"hash-wasm": "4.12.0",
|
"hash-wasm": "4.12.0",
|
||||||
"ioredis": "6.0.0",
|
"ioredis": "6.0.0",
|
||||||
|
"isomorphic-dompurify": "^4.1.0",
|
||||||
"jpeg-js": "0.4.4",
|
"jpeg-js": "0.4.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"jszip": "3.10.1",
|
"jszip": "3.10.1",
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tailwindcss/typography": "0.5.20",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
|
"@types/dompurify": "^3.2.0",
|
||||||
"@types/node": "26.4.0",
|
"@types/node": "26.4.0",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.2.5",
|
"@types/react-dom": "19.2.5",
|
||||||
|
|||||||
Generated
+355
-3
@@ -64,6 +64,9 @@ importers:
|
|||||||
ioredis:
|
ioredis:
|
||||||
specifier: 6.0.0
|
specifier: 6.0.0
|
||||||
version: 6.0.0([email protected])
|
version: 6.0.0([email protected])
|
||||||
|
isomorphic-dompurify:
|
||||||
|
specifier: ^4.1.0
|
||||||
|
version: 4.1.0(@noble/[email protected])
|
||||||
jpeg-js:
|
jpeg-js:
|
||||||
specifier: 0.4.4
|
specifier: 0.4.4
|
||||||
version: 0.4.4
|
version: 0.4.4
|
||||||
@@ -146,6 +149,9 @@ importers:
|
|||||||
'@tailwindcss/typography':
|
'@tailwindcss/typography':
|
||||||
specifier: 0.5.20
|
specifier: 0.5.20
|
||||||
version: 0.5.20([email protected])
|
version: 0.5.20([email protected])
|
||||||
|
'@types/dompurify':
|
||||||
|
specifier: ^3.2.0
|
||||||
|
version: 3.2.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 26.4.0
|
specifier: 26.4.0
|
||||||
version: 26.4.0
|
version: 26.4.0
|
||||||
@@ -187,7 +193,7 @@ importers:
|
|||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.11
|
specifier: 4.1.11
|
||||||
version: 4.1.11(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
version: 4.1.11(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -195,6 +201,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@asamuzakjp/[email protected]':
|
||||||
|
resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
|
||||||
|
engines: {node: ^22.13.0 || >=24.0.0}
|
||||||
|
|
||||||
|
'@asamuzakjp/[email protected]':
|
||||||
|
resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
|
||||||
|
engines: {node: ^22.13.0 || >=24.0.0}
|
||||||
|
|
||||||
'@auth/[email protected]':
|
'@auth/[email protected]':
|
||||||
resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==}
|
resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -321,6 +335,46 @@ packages:
|
|||||||
'@borewit/[email protected]':
|
'@borewit/[email protected]':
|
||||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
||||||
|
|
||||||
|
'@bramus/[email protected]':
|
||||||
|
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@csstools/[email protected]':
|
||||||
|
resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
|
'@csstools/[email protected]':
|
||||||
|
resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@csstools/css-parser-algorithms': ^4.0.0
|
||||||
|
'@csstools/css-tokenizer': ^4.0.0
|
||||||
|
|
||||||
|
'@csstools/[email protected]':
|
||||||
|
resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@csstools/css-parser-algorithms': ^4.0.0
|
||||||
|
'@csstools/css-tokenizer': ^4.0.0
|
||||||
|
|
||||||
|
'@csstools/[email protected]':
|
||||||
|
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@csstools/css-tokenizer': ^4.0.0
|
||||||
|
|
||||||
|
'@csstools/[email protected]':
|
||||||
|
resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==}
|
||||||
|
peerDependencies:
|
||||||
|
css-tree: ^3.2.1
|
||||||
|
peerDependenciesMeta:
|
||||||
|
css-tree:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@csstools/[email protected]':
|
||||||
|
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
'@dnd-kit/[email protected]':
|
'@dnd-kit/[email protected]':
|
||||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -531,6 +585,15 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@exodus/[email protected]':
|
||||||
|
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
|
||||||
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
'@noble/hashes': ^1.8.0 || ^2.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@noble/hashes':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@floating-ui/[email protected]':
|
'@floating-ui/[email protected]':
|
||||||
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
||||||
|
|
||||||
@@ -1719,6 +1782,10 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
||||||
|
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
@@ -1918,6 +1985,9 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
@@ -1965,6 +2035,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
|
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
|
||||||
engines: {node: '>=18.0'}
|
engines: {node: '>=18.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||||
|
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -1973,6 +2047,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||||
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
|
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
|
||||||
|
|
||||||
@@ -1985,6 +2063,9 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
@@ -2102,6 +2183,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||||
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
|
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
|
||||||
|
|
||||||
@@ -2186,6 +2271,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||||
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||||
|
|
||||||
@@ -2225,12 +2314,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-vjQwQSxyEkJHO6g+KIDlWj8swAzUB01pJu9GU9cLGHjE10d59GFzJkgtB2Lj5WTOoq9JeHlYjR/FFuqeopPO0Q==}
|
||||||
|
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2260,6 +2356,15 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
|
||||||
|
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
canvas: ^3.2.3
|
||||||
|
peerDependenciesMeta:
|
||||||
|
canvas:
|
||||||
|
optional: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||||
|
|
||||||
@@ -2444,6 +2549,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
|
||||||
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||||
@@ -2467,6 +2576,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==}
|
resolution: {integrity: sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2600,6 +2712,9 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
@@ -2655,6 +2770,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||||
|
|
||||||
@@ -2717,6 +2836,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==}
|
resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==}
|
||||||
|
|
||||||
@@ -2747,6 +2870,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
|
engines: {node: '>=v12.22.7'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
@@ -2856,6 +2983,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
||||||
|
|
||||||
@@ -2885,10 +3015,25 @@ packages:
|
|||||||
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
|
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -2913,6 +3058,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==}
|
||||||
|
engines: {node: '>=22.19.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3030,10 +3179,30 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
|
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||||
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
|
||||||
|
engines: {node: ^22.14.0 || >=24.0.0}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3045,6 +3214,13 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
@@ -3057,6 +3233,21 @@ snapshots:
|
|||||||
|
|
||||||
'@alloc/[email protected]': {}
|
'@alloc/[email protected]': {}
|
||||||
|
|
||||||
|
'@asamuzakjp/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@csstools/css-calc': 3.3.0(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||||
|
'@csstools/css-color-parser': 4.2.2(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||||
|
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||||
|
'@csstools/css-tokenizer': 4.0.0
|
||||||
|
lru-cache: 11.5.2
|
||||||
|
|
||||||
|
'@asamuzakjp/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
bidi-js: 1.0.3
|
||||||
|
css-tree: 3.2.1
|
||||||
|
is-potential-custom-element-name: 1.0.1
|
||||||
|
lru-cache: 11.5.2
|
||||||
|
|
||||||
'@auth/[email protected]':
|
'@auth/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@panva/hkdf': 1.2.1
|
'@panva/hkdf': 1.2.1
|
||||||
@@ -3142,6 +3333,34 @@ snapshots:
|
|||||||
|
|
||||||
'@borewit/[email protected]': {}
|
'@borewit/[email protected]': {}
|
||||||
|
|
||||||
|
'@bramus/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
css-tree: 3.2.1
|
||||||
|
|
||||||
|
'@csstools/[email protected]': {}
|
||||||
|
|
||||||
|
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||||
|
'@csstools/css-tokenizer': 4.0.0
|
||||||
|
|
||||||
|
'@csstools/[email protected](@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@csstools/color-helpers': 6.1.1
|
||||||
|
'@csstools/css-calc': 3.3.0(@csstools/[email protected](@csstools/[email protected]))(@csstools/[email protected])
|
||||||
|
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected])
|
||||||
|
'@csstools/css-tokenizer': 4.0.0
|
||||||
|
|
||||||
|
'@csstools/[email protected](@csstools/[email protected])':
|
||||||
|
dependencies:
|
||||||
|
'@csstools/css-tokenizer': 4.0.0
|
||||||
|
|
||||||
|
'@csstools/[email protected]([email protected])':
|
||||||
|
optionalDependencies:
|
||||||
|
css-tree: 3.2.1
|
||||||
|
|
||||||
|
'@csstools/[email protected]': {}
|
||||||
|
|
||||||
'@dnd-kit/[email protected]([email protected])':
|
'@dnd-kit/[email protected]([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
@@ -3289,6 +3508,10 @@ snapshots:
|
|||||||
'@esbuild/[email protected]':
|
'@esbuild/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@exodus/[email protected](@noble/[email protected])':
|
||||||
|
optionalDependencies:
|
||||||
|
'@noble/hashes': 2.4.0
|
||||||
|
|
||||||
'@floating-ui/[email protected]':
|
'@floating-ui/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/utils': 0.2.12
|
'@floating-ui/utils': 0.2.12
|
||||||
@@ -4064,6 +4287,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
dompurify: 3.4.14
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
@@ -4153,7 +4380,7 @@ snapshots:
|
|||||||
obug: 2.1.4
|
obug: 2.1.4
|
||||||
std-env: 4.2.0
|
std-env: 4.2.0
|
||||||
tinyrainbow: 3.1.1
|
tinyrainbow: 3.1.1
|
||||||
vitest: 4.1.11(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
vitest: 4.1.11(@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
'@vitest/[email protected]':
|
'@vitest/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4214,6 +4441,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -4252,10 +4483,22 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
mdn-data: 2.27.1
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected](@noble/[email protected]):
|
||||||
|
dependencies:
|
||||||
|
whatwg-mimetype: 5.0.0
|
||||||
|
whatwg-url: 16.0.1(@noble/[email protected])
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@noble/hashes'
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
@@ -4264,6 +4507,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
supports-color: 7.2.0
|
supports-color: 7.2.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -4294,6 +4539,8 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -4389,6 +4636,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected](@noble/[email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@noble/hashes'
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -4429,10 +4682,20 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-extglob: 2.1.1
|
is-extglob: 2.1.1
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected](@noble/[email protected]):
|
||||||
|
dependencies:
|
||||||
|
dompurify: 3.4.14
|
||||||
|
jsdom: 30.0.1(@noble/[email protected])
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@noble/hashes'
|
||||||
|
- canvas
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -4456,6 +4719,32 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected](@noble/[email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@asamuzakjp/css-color': 6.0.7
|
||||||
|
'@asamuzakjp/dom-selector': 8.3.2
|
||||||
|
'@bramus/specificity': 2.4.2
|
||||||
|
'@csstools/css-syntax-patches-for-csstree': 1.1.12([email protected])
|
||||||
|
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||||
|
css-tree: 3.2.1
|
||||||
|
data-urls: 7.0.0(@noble/[email protected])
|
||||||
|
decimal.js: 10.6.0
|
||||||
|
html-encoding-sniffer: 6.0.0(@noble/[email protected])
|
||||||
|
is-potential-custom-element-name: 1.0.1
|
||||||
|
lru-cache: 11.5.2
|
||||||
|
parse5: 8.0.1
|
||||||
|
saxes: 6.0.0
|
||||||
|
symbol-tree: 3.2.4
|
||||||
|
tough-cookie: 6.0.2
|
||||||
|
undici: 8.10.1
|
||||||
|
w3c-xmlserializer: 5.0.0
|
||||||
|
webidl-conversions: 8.0.1
|
||||||
|
whatwg-mimetype: 5.0.0
|
||||||
|
whatwg-url: 17.1.0(@noble/[email protected])
|
||||||
|
xml-name-validator: 5.0.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@noble/hashes'
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -4597,6 +4886,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
@@ -4619,6 +4910,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.8.5
|
semver: 7.8.5
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -4798,6 +5091,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
entities: 8.0.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -4870,6 +5167,8 @@ snapshots:
|
|||||||
end-of-stream: 1.4.5
|
end-of-stream: 1.4.5
|
||||||
once: 1.4.0
|
once: 1.4.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
@@ -4926,6 +5225,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -4962,6 +5263,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
xmlchars: 2.2.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -5065,6 +5370,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -5086,12 +5393,26 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
tldts-core: 7.4.11
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@borewit/text-codec': 0.2.2
|
'@borewit/text-codec': 0.2.2
|
||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
tldts: 7.4.11
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
punycode: 2.3.1
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -5129,6 +5450,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected](@types/[email protected])([email protected]):
|
[email protected](@types/[email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.8
|
react: 19.2.8
|
||||||
@@ -5173,7 +5496,7 @@ snapshots:
|
|||||||
tsx: 4.23.13
|
tsx: 4.23.13
|
||||||
yaml: 2.9.0
|
yaml: 2.9.0
|
||||||
|
|
||||||
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
|
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.11
|
'@vitest/expect': 4.1.11
|
||||||
'@vitest/mocker': 4.1.11([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
'@vitest/mocker': 4.1.11([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
@@ -5198,11 +5521,36 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 26.4.0
|
'@types/node': 26.4.0
|
||||||
'@vitest/coverage-v8': 4.1.11([email protected])
|
'@vitest/coverage-v8': 4.1.11([email protected])
|
||||||
|
jsdom: 30.0.1(@noble/[email protected])
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
xml-name-validator: 5.0.0
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected](@noble/[email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||||
|
tr46: 6.0.0
|
||||||
|
webidl-conversions: 8.0.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@noble/hashes'
|
||||||
|
|
||||||
|
[email protected](@noble/[email protected]):
|
||||||
|
dependencies:
|
||||||
|
'@exodus/bytes': 1.15.1(@noble/[email protected])
|
||||||
|
tr46: 6.0.0
|
||||||
|
webidl-conversions: 8.0.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@noble/hashes'
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
@@ -5212,6 +5560,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
+45
-2
@@ -1,8 +1,13 @@
|
|||||||
import "./load-env";
|
import "./load-env";
|
||||||
import { Cron } from "croner";
|
import { Cron } from "croner";
|
||||||
import { lt, sql } from "drizzle-orm";
|
import { and, lt, or, sql } from "drizzle-orm";
|
||||||
import { env } from "../src/env";
|
import { env } from "../src/env";
|
||||||
import { db, PasswordReset, WebsiteLoginLogs } from "../src/lib/db";
|
import {
|
||||||
|
db,
|
||||||
|
PasswordReset,
|
||||||
|
WebsiteArticles,
|
||||||
|
WebsiteLoginLogs,
|
||||||
|
} from "../src/lib/db";
|
||||||
import { logger } from "../src/lib/logger";
|
import { logger } from "../src/lib/logger";
|
||||||
import { redis } from "../src/lib/redis";
|
import { redis } from "../src/lib/redis";
|
||||||
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||||
@@ -224,6 +229,35 @@ async function cleanupOldSessions(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publishScheduledArticles(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const result = await db
|
||||||
|
.update(WebsiteArticles)
|
||||||
|
.set({
|
||||||
|
status: "published",
|
||||||
|
publishedAt: now,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
sql`${WebsiteArticles.status} = 'scheduled'`,
|
||||||
|
or(
|
||||||
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||||
|
sql`${WebsiteArticles.publishAt} <= ${now}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const info = result as unknown as { affectedRows?: number };
|
||||||
|
if (info.affectedRows && info.affectedRows > 0) {
|
||||||
|
logger.info(`Published ${info.affectedRows} scheduled article(s)`, {
|
||||||
|
module: "jobs",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
captureWorkerError(err, "Scheduled article publish failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
logger.info("Worker started", { module: "jobs" });
|
logger.info("Worker started", { module: "jobs" });
|
||||||
|
|
||||||
@@ -257,6 +291,15 @@ async function main() {
|
|||||||
});
|
});
|
||||||
logger.info("Scheduled: ops health probe (every 5 min)", { module: "jobs" });
|
logger.info("Scheduled: ops health probe (every 5 min)", { module: "jobs" });
|
||||||
|
|
||||||
|
new Cron("* * * * *", () => {
|
||||||
|
publishScheduledArticles().catch((e) =>
|
||||||
|
captureWorkerError(e, "ScheduledArticlePublish"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
logger.info("Scheduled: publish scheduled articles (every minute)", {
|
||||||
|
module: "jobs",
|
||||||
|
});
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
backupEmulatorJar(),
|
backupEmulatorJar(),
|
||||||
cleanupOldLogs(),
|
cleanupOldLogs(),
|
||||||
|
|||||||
@@ -22,3 +22,19 @@ export async function dismissApplication(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
revalidatePath("/admin/applications");
|
revalidatePath("/admin/applications");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function approveApplication(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.USERS_EDIT);
|
||||||
|
const id = formPositiveBigInt(formData, "id");
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.delete(WebsiteStaffApplications)
|
||||||
|
.where(eq(WebsiteStaffApplications.id, id));
|
||||||
|
} catch {
|
||||||
|
// already gone / no DB — nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/applications");
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { slugify } from "@/lib/format";
|
import { slugify } from "@/lib/format";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { notify } from "@/lib/services/webhook";
|
||||||
|
|
||||||
async function uniqueSlug(title: string): Promise<string> {
|
async function uniqueSlug(title: string): Promise<string> {
|
||||||
const base = slugify(title);
|
const base = slugify(title);
|
||||||
@@ -43,12 +44,16 @@ export async function createArticle(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.trim();
|
||||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||||
|
const status = String(formData.get("status") ?? "published");
|
||||||
|
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const publishAt = rawPublishAt ? new Date(rawPublishAt) : null;
|
||||||
|
const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title);
|
||||||
await db.insert(WebsiteArticles).values({
|
await db.insert(WebsiteArticles).values({
|
||||||
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
slug,
|
||||||
title: title.slice(0, 255),
|
title: title.slice(0, 255),
|
||||||
shortStory: shortStory.slice(0, 255),
|
shortStory: shortStory.slice(0, 255),
|
||||||
fullStory,
|
fullStory,
|
||||||
@@ -56,6 +61,16 @@ export async function createArticle(formData: FormData): Promise<void> {
|
|||||||
userId: staff.id,
|
userId: staff.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
status: status || "published",
|
||||||
|
publishAt,
|
||||||
|
publishedAt: status === "published" ? now : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
notify({
|
||||||
|
action: "news_publish",
|
||||||
|
actor: staff.username,
|
||||||
|
target: title,
|
||||||
|
details: slug,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Database error — re-render unchanged with error.
|
// Database error — re-render unchanged with error.
|
||||||
@@ -70,7 +85,10 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
await requirePermission(PERMS.NEWS_EDIT);
|
await requirePermission(PERMS.NEWS_EDIT);
|
||||||
const id = BigInt(String(formData.get("id")));
|
const id = BigInt(String(formData.get("id")));
|
||||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||||
|
const status = String(formData.get("status") ?? "published");
|
||||||
|
const rawPublishAt = String(formData.get("publishAt") ?? "").trim();
|
||||||
try {
|
try {
|
||||||
|
const publishAt = rawPublishAt ? new Date(rawPublishAt) : null;
|
||||||
await db
|
await db
|
||||||
.update(WebsiteArticles)
|
.update(WebsiteArticles)
|
||||||
.set({
|
.set({
|
||||||
@@ -90,6 +108,9 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim()
|
.trim()
|
||||||
.slice(0, 255),
|
.slice(0, 255),
|
||||||
|
status,
|
||||||
|
publishAt,
|
||||||
|
publishedAt: status === "published" ? new Date() : undefined,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(WebsiteArticles.id, id));
|
.where(eq(WebsiteArticles.id, id));
|
||||||
@@ -101,8 +122,13 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.NEWS_EDIT);
|
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||||
const id = BigInt(String(formData.get("id")));
|
const id = BigInt(String(formData.get("id")));
|
||||||
|
const [article] = await db
|
||||||
|
.select({ title: WebsiteArticles.title })
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.where(eq(WebsiteArticles.id, id))
|
||||||
|
.limit(1);
|
||||||
try {
|
try {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
@@ -113,6 +139,12 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
|||||||
.where(eq(WebsiteArticleComments.articleId, id));
|
.where(eq(WebsiteArticleComments.articleId, id));
|
||||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notify({
|
||||||
|
action: "news_delete",
|
||||||
|
actor: staff.username,
|
||||||
|
target: article?.title ?? String(id),
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
redirect("/admin/articles?error=Delete failed");
|
redirect("/admin/articles?error=Delete failed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requirePermissionRateLimited } from "@/lib/admin/guard";
|
import {
|
||||||
|
requirePermission,
|
||||||
|
requirePermissionRateLimited,
|
||||||
|
} from "@/lib/admin/guard";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
GuildForumViews,
|
GuildForumViews,
|
||||||
@@ -63,3 +66,47 @@ export async function disbandGuild(formData: FormData): Promise<void> {
|
|||||||
});
|
});
|
||||||
revalidatePath("/admin/guilds");
|
revalidatePath("/admin/guilds");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateGuild(formData: FormData): Promise<void> {
|
||||||
|
const staff = await requirePermission(PERMS.USERS_EDIT);
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
if (!(id > 0)) return;
|
||||||
|
|
||||||
|
const name = String(formData.get("name") ?? "")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 50);
|
||||||
|
const description = String(formData.get("description") ?? "")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 250);
|
||||||
|
const state = Number(formData.get("state"));
|
||||||
|
const forum = String(formData.get("forum") ?? "0");
|
||||||
|
const readForum = String(formData.get("readForum") ?? "EVERYONE");
|
||||||
|
const postMessages = String(formData.get("postMessages") ?? "EVERYONE");
|
||||||
|
const postThreads = String(formData.get("postThreads") ?? "EVERYONE");
|
||||||
|
const modForum = String(formData.get("modForum") ?? "ADMINS");
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(Guilds)
|
||||||
|
.set({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
state,
|
||||||
|
forum,
|
||||||
|
readForum,
|
||||||
|
postMessages,
|
||||||
|
postThreads,
|
||||||
|
modForum,
|
||||||
|
})
|
||||||
|
.where(eq(Guilds.id, id));
|
||||||
|
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "guild_update",
|
||||||
|
description: `Updated guild #${id}`,
|
||||||
|
targetType: "guild",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/guilds");
|
||||||
|
revalidatePath(`/admin/guilds/${id}`);
|
||||||
|
}
|
||||||
@@ -115,3 +115,88 @@ export async function deleteValue(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
revalidatePath("/admin/rare-values");
|
revalidatePath("/admin/rare-values");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateCategory(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
const id = formPositiveBigInt(formData, "id");
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const name = String(formData.get("name") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const badge = String(formData.get("badge") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const priorityRaw = Number(formData.get("priority"));
|
||||||
|
const priority =
|
||||||
|
Number.isFinite(priorityRaw) && priorityRaw > 0
|
||||||
|
? Math.floor(priorityRaw)
|
||||||
|
: 1;
|
||||||
|
if (!name || !badge) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(WebsiteRareValueCategories)
|
||||||
|
.set({ name, badge, priority })
|
||||||
|
.where(eq(WebsiteRareValueCategories.id, id));
|
||||||
|
} catch {
|
||||||
|
// Unique name collision or DB error — ignore.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/rare-values");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateValue(formData: FormData): Promise<void> {
|
||||||
|
await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
const id = formPositiveBigInt(formData, "id");
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const categoryId = formPositiveBigInt(formData, "categoryId");
|
||||||
|
|
||||||
|
const name = String(formData.get("name") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
if (!name || !furnitureIcon) return;
|
||||||
|
|
||||||
|
const itemIdRaw = Number(formData.get("itemId"));
|
||||||
|
const itemId =
|
||||||
|
Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
|
||||||
|
|
||||||
|
const creditValueRaw = String(formData.get("creditValue") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const currencyType =
|
||||||
|
String(formData.get("currencyType") ?? "diamonds")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255) || "diamonds";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sets: Record<string, unknown> = {
|
||||||
|
name,
|
||||||
|
itemId,
|
||||||
|
creditValue: creditValueRaw || null,
|
||||||
|
currencyValue: currencyValueRaw || null,
|
||||||
|
currencyType,
|
||||||
|
furnitureIcon,
|
||||||
|
};
|
||||||
|
if (categoryId) sets.categoryId = categoryId;
|
||||||
|
await db
|
||||||
|
.update(WebsiteRareValues)
|
||||||
|
.set(sets)
|
||||||
|
.where(eq(WebsiteRareValues.id, id));
|
||||||
|
} catch {
|
||||||
|
// DB error — ignore.
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/rare-values");
|
||||||
|
}
|
||||||
@@ -83,3 +83,58 @@ export async function deleteVoucher(input: {
|
|||||||
return actionError("Could not delete voucher");
|
return actionError("Could not delete voucher");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateVoucher(input: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
amount: number;
|
||||||
|
maxUses: number;
|
||||||
|
expiresAt?: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
await requirePermission(PERMS.SHOP_EDIT);
|
||||||
|
|
||||||
|
const id = positiveBigInt(String(input.id ?? "").trim());
|
||||||
|
if (!id) return actionError("Missing voucher id");
|
||||||
|
|
||||||
|
const code = String(input.code ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 255);
|
||||||
|
const amount = Number(input.amount);
|
||||||
|
const maxUsesRaw = Number(input.maxUses);
|
||||||
|
const maxUses =
|
||||||
|
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
|
||||||
|
|
||||||
|
if (!code || !(amount > 0)) {
|
||||||
|
return actionError("Code and a positive amount are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiresAt: Date | null = null;
|
||||||
|
const expiresRaw = String(input.expiresAt ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim();
|
||||||
|
if (expiresRaw) {
|
||||||
|
const parsed = new Date(expiresRaw);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(WebsiteShopVouchers)
|
||||||
|
.set({
|
||||||
|
code,
|
||||||
|
amount: Math.floor(amount),
|
||||||
|
maxUses,
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(WebsiteShopVouchers.id, id));
|
||||||
|
revalidatePath("/admin/vouchers");
|
||||||
|
return actionOk();
|
||||||
|
} catch (error) {
|
||||||
|
logServerError("admin.voucher_update_failed", error, {
|
||||||
|
voucherId: String(id),
|
||||||
|
});
|
||||||
|
return actionError("Could not update voucher (code may already exist)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db, User, UsersBadges, WebsiteDrawbadges } from "@/lib/db";
|
import { db, User, UsersBadges, WebsiteDrawbadges } from "@/lib/db";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -127,7 +128,12 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Grant the badge live so it appears immediately for online users.
|
// Grant the badge live so it appears immediately for online users.
|
||||||
await rcon.giveBadge(userId, code).catch(() => {});
|
await rcon.giveBadge(userId, code).catch((error) =>
|
||||||
|
logServerError("drawbadge.give_failed", error, {
|
||||||
|
userId,
|
||||||
|
code,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
outcome = "bought";
|
outcome = "bought";
|
||||||
boughtCode = code;
|
boughtCode = code;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { PERMS } from "@/lib/permissions";
|
|||||||
import { adminAction, authAction } from "@/lib/safe-action";
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
import { notify } from "@/lib/services/webhook";
|
||||||
import {
|
import {
|
||||||
createEventSchema,
|
createEventSchema,
|
||||||
eventPrizeSchema,
|
eventPrizeSchema,
|
||||||
@@ -120,6 +121,11 @@ export const createEvent = adminAction(
|
|||||||
targetId: eventId,
|
targetId: eventId,
|
||||||
after: { title: ctx.data.title },
|
after: { title: ctx.data.title },
|
||||||
});
|
});
|
||||||
|
notify({
|
||||||
|
action: "event_create",
|
||||||
|
actor: ctx.session.user.username,
|
||||||
|
target: ctx.data.title,
|
||||||
|
});
|
||||||
return actionOk({ id: eventId });
|
return actionOk({ id: eventId });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -155,6 +161,11 @@ export const updateEvent = adminAction(
|
|||||||
before: { title: existing.title, status: existing.status },
|
before: { title: existing.title, status: existing.status },
|
||||||
after: data,
|
after: data,
|
||||||
});
|
});
|
||||||
|
notify({
|
||||||
|
action: "event_update",
|
||||||
|
actor: ctx.session.user.username,
|
||||||
|
target: data.title ?? existing.title,
|
||||||
|
});
|
||||||
return actionOk({ id });
|
return actionOk({ id });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||||
|
import { notify } from "@/lib/services/webhook";
|
||||||
|
|
||||||
const ticketSchema = z.object({
|
const ticketSchema = z.object({
|
||||||
title: z.string().min(1, "Title is required").max(255),
|
title: z.string().min(1, "Title is required").max(255),
|
||||||
@@ -160,6 +161,15 @@ export async function createTicket(formData: FormData): Promise<void> {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
outcome = "created";
|
outcome = "created";
|
||||||
|
|
||||||
|
const sessionUser = session?.user;
|
||||||
|
if (sessionUser?.name) {
|
||||||
|
notify({
|
||||||
|
action: "ticket_create",
|
||||||
|
actor: sessionUser.name,
|
||||||
|
target: ticketTitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { env } from "@/env";
|
|||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
import { db, PasswordReset, User } from "@/lib/db";
|
import { db, PasswordReset, User } from "@/lib/db";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||||
import { sendMail } from "@/lib/services/email";
|
import { sendMail } from "@/lib/services/email";
|
||||||
|
|
||||||
@@ -123,7 +124,11 @@ export async function resetPassword(formData: FormData): Promise<void> {
|
|||||||
await db
|
await db
|
||||||
.delete(PasswordReset)
|
.delete(PasswordReset)
|
||||||
.where(eq(PasswordReset.email, email))
|
.where(eq(PasswordReset.email, email))
|
||||||
.catch(() => {});
|
.catch((error) =>
|
||||||
|
logServerError("password.reset_delete_tokens_failed", error, {
|
||||||
|
email,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { PERMS } from "@/lib/permissions";
|
|||||||
import { adminAction, authAction } from "@/lib/safe-action";
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
import { notify } from "@/lib/services/webhook";
|
||||||
import {
|
import {
|
||||||
createPollSchema,
|
createPollSchema,
|
||||||
pollQuestionSchema,
|
pollQuestionSchema,
|
||||||
@@ -38,6 +39,11 @@ export const createPoll = adminAction(
|
|||||||
targetId: pollId,
|
targetId: pollId,
|
||||||
after: { title: ctx.data.title },
|
after: { title: ctx.data.title },
|
||||||
});
|
});
|
||||||
|
notify({
|
||||||
|
action: "poll_create",
|
||||||
|
actor: ctx.session.user.username,
|
||||||
|
target: ctx.data.title,
|
||||||
|
});
|
||||||
return actionOk({ id: pollId });
|
return actionOk({ id: pollId });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+18
-1
@@ -7,6 +7,7 @@ import { db, Items, Rooms } from "@/lib/db";
|
|||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { notify } from "@/lib/services/webhook";
|
||||||
|
|
||||||
export async function updateRoomItem(payload: Record<string, unknown>) {
|
export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
@@ -75,11 +76,17 @@ export async function roomRconAction({
|
|||||||
roomId: number;
|
roomId: number;
|
||||||
action: string;
|
action: string;
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.ROOMS_EDIT);
|
const staff = await requirePermission(PERMS.ROOMS_EDIT);
|
||||||
if (action === "reload") {
|
if (action === "reload") {
|
||||||
await rcon.send("reloadroom", { room_id: roomId });
|
await rcon.send("reloadroom", { room_id: roomId });
|
||||||
} else if (action === "kick") {
|
} else if (action === "kick") {
|
||||||
await rcon.send("kickall", { room_id: roomId });
|
await rcon.send("kickall", { room_id: roomId });
|
||||||
|
notify({
|
||||||
|
action: "kick",
|
||||||
|
actor: staff.username,
|
||||||
|
target: String(roomId),
|
||||||
|
details: action,
|
||||||
|
});
|
||||||
} else if (action === "lock") {
|
} else if (action === "lock") {
|
||||||
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
|
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
|
||||||
} else if (action === "unlock") {
|
} else if (action === "unlock") {
|
||||||
@@ -89,6 +96,11 @@ export async function roomRconAction({
|
|||||||
|
|
||||||
export async function deleteRoom({ id }: { id: number }) {
|
export async function deleteRoom({ id }: { id: number }) {
|
||||||
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
const staff = await requirePermission(PERMS.ROOMS_DELETE);
|
||||||
|
const [room] = await db
|
||||||
|
.select({ name: Rooms.name })
|
||||||
|
.from(Rooms)
|
||||||
|
.where(eq(Rooms.id, id))
|
||||||
|
.limit(1);
|
||||||
await db.delete(Rooms).where(eq(Rooms.id, id));
|
await db.delete(Rooms).where(eq(Rooms.id, id));
|
||||||
await logStaffActivity({
|
await logStaffActivity({
|
||||||
staffId: staff.id,
|
staffId: staff.id,
|
||||||
@@ -97,6 +109,11 @@ export async function deleteRoom({ id }: { id: number }) {
|
|||||||
targetType: "room",
|
targetType: "room",
|
||||||
targetId: id,
|
targetId: id,
|
||||||
});
|
});
|
||||||
|
notify({
|
||||||
|
action: "room_delete",
|
||||||
|
actor: staff.username,
|
||||||
|
target: room?.name ?? `#${id}`,
|
||||||
|
});
|
||||||
revalidatePath("/admin/rooms");
|
revalidatePath("/admin/rooms");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
|
import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db";
|
||||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
import { creditsPerUnit } from "@/lib/services/paypal";
|
import { creditsPerUnit } from "@/lib/services/paypal";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
|
import { currencyDb, sendCurrency } from "@/lib/services/send-currency";
|
||||||
@@ -183,7 +184,12 @@ export async function buyShopArticle(formData: FormData): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const code of badgeCodes) {
|
for (const code of badgeCodes) {
|
||||||
await rcon.giveBadge(userId, code).catch(() => {});
|
await rcon.giveBadge(userId, code).catch((error) =>
|
||||||
|
logServerError("shop.give_badge_failed", error, {
|
||||||
|
userId,
|
||||||
|
code,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
outcome = "bought";
|
outcome = "bought";
|
||||||
|
|||||||
+16
-2
@@ -97,7 +97,7 @@ export const createUser = adminAction(
|
|||||||
});
|
});
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
action: "user_edit",
|
action: "user_create",
|
||||||
actor: ctx.session.user.username,
|
actor: ctx.session.user.username,
|
||||||
target: username,
|
target: username,
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
@@ -450,6 +450,13 @@ export const muteUser = adminAction(
|
|||||||
after: { duration: ctx.data.duration },
|
after: { duration: ctx.data.duration },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notify({
|
||||||
|
action: "hotel_alert",
|
||||||
|
actor: ctx.session.user.username,
|
||||||
|
target: _target.username,
|
||||||
|
details: "Muted",
|
||||||
|
});
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -463,7 +470,7 @@ const unmuteSchema = z.object({
|
|||||||
export const unmuteUser = adminAction(
|
export const unmuteUser = adminAction(
|
||||||
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
|
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||||
const success = await rcon.unmuteUser(ctx.data.userId);
|
const success = await rcon.unmuteUser(ctx.data.userId);
|
||||||
if (!success)
|
if (!success)
|
||||||
throw new ActionError("Failed to unmute. Is the emulator running?");
|
throw new ActionError("Failed to unmute. Is the emulator running?");
|
||||||
@@ -475,6 +482,13 @@ export const unmuteUser = adminAction(
|
|||||||
targetId: ctx.data.userId,
|
targetId: ctx.data.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notify({
|
||||||
|
action: "hotel_alert",
|
||||||
|
actor: ctx.session.user.username,
|
||||||
|
target: target.username,
|
||||||
|
details: "Unmuted",
|
||||||
|
});
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { desc, eq, inArray } from "drizzle-orm";
|
import { desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
@@ -14,6 +15,19 @@ import {
|
|||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.applyStaff");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
@@ -8,6 +9,19 @@ import { auth } from "@/lib/auth";
|
|||||||
import { db, WebsiteStaffApplications, WebsiteTeams } from "@/lib/db";
|
import { db, WebsiteStaffApplications, WebsiteTeams } from "@/lib/db";
|
||||||
import { resolveHotelName } from "@/lib/hotel-name";
|
import { resolveHotelName } from "@/lib/hotel-name";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.applyTeam");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Canonical Habbo badge image CDN (same base used by the profile page).
|
// Canonical Habbo badge image CDN (same base used by the profile page).
|
||||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { asc } from "drizzle-orm";
|
import { asc } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { db, WebsiteBadges } from "@/lib/db";
|
import { db, WebsiteBadges } from "@/lib/db";
|
||||||
|
|
||||||
export const metadata = { title: "Badges" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.badges");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here.
|
// Canonical Habbo badge image CDN. A badge_key (e.g. "ADM") maps to a .gif here.
|
||||||
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
|
|
||||||
export const metadata = { title: "Community" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.community");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const LINKS = [
|
const LINKS = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import { ContentCard } from "@/components/public/ui";
|
import { ContentCard } from "@/components/public/ui";
|
||||||
|
|
||||||
// Public API documentation. Static, hand-maintained from the routes that
|
// Public API documentation. Static, hand-maintained from the routes that
|
||||||
// actually exist under src/app/api — keep this in sync when endpoints change.
|
// actually exist under src/app/api — keep this in sync when endpoints change.
|
||||||
|
|
||||||
export const metadata = { title: "API" };
|
export const metadata: Metadata = {
|
||||||
|
title: "Developers",
|
||||||
|
description: "Public API documentation for the hotel.",
|
||||||
|
openGraph: {
|
||||||
|
title: "Developers",
|
||||||
|
description: "Public API documentation for the hotel.",
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
type Method = "GET" | "POST" | "DELETE";
|
type Method = "GET" | "POST" | "DELETE";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { buyBadge } from "@/actions/draw-badge";
|
import { buyBadge } from "@/actions/draw-badge";
|
||||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||||
@@ -10,7 +11,15 @@ import { siteSettings } from "@/lib/services/site-settings";
|
|||||||
// Reads the live users + website_drawbadges tables and writes credits/badges on
|
// Reads the live users + website_drawbadges tables and writes credits/badges on
|
||||||
// purchase — must never be statically rendered.
|
// purchase — must never be statically rendered.
|
||||||
|
|
||||||
export const metadata = { title: "Draw a Badge" };
|
export const metadata: Metadata = {
|
||||||
|
title: "Draw a Badge",
|
||||||
|
description: "Draw a random badge and add it to your collection.",
|
||||||
|
openGraph: {
|
||||||
|
title: "Draw a Badge",
|
||||||
|
description: "Draw a random badge and add it to your collection.",
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_PRICE = 50;
|
const DEFAULT_PRICE = 50;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { count, desc, eq, inArray } from "drizzle-orm";
|
import { count, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
@@ -12,7 +13,18 @@ import {
|
|||||||
import { excerpt, slugify } from "@/lib/format";
|
import { excerpt, slugify } from "@/lib/format";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
|
||||||
export const metadata = { title: "Events" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.events");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function EventsPage() {
|
export default async function EventsPage() {
|
||||||
const t = await getTranslations("pages.events");
|
const t = await getTranslations("pages.events");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { asc, eq, inArray, or } from "drizzle-orm";
|
import { asc, eq, inArray, or } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
@@ -9,7 +10,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { db, MessengerFriendships, User } from "@/lib/db";
|
import { db, MessengerFriendships, User } from "@/lib/db";
|
||||||
|
|
||||||
export const metadata = { title: "Friends" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.friends");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("emptySubtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("emptySubtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type SearchParams = Promise<{ removed?: string; error?: string }>;
|
type SearchParams = Promise<{ removed?: string; error?: string }>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { desc } from "drizzle-orm";
|
import { desc } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { db, Guilds } from "@/lib/db";
|
import { db, Guilds } from "@/lib/db";
|
||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
|
|
||||||
export const metadata = { title: "Guilds" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.guilds");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type GuildCard = {
|
type GuildCard = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { asc } from "drizzle-orm";
|
import { asc } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.help");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
|
import dynamic from "next/dynamic";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import MotionPageWrapper from "@/components/motion-page-wrapper";
|
import MotionPageWrapper from "@/components/motion-page-wrapper";
|
||||||
import { Navigation } from "@/components/navigation";
|
import { Navigation } from "@/components/navigation";
|
||||||
import RadioPlayerGate from "@/components/public/radio-player-gate";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import { SiteHeader } from "@/components/site-header";
|
import { SiteHeader } from "@/components/site-header";
|
||||||
import { TopHeader } from "@/components/top-header";
|
import { TopHeader } from "@/components/top-header";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
const RadioPlayerGate = dynamic(
|
||||||
|
() => import("@/components/public/radio-player-gate"),
|
||||||
|
{
|
||||||
|
loading: () => (
|
||||||
|
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-12" />
|
||||||
|
),
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
|
* Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`,
|
||||||
* so housekeeping is never constrained by the public max-w-7xl grid.
|
* so housekeeping is never constrained by the public max-w-7xl grid.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
||||||
@@ -10,7 +11,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
|
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
|
||||||
|
|
||||||
export const metadata = { title: "Leaderboard" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.leaderboard");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle", { currency: "credits" }),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle", { currency: "credits" }),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Currency type ids (see UsersCurrency in src/db/schema.ts):
|
// Currency type ids (see UsersCurrency in src/db/schema.ts):
|
||||||
// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5.
|
// Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5.
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { desc, eq, inArray } from "drizzle-orm";
|
import { desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui";
|
||||||
import { db, MarketplaceItems, User } from "@/lib/db";
|
import { db, MarketplaceItems, User } from "@/lib/db";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
|
||||||
export const metadata = { title: "Marketplace" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.marketplace");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// state == 1 → an active, unsold offer in the Arcturus marketplace.
|
// state == 1 → an active, unsold offer in the Arcturus marketplace.
|
||||||
const STATE_ACTIVE = 1;
|
const STATE_ACTIVE = 1;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
@@ -56,7 +56,16 @@ export async function generateMetadata({
|
|||||||
shortStory: WebsiteArticles.shortStory,
|
shortStory: WebsiteArticles.shortStory,
|
||||||
})
|
})
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
.where(eq(WebsiteArticles.slug, slug))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteArticles.slug, slug),
|
||||||
|
eq(WebsiteArticles.status, "published"),
|
||||||
|
or(
|
||||||
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||||
|
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
if (!article) return { title: "Article" };
|
if (!article) return { title: "Article" };
|
||||||
@@ -108,7 +117,16 @@ export default async function ArticlePage({
|
|||||||
updatedAt: WebsiteArticles.updatedAt,
|
updatedAt: WebsiteArticles.updatedAt,
|
||||||
})
|
})
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
.where(eq(WebsiteArticles.slug, slug))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteArticles.slug, slug),
|
||||||
|
eq(WebsiteArticles.status, "published"),
|
||||||
|
or(
|
||||||
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||||
|
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.catch(() => []);
|
.catch(() => []);
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { count, desc, eq } from "drizzle-orm";
|
import { count, desc, eq } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Home",
|
||||||
|
description:
|
||||||
|
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
||||||
|
openGraph: {
|
||||||
|
title: "Home",
|
||||||
|
description:
|
||||||
|
"An online virtual world where you can create your own avatar, make friends, chat, and build your own rooms.",
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
import { AmbientOrbs } from "@/components/ambient-orbs";
|
import { AmbientOrbs } from "@/components/ambient-orbs";
|
||||||
import { AnimatedCounter } from "@/components/animated-counter";
|
import { AnimatedCounter } from "@/components/animated-counter";
|
||||||
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
import { HomeLoginForm } from "@/components/auth/home-login-form";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { desc } from "drizzle-orm";
|
import { desc } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
import {
|
||||||
type LightboxPhoto,
|
type LightboxPhoto,
|
||||||
@@ -9,7 +10,18 @@ import { cached } from "@/lib/cache";
|
|||||||
import { CameraWeb, db } from "@/lib/db";
|
import { CameraWeb, db } from "@/lib/db";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
|
||||||
export const metadata = { title: "Photos" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.photos");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type Photo = {
|
type Photo = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { and, count, desc, inArray, isNull, lte, or } from "drizzle-orm";
|
import { and, count, desc, inArray, isNull, lte, or } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
@@ -6,7 +7,18 @@ import { db, WebsitePoll, WebsitePollQuestion } from "@/lib/db";
|
|||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
|
||||||
export const metadata = { title: "Polls" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.polls");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function PollsPage() {
|
export default async function PollsPage() {
|
||||||
const t = await getTranslations("pages.polls");
|
const t = await getTranslations("pages.polls");
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { asc, eq, inArray } from "drizzle-orm";
|
import { asc, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.radio");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
import { ContentCard, EmptyState, OnlineBadge } from "@/components/public/ui";
|
||||||
import { db, RadioSchedules, User } from "@/lib/db";
|
import { db, RadioSchedules, User } from "@/lib/db";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { desc } from "drizzle-orm";
|
import { desc } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +13,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
|
|
||||||
export const metadata = { title: "Rankings" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.rankings");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type TopUser = {
|
type TopUser = {
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { asc, count } from "drizzle-orm";
|
import { asc, count } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
|
import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export const metadata = { title: "Rare values" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.rares");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type CategoryRow = {
|
type CategoryRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
|||||||
import { SurfaceCard } from "@/components/surface-card";
|
import { SurfaceCard } from "@/components/surface-card";
|
||||||
import { db, Rooms, User } from "@/lib/db";
|
import { db, Rooms, User } from "@/lib/db";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Search" };
|
export const metadata: Metadata = {
|
||||||
|
title: "Search",
|
||||||
|
description: "Search for users and rooms in the hotel.",
|
||||||
|
openGraph: {
|
||||||
|
title: "Search",
|
||||||
|
description: "Search for users and rooms in the hotel.",
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
type SearchParams = Promise<{ q?: string }>;
|
type SearchParams = Promise<{ q?: string }>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { buyShopArticle } from "@/actions/shop";
|
import { buyShopArticle } from "@/actions/shop";
|
||||||
@@ -10,7 +11,18 @@ import { db, WebsiteShopArticles, WebsiteShopCategories } from "@/lib/db";
|
|||||||
import { excerpt } from "@/lib/format";
|
import { excerpt } from "@/lib/format";
|
||||||
import { creditsPerUnit } from "@/lib/services/paypal";
|
import { creditsPerUnit } from "@/lib/services/paypal";
|
||||||
|
|
||||||
export const metadata = { title: "Shop" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.shop");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
function feedbackStyle(tone: "success" | "error"): CSSProperties {
|
||||||
const accent =
|
const accent =
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { asc, desc, eq, gte } from "drizzle-orm";
|
import { asc, desc, eq, gte } from "drizzle-orm";
|
||||||
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||||
import { db, User, WebsiteTeams } from "@/lib/db";
|
import { db, User, WebsiteTeams } from "@/lib/db";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
export const metadata = { title: "Staff" };
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("pages.staff");
|
||||||
|
return {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
openGraph: {
|
||||||
|
title: t("title"),
|
||||||
|
description: t("subtitle"),
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type StaffMember = {
|
type StaffMember = {
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { and, count, eq, gte, sql, sum } from "drizzle-orm";
|
import { and, count, eq, gte, sql, sum } from "drizzle-orm";
|
||||||
import { ArrowLeftRight } from "lucide-react";
|
import { ArrowLeftRight } from "lucide-react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { RevenueChart } from "@/components/admin/revenue-chart";
|
|
||||||
import { StatsCard } from "@/components/admin/stats-card";
|
import { StatsCard } from "@/components/admin/stats-card";
|
||||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -16,6 +16,19 @@ import {
|
|||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { redisCache } from "@/lib/redis-cache";
|
import { redisCache } from "@/lib/redis-cache";
|
||||||
|
|
||||||
|
const RevenueChart = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/admin/revenue-chart").then((m) => ({
|
||||||
|
default: m.RevenueChart,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
loading: () => (
|
||||||
|
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||||
|
),
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
type EconomyData = {
|
type EconomyData = {
|
||||||
totalCredits: number;
|
totalCredits: number;
|
||||||
totalPixels: number;
|
totalPixels: number;
|
||||||
|
|||||||
@@ -7,16 +7,41 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { DashboardChart } from "@/components/admin/dashboard-chart";
|
|
||||||
import { PeakHoursHeatmap } from "@/components/admin/peak-hours-heatmap";
|
|
||||||
import { StatsCard } from "@/components/admin/stats-card";
|
import { StatsCard } from "@/components/admin/stats-card";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db";
|
import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
import { redisCache } from "@/lib/redis-cache";
|
import { redisCache } from "@/lib/redis-cache";
|
||||||
|
|
||||||
|
const DashboardChart = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/admin/dashboard-chart").then((m) => ({
|
||||||
|
default: m.DashboardChart,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
loading: () => (
|
||||||
|
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||||
|
),
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const PeakHoursHeatmap = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/admin/peak-hours-heatmap").then((m) => ({
|
||||||
|
default: m.PeakHoursHeatmap,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
loading: () => (
|
||||||
|
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||||
|
),
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
type AnalyticsData = {
|
type AnalyticsData = {
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
onlineUsers: number;
|
onlineUsers: number;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { desc, inArray } from "drizzle-orm";
|
import { desc, inArray } from "drizzle-orm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { dismissApplication } from "@/actions/admin-applications";
|
import { ApplicationActions } from "@/components/admin/application-actions";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { db, User, WebsiteStaffApplications } from "@/lib/db";
|
import { db, User, WebsiteStaffApplications } from "@/lib/db";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
|
||||||
type ApplicationRow = {
|
type ApplicationRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -31,7 +32,8 @@ export default async function AdminApplications() {
|
|||||||
.from(WebsiteStaffApplications)
|
.from(WebsiteStaffApplications)
|
||||||
.orderBy(desc(WebsiteStaffApplications.createdAt))
|
.orderBy(desc(WebsiteStaffApplications.createdAt))
|
||||||
.limit(100);
|
.limit(100);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logServerError("applications.query_failed", error);
|
||||||
applications = [];
|
applications = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +47,8 @@ export default async function AdminApplications() {
|
|||||||
.from(User)
|
.from(User)
|
||||||
.where(inArray(User.id, ids));
|
.where(inArray(User.id, ids));
|
||||||
for (const u of users) userMap.set(u.id, u.username);
|
for (const u of users) userMap.set(u.id, u.username);
|
||||||
} catch {
|
} catch (error) {
|
||||||
// no DB — fall back to raw ids
|
logServerError("applications.user_lookup_failed", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,12 +95,7 @@ export default async function AdminApplications() {
|
|||||||
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">
|
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">
|
||||||
{a.content}
|
{a.content}
|
||||||
</p>
|
</p>
|
||||||
<form action={dismissApplication}>
|
<ApplicationActions id={String(a.id)} />
|
||||||
<input type="hidden" name="id" value={String(a.id)} />
|
|
||||||
<Button type="submit" variant="destructive">
|
|
||||||
{t("dismiss")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
|
export default async function CatalogMaintenancePage() {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
|
|
||||||
export default function CatalogMaintenancePage() {
|
|
||||||
return <CatalogMaintenancePanel />;
|
return <CatalogMaintenancePanel />;
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { asc, count, eq, inArray } from "drizzle-orm";
|
import { asc, count, eq, inArray } from "drizzle-orm";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { disbandGuild } from "@/actions/admin-guilds";
|
import { disbandGuild, updateGuild } from "@/actions/admin-guilds";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { db, Guilds, GuildsForumsThreads, GuildsMembers, User } from "@/lib/db";
|
import { db, Guilds, GuildsForumsThreads, GuildsMembers, User } from "@/lib/db";
|
||||||
@@ -35,6 +35,10 @@ export default async function AdminGuildDetailPage({
|
|||||||
dateCreated: Guilds.dateCreated,
|
dateCreated: Guilds.dateCreated,
|
||||||
state: Guilds.state,
|
state: Guilds.state,
|
||||||
forum: Guilds.forum,
|
forum: Guilds.forum,
|
||||||
|
readForum: Guilds.readForum,
|
||||||
|
postMessages: Guilds.postMessages,
|
||||||
|
postThreads: Guilds.postThreads,
|
||||||
|
modForum: Guilds.modForum,
|
||||||
})
|
})
|
||||||
.from(Guilds)
|
.from(Guilds)
|
||||||
.where(eq(Guilds.id, id))
|
.where(eq(Guilds.id, id))
|
||||||
@@ -139,6 +143,142 @@ export default async function AdminGuildDetailPage({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{canEdit ? (
|
||||||
|
<section>
|
||||||
|
<h2 className="admin-section-title">{t("edit")}</h2>
|
||||||
|
<form action={updateGuild} className="space-y-3">
|
||||||
|
<input type="hidden" name="id" value={guild.id} />
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="guild-name" className="text-xs muted">
|
||||||
|
{t("formName")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="guild-name"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
maxLength={50}
|
||||||
|
defaultValue={guild.name}
|
||||||
|
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="guild-description" className="text-xs muted">
|
||||||
|
{t("formDescription")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="guild-description"
|
||||||
|
type="text"
|
||||||
|
name="description"
|
||||||
|
maxLength={250}
|
||||||
|
defaultValue={guild.description}
|
||||||
|
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="guild-state" className="text-xs muted">
|
||||||
|
{t("formState")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="guild-state"
|
||||||
|
name="state"
|
||||||
|
defaultValue={guild.state}
|
||||||
|
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="0">0 — Normal</option>
|
||||||
|
<option value="1">1 — Locked</option>
|
||||||
|
<option value="2">2 — Exclusive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="guild-forum" className="text-xs muted">
|
||||||
|
{t("formForum")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="guild-forum"
|
||||||
|
name="forum"
|
||||||
|
defaultValue={guild.forum}
|
||||||
|
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="0">0 — Off</option>
|
||||||
|
<option value="1">1 — On</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="guild-readForum" className="text-xs muted">
|
||||||
|
{t("formReadForum")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="guild-readForum"
|
||||||
|
name="readForum"
|
||||||
|
defaultValue={guild.readForum}
|
||||||
|
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="EVERYONE">EVERYONE</option>
|
||||||
|
<option value="OWNER">OWNER</option>
|
||||||
|
<option value="ADMIN">ADMIN</option>
|
||||||
|
<option value="MEMBER">MEMBER</option>
|
||||||
|
<option value="NONE">NONE</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="guild-postMessages" className="text-xs muted">
|
||||||
|
{t("formPostMessages")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="guild-postMessages"
|
||||||
|
name="postMessages"
|
||||||
|
defaultValue={guild.postMessages}
|
||||||
|
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="EVERYONE">EVERYONE</option>
|
||||||
|
<option value="OWNER">OWNER</option>
|
||||||
|
<option value="ADMIN">ADMIN</option>
|
||||||
|
<option value="MEMBER">MEMBER</option>
|
||||||
|
<option value="NONE">NONE</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="guild-postThreads" className="text-xs muted">
|
||||||
|
{t("formPostThreads")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="guild-postThreads"
|
||||||
|
name="postThreads"
|
||||||
|
defaultValue={guild.postThreads}
|
||||||
|
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="EVERYONE">EVERYONE</option>
|
||||||
|
<option value="OWNER">OWNER</option>
|
||||||
|
<option value="ADMIN">ADMIN</option>
|
||||||
|
<option value="MEMBER">MEMBER</option>
|
||||||
|
<option value="NONE">NONE</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="guild-modForum" className="text-xs muted">
|
||||||
|
{t("formModForum")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="guild-modForum"
|
||||||
|
name="modForum"
|
||||||
|
defaultValue={guild.modForum}
|
||||||
|
className="w-full rounded border bg-[var(--admin-card)] px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="ADMINS">ADMINS</option>
|
||||||
|
<option value="OWNER">OWNER</option>
|
||||||
|
<option value="MEMBER">MEMBER</option>
|
||||||
|
<option value="NONE">NONE</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="ghost" size="sm">
|
||||||
|
{t("save")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-3">
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="text-xs muted">{t("colOwner")}</div>
|
<div className="text-xs muted">{t("colOwner")}</div>
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
|
import dynamic from "next/dynamic";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AdminMediaGrid } from "@/components/admin/media-grid";
|
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
|
const AdminMediaGrid = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/admin/media-grid").then((m) => ({
|
||||||
|
default: m.AdminMediaGrid,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
loading: () => (
|
||||||
|
<div className="animate-pulse bg-[var(--admin-surface)] rounded h-48" />
|
||||||
|
),
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default async function AdminMediaPage() {
|
export default async function AdminMediaPage() {
|
||||||
const { session, permissions } = await getAdminContext();
|
const { session, permissions } = await getAdminContext();
|
||||||
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export function ModActionsClient() {
|
|||||||
value={userId}
|
value={userId}
|
||||||
onChange={(e) => setUserId(e.target.value)}
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
placeholder="Enter user ID"
|
placeholder="Enter user ID"
|
||||||
|
aria-label="User ID"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,6 +145,7 @@ export function ModActionsClient() {
|
|||||||
onChange={(e) => setMuteDuration(e.target.value)}
|
onChange={(e) => setMuteDuration(e.target.value)}
|
||||||
className="w-24"
|
className="w-24"
|
||||||
placeholder="Min"
|
placeholder="Min"
|
||||||
|
aria-label="Mute duration in minutes"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -215,6 +217,7 @@ export function ModActionsClient() {
|
|||||||
value={roomId}
|
value={roomId}
|
||||||
onChange={(e) => setRoomId(e.target.value)}
|
onChange={(e) => setRoomId(e.target.value)}
|
||||||
placeholder="Enter room ID"
|
placeholder="Enter room ID"
|
||||||
|
aria-label="Room ID"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
createValue,
|
createValue,
|
||||||
deleteCategory,
|
deleteCategory,
|
||||||
deleteValue,
|
deleteValue,
|
||||||
|
updateCategory,
|
||||||
|
updateValue,
|
||||||
} from "@/actions/admin-rare-values";
|
} from "@/actions/admin-rare-values";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -120,12 +122,49 @@ export default async function AdminRareValues() {
|
|||||||
{t("itemCount", { count: rows.length })}
|
{t("itemCount", { count: rows.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<form action={deleteCategory}>
|
<div className="flex gap-2 items-center">
|
||||||
<input type="hidden" name="id" value={catId} />
|
<details className="group">
|
||||||
<Button type="submit" variant="destructive">
|
<summary className="cursor-pointer list-none text-xs font-bold text-[var(--admin-accent)] hover:underline">
|
||||||
{t("deleteCategory")}
|
{t("editCategory")}
|
||||||
</Button>
|
</summary>
|
||||||
</form>
|
<form
|
||||||
|
action={updateCategory}
|
||||||
|
className="mt-2 flex gap-2 flex-wrap"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={catId} />
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
defaultValue={cat.name}
|
||||||
|
placeholder={t("categoryForm.namePlaceholder")}
|
||||||
|
required
|
||||||
|
className="flex-1 min-w-[140px]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="badge"
|
||||||
|
defaultValue={cat.badge}
|
||||||
|
placeholder={t("badgePlaceholder")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="priority"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
defaultValue={cat.priority}
|
||||||
|
placeholder={t("priorityPlaceholder")}
|
||||||
|
className="w-[110px]"
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="default">
|
||||||
|
{t("categoryForm.save")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<form action={deleteCategory}>
|
||||||
|
<input type="hidden" name="id" value={catId} />
|
||||||
|
<Button type="submit" variant="destructive">
|
||||||
|
{t("deleteCategory")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table style={{ marginTop: "0.75rem" }}>
|
<table style={{ marginTop: "0.75rem" }}>
|
||||||
@@ -141,32 +180,108 @@ export default async function AdminRareValues() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((v) => (
|
{rows.map((v) => {
|
||||||
<tr key={String(v.id)}>
|
const vId = String(v.id);
|
||||||
<td>{v.name}</td>
|
return (
|
||||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
<tr key={vId}>
|
||||||
{v.itemId ?? "—"}
|
<td>{v.name}</td>
|
||||||
</td>
|
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||||
<td>{v.creditValue ?? "—"}</td>
|
{v.itemId ?? "—"}
|
||||||
<td>{v.currencyValue ?? "—"}</td>
|
</td>
|
||||||
<td>
|
<td>{v.creditValue ?? "—"}</td>
|
||||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
<td>{v.currencyValue ?? "—"}</td>
|
||||||
{v.currencyType}
|
<td>
|
||||||
</span>
|
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||||
</td>
|
{v.currencyType}
|
||||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
</span>
|
||||||
{v.furnitureIcon}
|
</td>
|
||||||
</td>
|
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||||
<td>
|
{v.furnitureIcon}
|
||||||
<form action={deleteValue}>
|
</td>
|
||||||
<input type="hidden" name="id" value={String(v.id)} />
|
<td>
|
||||||
<Button type="submit" variant="destructive">
|
<div className="flex gap-1 items-center">
|
||||||
✕
|
<details className="group">
|
||||||
</Button>
|
<summary className="cursor-pointer list-none text-[0.72rem] font-bold text-[var(--admin-accent)] hover:underline">
|
||||||
</form>
|
{t("editValue")}
|
||||||
</td>
|
</summary>
|
||||||
</tr>
|
<form
|
||||||
))}
|
action={updateValue}
|
||||||
|
className="mt-2 flex gap-1 flex-wrap items-end"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={vId} />
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="categoryId"
|
||||||
|
value={catId}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
defaultValue={v.name}
|
||||||
|
placeholder={t(
|
||||||
|
"itemForm.itemNamePlaceholder",
|
||||||
|
)}
|
||||||
|
required
|
||||||
|
className="min-w-[120px]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="itemId"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
defaultValue={v.itemId ?? ""}
|
||||||
|
placeholder={t("itemForm.itemIdPlaceholder")}
|
||||||
|
className="w-[90px]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="furnitureIcon"
|
||||||
|
defaultValue={v.furnitureIcon}
|
||||||
|
placeholder={t("itemForm.iconPlaceholder")}
|
||||||
|
required
|
||||||
|
className="min-w-[120px]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="creditValue"
|
||||||
|
defaultValue={v.creditValue ?? ""}
|
||||||
|
placeholder={t("itemForm.creditsPlaceholder")}
|
||||||
|
className="w-[110px]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="currencyValue"
|
||||||
|
defaultValue={v.currencyValue ?? ""}
|
||||||
|
placeholder={t(
|
||||||
|
"itemForm.currencyValuePlaceholder",
|
||||||
|
)}
|
||||||
|
className="w-[110px]"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
name="currencyType"
|
||||||
|
defaultValue={v.currencyType}
|
||||||
|
>
|
||||||
|
{CURRENCY_TYPES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="default"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{t("itemForm.save")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<form action={deleteValue}>
|
||||||
|
<input type="hidden" name="id" value={vId} />
|
||||||
|
<Button type="submit" variant="destructive">
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
import { CatalogMaintenancePanel } from "@/components/admin/catalog/catalog-maintenance-panel";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
|
export default async function StudioCatalogMaintenancePage() {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
|
|
||||||
export default function StudioCatalogMaintenancePage() {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-0">
|
<div className="min-h-0">
|
||||||
<CatalogMaintenancePanel />
|
<CatalogMaintenancePanel />
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createVoucher, deleteVoucher } from "@/actions/admin-vouchers";
|
import {
|
||||||
|
createVoucher,
|
||||||
|
deleteVoucher,
|
||||||
|
updateVoucher,
|
||||||
|
} from "@/actions/admin-vouchers";
|
||||||
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
|
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
import { StatusCard } from "@/components/admin/dashboard";
|
||||||
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
import { CurrencyIcon } from "@/components/shared/currency-icon";
|
||||||
@@ -33,10 +37,15 @@ export function VouchersClient({
|
|||||||
const t = useTranslations("pages.admin.vouchers");
|
const t = useTranslations("pages.admin.vouchers");
|
||||||
const { run, isPending } = useServerAction();
|
const { run, isPending } = useServerAction();
|
||||||
const [pendingDelete, setPendingDelete] = useState<VoucherRow | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<VoucherRow | null>(null);
|
||||||
|
const [editingVoucher, setEditingVoucher] = useState<VoucherRow | null>(null);
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
const [maxUses, setMaxUses] = useState("1");
|
const [maxUses, setMaxUses] = useState("1");
|
||||||
const [expiresAt, setExpiresAt] = useState("");
|
const [expiresAt, setExpiresAt] = useState("");
|
||||||
|
const [editCode, setEditCode] = useState("");
|
||||||
|
const [editAmount, setEditAmount] = useState("");
|
||||||
|
const [editMaxUses, setEditMaxUses] = useState("1");
|
||||||
|
const [editExpiresAt, setEditExpiresAt] = useState("");
|
||||||
|
|
||||||
function handleCreate(e: React.FormEvent) {
|
function handleCreate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -72,6 +81,36 @@ export function VouchersClient({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleStartEdit(v: VoucherRow) {
|
||||||
|
setEditingVoucher(v);
|
||||||
|
setEditCode(v.code);
|
||||||
|
setEditAmount(String(v.amount));
|
||||||
|
setEditMaxUses(String(v.maxUses));
|
||||||
|
setEditExpiresAt(
|
||||||
|
v.expiresAt ? new Date(v.expiresAt).toISOString().slice(0, 16) : "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdate(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingVoucher || !canEdit) return;
|
||||||
|
run(
|
||||||
|
() =>
|
||||||
|
updateVoucher({
|
||||||
|
id: editingVoucher.id,
|
||||||
|
code: editCode,
|
||||||
|
amount: Number(editAmount),
|
||||||
|
maxUses: Number(editMaxUses),
|
||||||
|
expiresAt: editExpiresAt || undefined,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
successMessage: t("updated"),
|
||||||
|
errorMessage: t("updateError"),
|
||||||
|
onSuccess: () => setEditingVoucher(null),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||||
@@ -158,6 +197,79 @@ export function VouchersClient({
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canEdit && editingVoucher && (
|
||||||
|
<form onSubmit={handleUpdate} className="admin-card mb-6">
|
||||||
|
<h3 style={{ marginTop: 0 }}>
|
||||||
|
{t("edit")} — {editingVoucher.code}
|
||||||
|
</h3>
|
||||||
|
<div className="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||||
|
{t("form.code")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={editCode}
|
||||||
|
onChange={(e) => setEditCode(e.target.value)}
|
||||||
|
placeholder={t("form.codePlaceholder")}
|
||||||
|
maxLength={255}
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||||
|
{t("form.amount")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={editAmount}
|
||||||
|
onChange={(e) => setEditAmount(e.target.value)}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||||
|
{t("form.maxUses")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={editMaxUses}
|
||||||
|
onChange={(e) => setEditMaxUses(e.target.value)}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||||
|
{t("form.expiresAt")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={editExpiresAt}
|
||||||
|
onChange={(e) => setEditExpiresAt(e.target.value)}
|
||||||
|
type="datetime-local"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="submit" variant="default" disabled={isPending}>
|
||||||
|
{isPending ? t("form.saving") : t("form.save")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setEditingVoucher(null)}
|
||||||
|
>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<h2 className="admin-section-title">
|
<h2 className="admin-section-title">
|
||||||
{t("title")} ({vouchers.length})
|
{t("title")} ({vouchers.length})
|
||||||
@@ -216,14 +328,24 @@ export function VouchersClient({
|
|||||||
</td>
|
</td>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<td>
|
<td>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<Button
|
||||||
variant="destructive"
|
type="button"
|
||||||
disabled={isPending}
|
variant="default"
|
||||||
onClick={() => setPendingDelete(v)}
|
disabled={isPending}
|
||||||
>
|
onClick={() => handleStartEdit(v)}
|
||||||
{t("delete")}
|
>
|
||||||
</Button>
|
{t("edit")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => setPendingDelete(v)}
|
||||||
|
>
|
||||||
|
{t("delete")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { eq, like, or } from "drizzle-orm";
|
||||||
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
|
import { apiOk } from "@/lib/api-response";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
Guilds,
|
||||||
|
Rooms,
|
||||||
|
User,
|
||||||
|
WebsiteArticles,
|
||||||
|
WebsiteRareValues,
|
||||||
|
WebsiteShopArticles,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
|
type SearchResult = {
|
||||||
|
type: string;
|
||||||
|
id: number | string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PER_TYPE = 5;
|
||||||
|
const MAX_TOTAL = 20;
|
||||||
|
|
||||||
|
export const GET = withAdmin(
|
||||||
|
{ permission: PERMS.ADMIN_DASHBOARD },
|
||||||
|
async (request) => {
|
||||||
|
const q = (request.nextUrl.searchParams.get("q") || "").trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
return apiOk({ results: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${q}%`;
|
||||||
|
const idExact = Number.parseInt(q, 10);
|
||||||
|
const hasId = Number.isFinite(idExact) && String(idExact) === q;
|
||||||
|
|
||||||
|
const [users, articles, rooms, guilds, shopArticles, rareValues] =
|
||||||
|
await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: User.id,
|
||||||
|
title: User.username,
|
||||||
|
subtitle: User.mail,
|
||||||
|
})
|
||||||
|
.from(User)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
like(User.username, pattern),
|
||||||
|
like(User.mail, pattern),
|
||||||
|
...(hasId ? [eq(User.id, idExact)] : []),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(PER_TYPE),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: WebsiteArticles.id,
|
||||||
|
title: WebsiteArticles.title,
|
||||||
|
subtitle: WebsiteArticles.slug,
|
||||||
|
})
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
like(WebsiteArticles.title, pattern),
|
||||||
|
like(WebsiteArticles.slug, pattern),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(PER_TYPE),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: Rooms.id,
|
||||||
|
title: Rooms.name,
|
||||||
|
subtitle: Rooms.ownerName,
|
||||||
|
})
|
||||||
|
.from(Rooms)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
like(Rooms.name, pattern),
|
||||||
|
...(hasId ? [eq(Rooms.id, idExact)] : []),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(PER_TYPE),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: Guilds.id,
|
||||||
|
title: Guilds.name,
|
||||||
|
subtitle: Guilds.description,
|
||||||
|
})
|
||||||
|
.from(Guilds)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
like(Guilds.name, pattern),
|
||||||
|
...(hasId ? [eq(Guilds.id, idExact)] : []),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(PER_TYPE),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: WebsiteShopArticles.id,
|
||||||
|
title: WebsiteShopArticles.name,
|
||||||
|
subtitle: WebsiteShopArticles.info,
|
||||||
|
})
|
||||||
|
.from(WebsiteShopArticles)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
like(WebsiteShopArticles.name, pattern),
|
||||||
|
...(hasId ? [eq(WebsiteShopArticles.id, BigInt(idExact))] : []),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(PER_TYPE),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: WebsiteRareValues.id,
|
||||||
|
title: WebsiteRareValues.name,
|
||||||
|
subtitle: WebsiteRareValues.itemId,
|
||||||
|
})
|
||||||
|
.from(WebsiteRareValues)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
like(WebsiteRareValues.name, pattern),
|
||||||
|
...(hasId ? [eq(WebsiteRareValues.itemId, idExact)] : []),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(PER_TYPE),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groupMap: Record<
|
||||||
|
string,
|
||||||
|
{ type: string; items: Array<SearchResult> }
|
||||||
|
> = {
|
||||||
|
users: { type: "users", items: [] },
|
||||||
|
articles: { type: "articles", items: [] },
|
||||||
|
rooms: { type: "rooms", items: [] },
|
||||||
|
guilds: { type: "guilds", items: [] },
|
||||||
|
shop: { type: "shop", items: [] },
|
||||||
|
rareValues: { type: "rareValues", items: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const r of users) {
|
||||||
|
groupMap.users.items.push({
|
||||||
|
type: "users",
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
subtitle: r.subtitle || "",
|
||||||
|
url: `/admin/users/show/${r.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const r of articles) {
|
||||||
|
groupMap.articles.items.push({
|
||||||
|
type: "articles",
|
||||||
|
id: Number(r.id),
|
||||||
|
title: r.title,
|
||||||
|
subtitle: r.subtitle,
|
||||||
|
url: `/admin/articles/${r.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const r of rooms) {
|
||||||
|
groupMap.rooms.items.push({
|
||||||
|
type: "rooms",
|
||||||
|
id: r.id,
|
||||||
|
title: r.title || `Room #${r.id}`,
|
||||||
|
subtitle: r.subtitle || "",
|
||||||
|
url: `/admin/rooms/${r.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const r of guilds) {
|
||||||
|
groupMap.guilds.items.push({
|
||||||
|
type: "guilds",
|
||||||
|
id: r.id,
|
||||||
|
title: r.title || `Guild #${r.id}`,
|
||||||
|
subtitle: r.subtitle || "",
|
||||||
|
url: `/admin/guilds/${r.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const r of shopArticles) {
|
||||||
|
groupMap.shop.items.push({
|
||||||
|
type: "shop",
|
||||||
|
id: Number(r.id),
|
||||||
|
title: r.title,
|
||||||
|
subtitle: r.subtitle || "",
|
||||||
|
url: `/admin/shop/${r.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const r of rareValues) {
|
||||||
|
groupMap.rareValues.items.push({
|
||||||
|
type: "rareValues",
|
||||||
|
id: Number(r.id),
|
||||||
|
title: r.title,
|
||||||
|
subtitle: r.subtitle != null ? `Item #${r.subtitle}` : "",
|
||||||
|
url: `/admin/rare-values/${r.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
const order = [
|
||||||
|
"users",
|
||||||
|
"articles",
|
||||||
|
"rooms",
|
||||||
|
"guilds",
|
||||||
|
"shop",
|
||||||
|
"rareValues",
|
||||||
|
];
|
||||||
|
for (const key of order) {
|
||||||
|
for (const item of groupMap[key].items) {
|
||||||
|
results.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiOk({ results: results.slice(0, MAX_TOTAL) });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { and, eq, or, sql } from "drizzle-orm";
|
||||||
import { apiJson } from "@/lib/api";
|
import { apiJson } from "@/lib/api";
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
||||||
@@ -30,7 +30,16 @@ export async function GET(
|
|||||||
updatedAt: WebsiteArticles.updatedAt,
|
updatedAt: WebsiteArticles.updatedAt,
|
||||||
})
|
})
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
.where(eq(WebsiteArticles.slug, slug))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteArticles.slug, slug),
|
||||||
|
eq(WebsiteArticles.status, "published"),
|
||||||
|
or(
|
||||||
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||||
|
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!article) {
|
if (!article) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { count, desc } from "drizzle-orm";
|
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
||||||
import { apiJson, pagination } from "@/lib/api";
|
import { apiJson, pagination } from "@/lib/api";
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
||||||
@@ -17,8 +17,18 @@ export async function GET(req: Request) {
|
|||||||
apiCacheKey(`articles:${page}:${perPage}`),
|
apiCacheKey(`articles:${page}:${perPage}`),
|
||||||
60,
|
60,
|
||||||
async () => {
|
async () => {
|
||||||
|
const publishedFilter = and(
|
||||||
|
eq(WebsiteArticles.status, "published"),
|
||||||
|
or(
|
||||||
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||||
|
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||||
|
),
|
||||||
|
);
|
||||||
const [totalRows, articles] = await Promise.all([
|
const [totalRows, articles] = await Promise.all([
|
||||||
db.select({ total: count() }).from(WebsiteArticles),
|
db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.where(publishedFilter),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: WebsiteArticles.id,
|
id: WebsiteArticles.id,
|
||||||
@@ -29,6 +39,7 @@ export async function GET(req: Request) {
|
|||||||
createdAt: WebsiteArticles.createdAt,
|
createdAt: WebsiteArticles.createdAt,
|
||||||
})
|
})
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
|
.where(publishedFilter)
|
||||||
.orderBy(desc(WebsiteArticles.createdAt))
|
.orderBy(desc(WebsiteArticles.createdAt))
|
||||||
.limit(take)
|
.limit(take)
|
||||||
.offset(skip),
|
.offset(skip),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { count, desc, eq } from "drizzle-orm";
|
import { and, count, desc, eq, or, sql } from "drizzle-orm";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { apiJson } from "@/lib/api";
|
import { apiJson } from "@/lib/api";
|
||||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||||
@@ -24,6 +24,15 @@ export async function GET(_req: Request) {
|
|||||||
createdAt: WebsiteArticles.createdAt,
|
createdAt: WebsiteArticles.createdAt,
|
||||||
})
|
})
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteArticles.status, "published"),
|
||||||
|
or(
|
||||||
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||||
|
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.orderBy(desc(WebsiteArticles.createdAt))
|
.orderBy(desc(WebsiteArticles.createdAt))
|
||||||
.limit(4),
|
.limit(4),
|
||||||
db.select({ total: count() }).from(User).where(eq(User.online, "1")),
|
db.select({ total: count() }).from(User).where(eq(User.online, "1")),
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ export default async function ModBansPage() {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("colUserId")}</th>
|
<th scope="col">{t("colUserId")}</th>
|
||||||
<th>{t("colType")}</th>
|
<th scope="col">{t("colType")}</th>
|
||||||
<th>{t("colReason")}</th>
|
<th scope="col">{t("colReason")}</th>
|
||||||
<th>{t("colExpires")}</th>
|
<th scope="col">{t("colExpires")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { requireModPermission } from "@/lib/admin/guard";
|
|||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||||
import { db, SupportTickets, User } from "@/lib/db";
|
import { db, SupportTickets, User } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
|
||||||
const SORT_COLS = {
|
const SORT_COLS = {
|
||||||
id: SupportTickets.id,
|
id: SupportTickets.id,
|
||||||
@@ -55,7 +56,8 @@ export default async function ModCfhListPage({
|
|||||||
.where(like(User.username, `%${q}%`))
|
.where(like(User.username, `%${q}%`))
|
||||||
.limit(50);
|
.limit(50);
|
||||||
userIds = users.map((u) => u.id);
|
userIds = users.map((u) => u.id);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
logServerError("cfh.user_search_failed", error, { query: q });
|
||||||
userIds = [];
|
userIds = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +93,10 @@ export default async function ModCfhListPage({
|
|||||||
.from(SupportTickets)
|
.from(SupportTickets)
|
||||||
.where(where)
|
.where(where)
|
||||||
.then((rows) => rows[0]?.total ?? 0)
|
.then((rows) => rows[0]?.total ?? 0)
|
||||||
.catch(() => 0);
|
.catch((error) => {
|
||||||
|
logServerError("cfh.count_failed", error);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||||
|
|
||||||
const tickets = await db
|
const tickets = await db
|
||||||
@@ -101,7 +106,10 @@ export default async function ModCfhListPage({
|
|||||||
.orderBy(finalOrder)
|
.orderBy(finalOrder)
|
||||||
.offset(pagination.offset)
|
.offset(pagination.offset)
|
||||||
.limit(pagination.perPage)
|
.limit(pagination.perPage)
|
||||||
.catch(() => []);
|
.catch((error) => {
|
||||||
|
logServerError("cfh.query_failed", error);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
const userIds = [
|
const userIds = [
|
||||||
...new Set([
|
...new Set([
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
|||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { getAvatarUrl } from "@/lib/imager";
|
import { getAvatarUrl } from "@/lib/imager";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
import type { DataTableColumn } from "@/types/common";
|
import type { DataTableColumn } from "@/types/common";
|
||||||
|
|
||||||
type ModUserRow = {
|
type ModUserRow = {
|
||||||
@@ -139,7 +140,10 @@ export default async function ModUsersPage({
|
|||||||
.from(User)
|
.from(User)
|
||||||
.where(where)
|
.where(where)
|
||||||
.then((rows) => rows[0]?.total ?? 0)
|
.then((rows) => rows[0]?.total ?? 0)
|
||||||
.catch(() => 0),
|
.catch((error) => {
|
||||||
|
logServerError("users.count_failed", error);
|
||||||
|
return 0;
|
||||||
|
}),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: User.id,
|
id: User.id,
|
||||||
@@ -155,7 +159,10 @@ export default async function ModUsersPage({
|
|||||||
.orderBy(finalOrder)
|
.orderBy(finalOrder)
|
||||||
.offset((parsed.page - 1) * parsed.perPage)
|
.offset((parsed.page - 1) * parsed.perPage)
|
||||||
.limit(parsed.perPage)
|
.limit(parsed.perPage)
|
||||||
.catch(() => [] as ModUserRow[]),
|
.catch((error) => {
|
||||||
|
logServerError("users.query_failed", error);
|
||||||
|
return [] as ModUserRow[];
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const total = totals;
|
const total = totals;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { SearchDialog } from "@/components/admin/search-dialog";
|
||||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||||
import { findAdminHub } from "@/lib/admin-nav";
|
import { findAdminHub } from "@/lib/admin-nav";
|
||||||
@@ -29,6 +30,7 @@ export function AdminTopbar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<SearchDialog />
|
||||||
<div className="flex items-center gap-1.5 lg:hidden">
|
<div className="flex items-center gap-1.5 lg:hidden">
|
||||||
<LanguageSwitcher variant="admin" />
|
<LanguageSwitcher variant="admin" />
|
||||||
<ThemeSwitcher variant="admin" />
|
<ThemeSwitcher variant="admin" />
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import {
|
||||||
|
approveApplication,
|
||||||
|
dismissApplication,
|
||||||
|
} from "@/actions/admin-applications";
|
||||||
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function ApplicationActions({ id }: { id: string }) {
|
||||||
|
const t = useTranslations("pages.admin.applications");
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||||
|
|
||||||
|
async function handleApprove() {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("approve"),
|
||||||
|
description: t("confirmApprove"),
|
||||||
|
confirmLabel: t("approve"),
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("id", id);
|
||||||
|
await approveApplication(fd);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject() {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("reject"),
|
||||||
|
description: t("confirmReject"),
|
||||||
|
confirmLabel: t("reject"),
|
||||||
|
variant: "danger",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("id", id);
|
||||||
|
await dismissApplication(fd);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{confirmDialog}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={handleApprove}
|
||||||
|
>
|
||||||
|
{t("approve")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={handleReject}
|
||||||
|
>
|
||||||
|
{t("reject")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ export function ArticleForm({
|
|||||||
image?: string;
|
image?: string;
|
||||||
shortStory?: string;
|
shortStory?: string;
|
||||||
fullStory?: string;
|
fullStory?: string;
|
||||||
|
status?: string;
|
||||||
|
publishAt?: string;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.admin.articles.form");
|
const t = useTranslations("pages.admin.articles.form");
|
||||||
@@ -26,6 +28,7 @@ export function ArticleForm({
|
|||||||
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
|
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
|
||||||
const [image, setImage] = useState(defaultValues?.image ?? "");
|
const [image, setImage] = useState(defaultValues?.image ?? "");
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
const [status, setStatus] = useState(defaultValues?.status ?? "published");
|
||||||
|
|
||||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||||
|
|
||||||
@@ -132,6 +135,39 @@ export function ArticleForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("status")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
className="input input-bordered mt-1 w-full"
|
||||||
|
>
|
||||||
|
<option value="draft">{t("statusDraft")}</option>
|
||||||
|
<option value="scheduled">{t("statusScheduled")}</option>
|
||||||
|
<option value="published">{t("statusPublished")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{status === "scheduled" ? (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("publishAt")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="publishAt"
|
||||||
|
defaultValue={defaultValues?.publishAt ?? ""}
|
||||||
|
required
|
||||||
|
className="input input-bordered mt-1 w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" className="btn btn-primary justify-self-start">
|
<button type="submit" className="btn btn-primary justify-self-start">
|
||||||
{t("save")}
|
{t("save")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export function BreadcrumbBar() {
|
|||||||
dispatch({ type: "SET_ANCESTORS", ancestors: d.ancestors ?? [] });
|
dispatch({ type: "SET_ANCESTORS", ancestors: d.ancestors ?? [] });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch((error) =>
|
||||||
|
console.error("[Breadcrumb] Failed to fetch ancestors:", error),
|
||||||
|
);
|
||||||
return () => ac.abort();
|
return () => ac.abort();
|
||||||
}, [selectedPageId, dispatch, catQs]);
|
}, [selectedPageId, dispatch, catQs]);
|
||||||
|
|
||||||
|
|||||||
@@ -667,7 +667,9 @@ export function SortableTree({
|
|||||||
ids: pages.map((p) => p.id),
|
ids: pages.map((p) => p.id),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch((error) =>
|
||||||
|
console.error("[SortableTree] Failed to load children:", error),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [activeTabId, childrenMap, nodes, dispatch, loadChildren]);
|
}, [activeTabId, childrenMap, nodes, dispatch, loadChildren]);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function QuickAddFurni({
|
|||||||
)
|
)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => setResults(data.results ?? []))
|
.then((data) => setResults(data.results ?? []))
|
||||||
.catch(() => {})
|
.catch((error) => console.error("[QuickAdd] Furni search failed:", error))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
return () => ac.abort();
|
return () => ac.abort();
|
||||||
}, [debounced, open]);
|
}, [debounced, open]);
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function AdminMediaGrid() {
|
|||||||
setCopied(url);
|
setCopied(url);
|
||||||
setTimeout(() => setCopied(null), 1500);
|
setTimeout(() => setCopied(null), 1500);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch((error) => console.error("[Media] Clipboard copy failed:", error));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(name: string) {
|
async function remove(name: string) {
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SearchIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
type SearchResult = {
|
||||||
|
type: string;
|
||||||
|
id: number | string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
results: SearchResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SearchDialog() {
|
||||||
|
const t = useTranslations("pages.admin.search");
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const debouncedQuery = useDebounce(query, 250);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const groupLabels: Record<string, string> = {
|
||||||
|
users: t("users"),
|
||||||
|
articles: t("articles"),
|
||||||
|
rooms: t("rooms"),
|
||||||
|
guilds: t("guilds"),
|
||||||
|
shop: t("shop"),
|
||||||
|
rareValues: t("rareValues"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchResults = useCallback(async (q: string) => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
if (q.length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const data: SearchResponse = await res.json();
|
||||||
|
if (data.ok) setResults(data.results);
|
||||||
|
} catch {}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResults(debouncedQuery);
|
||||||
|
}, [debouncedQuery, fetchResults]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleSelect(url: string) {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = groupResults(results);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs text-[var(--admin-text-muted)] hover:border-[var(--admin-accent)]/40 hover:text-[var(--admin-text)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<SearchIcon size={14} />
|
||||||
|
<span className="hidden sm:inline">{t("placeholder")}</span>
|
||||||
|
<kbd className="pointer-events-none hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-60">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
|
||||||
|
<Command
|
||||||
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
|
||||||
|
shouldFilter={false}
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("placeholder")}
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
className="h-12"
|
||||||
|
/>
|
||||||
|
<CommandList className="max-h-[400px]">
|
||||||
|
{loading && query.length >= 2 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||||
|
{Object.entries(grouped).map(([group, items]) => (
|
||||||
|
<CommandGroup key={group} heading={groupLabels[group] ?? group}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`${item.type}-${item.id}`}
|
||||||
|
value={`${item.type}-${item.id}`}
|
||||||
|
onSelect={() => handleSelect(item.url)}
|
||||||
|
className="flex flex-col items-start gap-0.5 py-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{item.title}</span>
|
||||||
|
{item.subtitle ? (
|
||||||
|
<span className="text-xs text-muted-foreground truncate w-full">
|
||||||
|
{item.subtitle}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupResults(results: SearchResult[]) {
|
||||||
|
const grouped: Record<string, SearchResult[]> = {};
|
||||||
|
for (const r of results) {
|
||||||
|
if (!grouped[r.type]) grouped[r.type] = [];
|
||||||
|
grouped[r.type].push(r);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { type ReactNode, useRef, useState } from "react";
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { mobileMenuVariants } from "@/lib/motion";
|
import { mobileMenuVariants } from "@/lib/motion";
|
||||||
|
|
||||||
interface MobileNavProps {
|
interface MobileNavProps {
|
||||||
@@ -20,6 +26,57 @@ export function MobileNav({
|
|||||||
}: MobileNavProps) {
|
}: MobileNavProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const detailsRef = useRef<HTMLDivElement>(null);
|
const detailsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const MENU_ID = "mobile-nav-menu";
|
||||||
|
|
||||||
|
const handleEscape = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && open) {
|
||||||
|
setOpen(false);
|
||||||
|
detailsRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[open],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [handleEscape]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const menu = menuRef.current;
|
||||||
|
if (!menu) return;
|
||||||
|
|
||||||
|
const focusableSelector =
|
||||||
|
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
function handleTab(e: KeyboardEvent) {
|
||||||
|
if (e.key !== "Tab" || !menu) return;
|
||||||
|
const focusable = Array.from(
|
||||||
|
menu.querySelectorAll<HTMLElement>(focusableSelector),
|
||||||
|
);
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.addEventListener("keydown", handleTab);
|
||||||
|
return () => menu.removeEventListener("keydown", handleTab);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={detailsRef} className="group relative md:hidden">
|
<div ref={detailsRef} className="group relative md:hidden">
|
||||||
@@ -33,6 +90,8 @@ export function MobileNav({
|
|||||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary-readable,var(--color-primary))] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-navbar)]"
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary-readable,var(--color-primary))] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-navbar)]"
|
||||||
aria-label={open ? closeLabel : menuLabel}
|
aria-label={open ? closeLabel : menuLabel}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-controls={MENU_ID}
|
||||||
>
|
>
|
||||||
<motion.svg
|
<motion.svg
|
||||||
className="w-6 h-6"
|
className="w-6 h-6"
|
||||||
@@ -63,6 +122,9 @@ export function MobileNav({
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
id={MENU_ID}
|
||||||
|
role="menu"
|
||||||
|
aria-label={brandLabel || "Navigation menu"}
|
||||||
className="absolute left-0 top-full mt-2 w-[min(88vw,360px)] z-50 origin-top-left"
|
className="absolute left-0 top-full mt-2 w-[min(88vw,360px)] z-50 origin-top-left"
|
||||||
style={{ backgroundColor: "transparent" }}
|
style={{ backgroundColor: "transparent" }}
|
||||||
variants={mobileMenuVariants}
|
variants={mobileMenuVariants}
|
||||||
@@ -71,8 +133,9 @@ export function MobileNav({
|
|||||||
exit="exit"
|
exit="exit"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={menuRef}
|
||||||
className="flex flex-col gap-y-1 p-2 rounded-2xl border shadow-xl max-h-[calc(100dvh-5rem)] overflow-y-auto overscroll-contain
|
className="flex flex-col gap-y-1 p-2 rounded-2xl border shadow-xl max-h-[calc(100dvh-5rem)] overflow-y-auto overscroll-contain
|
||||||
bg-[var(--color-dropdown,var(--color-surface,#fff))]"
|
bg-[var(--color-dropdown,var(--color-surface,#fff))]"
|
||||||
style={{
|
style={{
|
||||||
borderColor:
|
borderColor:
|
||||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export default function RadioPlayer() {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data !== null) setConfig(parseConfig(data));
|
if (data !== null) setConfig(parseConfig(data));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch((error) => console.error("[Radio] Config fetch failed:", error));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Keep the <audio> volume in sync with the slider.
|
// Keep the <audio> volume in sync with the slider.
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ export function PwaRegister() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
navigator.serviceWorker.register(`/sw.js?${SW_VERSION}`).catch(() => {});
|
navigator.serviceWorker
|
||||||
|
.register(`/sw.js?${SW_VERSION}`)
|
||||||
|
.catch((error) =>
|
||||||
|
console.error("[PWA] Service worker registration failed:", error),
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
import { sanitize } from "@/lib/sanitize";
|
||||||
|
|
||||||
interface SanitizedHtmlProps {
|
interface SanitizedHtmlProps {
|
||||||
html: string;
|
html: string;
|
||||||
@@ -11,7 +12,7 @@ export function SanitizedHtml({ html, className, style }: SanitizedHtmlProps) {
|
|||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
style={style}
|
style={style}
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: sanitize(html) }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -660,6 +660,9 @@ export const WebsiteArticles = mysqlTable("website_articles", {
|
|||||||
image: varchar("image", { length: 255 }).notNull(),
|
image: varchar("image", { length: 255 }).notNull(),
|
||||||
createdAt: timestamp("created_at"),
|
createdAt: timestamp("created_at"),
|
||||||
updatedAt: timestamp("updated_at"),
|
updatedAt: timestamp("updated_at"),
|
||||||
|
status: varchar("status", { length: 20 }).notNull().default("published"),
|
||||||
|
publishAt: timestamp("publish_at"),
|
||||||
|
publishedAt: timestamp("published_at"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WebsiteLanguages = mysqlTable("website_languages", {
|
export const WebsiteLanguages = mysqlTable("website_languages", {
|
||||||
|
|||||||
+2
-4
@@ -1,8 +1,6 @@
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
|
|
||||||
const sanitizeHtml = (html: string) => DOMPurify.sanitize(html);
|
|
||||||
|
|
||||||
export function sanitize(html: string | null | undefined): string {
|
export function sanitize(html: string | null | undefined): string {
|
||||||
if (!html) return "";
|
if (!html) return "";
|
||||||
return sanitizeHtml(html);
|
return DOMPurify.sanitize(html);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { existsSync, promises as fs } from "node:fs";
|
import { existsSync, promises as fs } from "node:fs";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
import { resolveFigureSwfUrl } from "@/lib/services/figure-source";
|
import { resolveFigureSwfUrl } from "@/lib/services/figure-source";
|
||||||
import { listFigureLibraries } from "@/lib/services/figuremap";
|
import { listFigureLibraries } from "@/lib/services/figuremap";
|
||||||
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
import { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
||||||
@@ -134,7 +135,9 @@ export async function deleteImportedFigure(
|
|||||||
): Promise<{ deletedFile: boolean }> {
|
): Promise<{ deletedFile: boolean }> {
|
||||||
const p = await nitroPathFor(lib);
|
const p = await nitroPathFor(lib);
|
||||||
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
if (existsSync(/*turbopackIgnore: true*/ p)) {
|
||||||
await fs.unlink(/*turbopackIgnore: true*/ p).catch(() => {});
|
await fs
|
||||||
|
.unlink(/*turbopackIgnore: true*/ p)
|
||||||
|
.catch((error) => logServerError("figure.delete_failed", error, { lib }));
|
||||||
return { deletedFile: true };
|
return { deletedFile: true };
|
||||||
}
|
}
|
||||||
return { deletedFile: false };
|
return { deletedFile: false };
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { and, eq, type SQL, sql } from "drizzle-orm";
|
|||||||
import { CatalogPages, db, ItemsBase } from "@/lib/db";
|
import { CatalogPages, db, ItemsBase } from "@/lib/db";
|
||||||
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FURNI_NITRO_DIR,
|
DEFAULT_FURNI_NITRO_DIR,
|
||||||
getFurniAssetDirs,
|
getFurniAssetDirs,
|
||||||
@@ -474,7 +475,9 @@ export async function allocateCatalogItemId<T>(
|
|||||||
catalogIdSeedChain = new Promise<void>((r) => {
|
catalogIdSeedChain = new Promise<void>((r) => {
|
||||||
settle = r;
|
settle = r;
|
||||||
});
|
});
|
||||||
await prev.catch(() => {});
|
await prev.catch((error) =>
|
||||||
|
logServerError("furni.catalog_id_chain_failed", error),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
catalogNextId === null ||
|
catalogNextId === null ||
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { desc } from "drizzle-orm";
|
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||||
import { cached } from "@/lib/cache";
|
import { cached } from "@/lib/cache";
|
||||||
import { db, WebsiteArticles } from "@/lib/db";
|
import { db, WebsiteArticles } from "@/lib/db";
|
||||||
|
|
||||||
@@ -42,6 +42,15 @@ export async function getNewsList(limit: number): Promise<NewsListItem[]> {
|
|||||||
createdAt: WebsiteArticles.createdAt,
|
createdAt: WebsiteArticles.createdAt,
|
||||||
})
|
})
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(WebsiteArticles.status, "published"),
|
||||||
|
or(
|
||||||
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||||
|
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.orderBy(desc(WebsiteArticles.createdAt))
|
.orderBy(desc(WebsiteArticles.createdAt))
|
||||||
.limit(FETCH_LIMIT),
|
.limit(FETCH_LIMIT),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { existsSync, promises as fs } from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
||||||
import {
|
import {
|
||||||
type CloneSource,
|
type CloneSource,
|
||||||
@@ -152,7 +153,10 @@ export async function repairMissingIcons(
|
|||||||
if (gamedataRoot) {
|
if (gamedataRoot) {
|
||||||
const badgeFiles = await fs
|
const badgeFiles = await fs
|
||||||
.readdir(getRuntimePath(gamedataRoot, "album1584"))
|
.readdir(getRuntimePath(gamedataRoot, "album1584"))
|
||||||
.catch(() => [] as string[]);
|
.catch((error) => {
|
||||||
|
logServerError("repair_icons.readdir_album_failed", error);
|
||||||
|
return [] as string[];
|
||||||
|
});
|
||||||
const albumBadgeSet = new Set(
|
const albumBadgeSet = new Set(
|
||||||
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
|
badgeFiles.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)),
|
||||||
);
|
);
|
||||||
@@ -373,7 +377,11 @@ export async function repairMissingIcons(
|
|||||||
`extract from ${source.name} .nitro failed: ${(err as Error).message}`,
|
`extract from ${source.name} .nitro failed: ${(err as Error).message}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await fs.unlink(nitroTmp).catch(() => {});
|
await fs
|
||||||
|
.unlink(nitroTmp)
|
||||||
|
.catch((error) =>
|
||||||
|
logServerError("repair_icons.cleanup_failed", error),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (ok) break;
|
if (ok) break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { db, ItemsBase } from "@/lib/db";
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
|
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
|
||||||
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
|
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
|
||||||
import {
|
import {
|
||||||
@@ -96,7 +97,9 @@ async function allocateItemsBaseId<T>(
|
|||||||
itemsBaseIdSeedChain = new Promise<void>((r) => {
|
itemsBaseIdSeedChain = new Promise<void>((r) => {
|
||||||
settle = r;
|
settle = r;
|
||||||
});
|
});
|
||||||
await prev.catch(() => {});
|
await prev.catch((error) =>
|
||||||
|
logServerError("upload.items_base_id_chain_failed", error),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
itemsBaseNextId === null ||
|
itemsBaseNextId === null ||
|
||||||
@@ -358,10 +361,25 @@ export async function uploadSingleFurni(params: {
|
|||||||
return nextId;
|
return nextId;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await fs.unlink(nitroPath).catch(() => {});
|
await fs
|
||||||
if (iconPath) await fs.unlink(iconPath).catch(() => {});
|
.unlink(nitroPath)
|
||||||
|
.catch((error) =>
|
||||||
|
logServerError("upload.cleanup_nitro_failed", error, { classname }),
|
||||||
|
);
|
||||||
|
if (iconPath)
|
||||||
|
await fs
|
||||||
|
.unlink(iconPath)
|
||||||
|
.catch((error) =>
|
||||||
|
logServerError("upload.cleanup_icon_failed", error, { classname }),
|
||||||
|
);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
mirroredPaths.map((file) => fs.unlink(file).catch(() => {})),
|
mirroredPaths.map((file) =>
|
||||||
|
fs
|
||||||
|
.unlink(file)
|
||||||
|
.catch((error) =>
|
||||||
|
logServerError("upload.cleanup_mirror_failed", error, { file }),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import { db, WebsiteSetting } from "@/lib/db";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { logServerError } from "@/lib/server-log";
|
||||||
|
|
||||||
export type { WebhookAction } from "@/types/admin";
|
export type { WebhookAction } from "@/types/admin";
|
||||||
|
|
||||||
@@ -126,6 +127,14 @@ async function sendTelegram(payload: WebhookPayload): Promise<void> {
|
|||||||
|
|
||||||
/** Fire-and-forget notification to Discord + Telegram */
|
/** Fire-and-forget notification to Discord + Telegram */
|
||||||
export function notify(payload: WebhookPayload): void {
|
export function notify(payload: WebhookPayload): void {
|
||||||
sendDiscord(payload).catch(() => {});
|
sendDiscord(payload).catch((error) =>
|
||||||
sendTelegram(payload).catch(() => {});
|
logServerError("webhook.discord_notify_failed", error, {
|
||||||
|
action: payload.action,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sendTelegram(payload).catch((error) =>
|
||||||
|
logServerError("webhook.telegram_notify_failed", error, {
|
||||||
|
action: payload.action,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+35
-3
@@ -1069,7 +1069,17 @@
|
|||||||
"commandLog": "Command log",
|
"commandLog": "Command log",
|
||||||
"tradeLog": "Trade log",
|
"tradeLog": "Trade log",
|
||||||
"tools": "Tools",
|
"tools": "Tools",
|
||||||
"studio": "Studio"
|
"studio": "Studio",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search…",
|
||||||
|
"noResults": "No results found",
|
||||||
|
"users": "Users",
|
||||||
|
"articles": "Articles",
|
||||||
|
"rooms": "Rooms",
|
||||||
|
"guilds": "Guilds",
|
||||||
|
"shop": "Shop",
|
||||||
|
"hint": "Start typing to search across users, articles, rooms, guilds and shop items"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -1120,7 +1130,13 @@
|
|||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"deleteArticle": "Delete article",
|
"deleteArticle": "Delete article",
|
||||||
"deleteConfirm": "This action cannot be undone. The article will be permanently removed.",
|
"deleteConfirm": "This action cannot be undone. The article will be permanently removed.",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"status": "Status",
|
||||||
|
"statusDraft": "Draft",
|
||||||
|
"statusScheduled": "Scheduled",
|
||||||
|
"statusPublished": "Published",
|
||||||
|
"publishAt": "Publish at",
|
||||||
|
"publishedAt": "Published at"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
@@ -1893,7 +1909,20 @@
|
|||||||
"noMembers": "No members.",
|
"noMembers": "No members.",
|
||||||
"back": "Back to guilds",
|
"back": "Back to guilds",
|
||||||
"viewPublic": "Open public page",
|
"viewPublic": "Open public page",
|
||||||
"disband": "Disband guild"
|
"disband": "Disband guild",
|
||||||
|
"edit": "Edit guild",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"updated": "Guild updated",
|
||||||
|
"updateError": "Could not update guild",
|
||||||
|
"formName": "Name",
|
||||||
|
"formDescription": "Description",
|
||||||
|
"formState": "State",
|
||||||
|
"formForum": "Forum",
|
||||||
|
"formReadForum": "Read forum",
|
||||||
|
"formPostMessages": "Post messages",
|
||||||
|
"formPostThreads": "Post threads",
|
||||||
|
"formModForum": "Moderate forum"
|
||||||
},
|
},
|
||||||
"transactions": {
|
"transactions": {
|
||||||
"title": "Transactions",
|
"title": "Transactions",
|
||||||
@@ -1943,8 +1972,10 @@
|
|||||||
"confirmDelete": "Delete voucher \"{code}\"?",
|
"confirmDelete": "Delete voucher \"{code}\"?",
|
||||||
"created": "Voucher created",
|
"created": "Voucher created",
|
||||||
"deleted": "Voucher deleted",
|
"deleted": "Voucher deleted",
|
||||||
|
"updated": "Voucher updated",
|
||||||
"createError": "Could not create voucher",
|
"createError": "Could not create voucher",
|
||||||
"deleteError": "Could not delete voucher",
|
"deleteError": "Could not delete voucher",
|
||||||
|
"updateError": "Could not update voucher",
|
||||||
"statusActive": "Active",
|
"statusActive": "Active",
|
||||||
"statusUsedUp": "Used up",
|
"statusUsedUp": "Used up",
|
||||||
"statusExpired": "Expired",
|
"statusExpired": "Expired",
|
||||||
@@ -2010,6 +2041,7 @@
|
|||||||
"items": "Items",
|
"items": "Items",
|
||||||
"addCategory": "Add category",
|
"addCategory": "Add category",
|
||||||
"editCategory": "Edit category",
|
"editCategory": "Edit category",
|
||||||
|
"editValue": "Edit value",
|
||||||
"deleteCategory": "Delete category",
|
"deleteCategory": "Delete category",
|
||||||
"noCategories": "No rare value categories yet",
|
"noCategories": "No rare value categories yet",
|
||||||
"confirmDeleteCategory": "Delete category \"{name}\" and all its items?",
|
"confirmDeleteCategory": "Delete category \"{name}\" and all its items?",
|
||||||
|
|||||||
Reference in new issue
Block a user