diff --git a/.gitignore b/.gitignore index 17cb3763..1ca97329 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ public/tmp/ # Test coverage reports coverage/ +.aider* diff --git a/package.json b/package.json index 019173eb..57848347 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "drizzle-orm": "0.45.2", "hash-wasm": "4.12.0", "ioredis": "6.0.0", + "isomorphic-dompurify": "^4.1.0", "jpeg-js": "0.4.4", "jsonc-parser": "3.3.1", "jszip": "3.10.1", @@ -74,6 +75,7 @@ "@tailwindcss/forms": "0.5.11", "@tailwindcss/postcss": "4.3.3", "@tailwindcss/typography": "0.5.20", + "@types/dompurify": "^3.2.0", "@types/node": "26.4.0", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38a22756..c237c730 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,9 @@ importers: ioredis: specifier: 6.0.0 version: 6.0.0(supports-color@7.2.0) + isomorphic-dompurify: + specifier: ^4.1.0 + version: 4.1.0(@noble/hashes@2.4.0) jpeg-js: specifier: 0.4.4 version: 0.4.4 @@ -146,6 +149,9 @@ importers: '@tailwindcss/typography': specifier: 0.5.20 version: 0.5.20(tailwindcss@4.3.3) + '@types/dompurify': + specifier: ^3.2.0 + version: 3.2.0 '@types/node': specifier: 26.4.0 version: 26.4.0 @@ -187,7 +193,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -195,6 +201,14 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@auth/core@0.41.3': resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==} peerDependencies: @@ -321,6 +335,46 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + 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/css-color-parser@4.2.2': + 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/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -531,6 +585,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + 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/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} @@ -1719,6 +1782,10 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/dompurify@3.2.0': + 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/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -1918,6 +1985,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1965,6 +2035,10 @@ packages: resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} engines: {node: '>=18.0'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1973,6 +2047,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -1985,6 +2063,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -2102,6 +2183,10 @@ packages: resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -2186,6 +2271,10 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2225,12 +2314,19 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isomorphic-dompurify@4.1.0: + resolution: {integrity: sha512-vjQwQSxyEkJHO6g+KIDlWj8swAzUB01pJu9GU9cLGHjE10d59GFzJkgtB2Lj5WTOoq9JeHlYjR/FFuqeopPO0Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2260,6 +2356,15 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + jsdom@30.0.1: + 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 + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -2444,6 +2549,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru.min@1.1.4: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} 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==} engines: {node: '>=10'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@2.0.0: resolution: {integrity: sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==} engines: {node: '>=18'} @@ -2600,6 +2712,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2655,6 +2770,10 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -2717,6 +2836,10 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.3.0: resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} @@ -2747,6 +2870,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2856,6 +2983,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -2885,10 +3015,25 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.11: + resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} + + tldts@7.4.11: + resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==} + hasBin: true + token-types@6.1.2: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2913,6 +3058,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@8.10.1: + resolution: {integrity: sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==} + engines: {node: '>=22.19.0'} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -3030,10 +3179,30 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -3045,6 +3214,13 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -3057,6 +3233,21 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + 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/core@0.41.3': dependencies: '@panva/hkdf': 1.2.1 @@ -3142,6 +3333,34 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.8)': dependencies: react: 19.2.8 @@ -3289,6 +3508,10 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@exodus/bytes@1.15.1(@noble/hashes@2.4.0)': + optionalDependencies: + '@noble/hashes': 2.4.0 + '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -4064,6 +4287,10 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.4.14 + '@types/estree@1.0.9': {} '@types/node@26.4.0': @@ -4153,7 +4380,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -4214,6 +4441,10 @@ snapshots: baseline-browser-mapping@2.11.20: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + buffer-from@1.1.2: {} caniuse-lite@1.0.30001810: {} @@ -4252,10 +4483,22 @@ snapshots: croner@10.0.1: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} csstype@3.2.3: {} + data-urls@7.0.0(@noble/hashes@2.4.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' + dateformat@4.6.3: {} debug@4.4.3(supports-color@7.2.0): @@ -4264,6 +4507,8 @@ snapshots: optionalDependencies: supports-color: 7.2.0 + decimal.js@10.6.0: {} + denque@2.1.0: {} detect-libc@2.1.2: {} @@ -4294,6 +4539,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + es-module-lexer@2.3.2: {} esbuild@0.25.12: @@ -4389,6 +4636,12 @@ snapshots: help-me@5.0.0: {} + html-encoding-sniffer@6.0.0(@noble/hashes@2.4.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} husky@9.1.7: {} @@ -4429,10 +4682,20 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} + is-property@1.0.2: {} isarray@1.0.0: {} + isomorphic-dompurify@4.1.0(@noble/hashes@2.4.0): + dependencies: + dompurify: 3.4.14 + jsdom: 30.0.1(@noble/hashes@2.4.0) + transitivePeerDependencies: + - '@noble/hashes' + - canvas + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -4456,6 +4719,32 @@ snapshots: js-tokens@10.0.0: {} + jsdom@30.0.1(@noble/hashes@2.4.0): + 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(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.4.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.4.0) + 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/hashes@2.4.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsonc-parser@3.3.1: {} jszip@3.10.1: @@ -4597,6 +4886,8 @@ snapshots: long@5.3.2: {} + lru-cache@11.5.2: {} + lru.min@1.1.4: {} lucide-react@1.38.0(react@19.2.8): @@ -4619,6 +4910,8 @@ snapshots: dependencies: semver: 7.8.5 + mdn-data@2.27.1: {} + media-typer@2.0.0: {} mini-svg-data-uri@1.4.4: {} @@ -4798,6 +5091,10 @@ snapshots: pako@1.0.11: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -4870,6 +5167,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + quick-format-unescaped@4.0.4: {} react-dom@19.2.8(react@19.2.8): @@ -4926,6 +5225,8 @@ snapshots: redis-errors@1.2.0: {} + require-from-string@2.0.2: {} + reselect@5.3.0: {} resend@6.25.0: @@ -4962,6 +5263,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} secure-json-parse@4.1.0: {} @@ -5065,6 +5370,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.3: {} @@ -5086,12 +5393,26 @@ snapshots: tinyrainbow@3.1.1: {} + tldts-core@7.4.11: {} + + tldts@7.4.11: + dependencies: + tldts-core: 7.4.11 + token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.2 '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.11 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tslib@2.8.1: {} tsx@4.23.13: @@ -5129,6 +5450,8 @@ snapshots: undici-types@8.3.0: {} + undici@8.10.1: {} + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 @@ -5173,7 +5496,7 @@ snapshots: tsx: 4.23.13 yaml: 2.9.0 - vitest@4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.4.0)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.0)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -5198,11 +5521,36 @@ snapshots: optionalDependencies: '@types/node': 26.4.0 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 30.0.1(@noble/hashes@2.4.0) transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.4.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0(@noble/hashes@2.4.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -5212,6 +5560,10 @@ snapshots: wrappy@1.0.2: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yaml@2.9.0: {} zod@4.5.4: {} diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index c89d137d..3f45ef50 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -1,8 +1,13 @@ import "./load-env"; import { Cron } from "croner"; -import { lt, sql } from "drizzle-orm"; +import { and, lt, or, sql } from "drizzle-orm"; 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 { redis } from "../src/lib/redis"; import { emulatorOffline, healthDegraded } from "../src/lib/services/alert"; @@ -224,6 +229,35 @@ async function cleanupOldSessions(): Promise { } } +async function publishScheduledArticles(): Promise { + 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() { logger.info("Worker started", { module: "jobs" }); @@ -257,6 +291,15 @@ async function main() { }); 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([ backupEmulatorJar(), cleanupOldLogs(), diff --git a/src/actions/admin-applications.ts b/src/actions/admin-applications.ts index 22bce9fb..5b3deb44 100644 --- a/src/actions/admin-applications.ts +++ b/src/actions/admin-applications.ts @@ -22,3 +22,19 @@ export async function dismissApplication(formData: FormData): Promise { revalidatePath("/admin/applications"); } + +export async function approveApplication(formData: FormData): Promise { + 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"); +} diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 5369ed9c..d550e159 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -12,6 +12,7 @@ import { } from "@/lib/db"; import { slugify } from "@/lib/format"; import { PERMS } from "@/lib/permissions"; +import { notify } from "@/lib/services/webhook"; async function uniqueSlug(title: string): Promise { const base = slugify(title); @@ -43,12 +44,16 @@ export async function createArticle(formData: FormData): Promise { .normalize("NFC") .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; try { 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({ - slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title), + slug, title: title.slice(0, 255), shortStory: shortStory.slice(0, 255), fullStory, @@ -56,6 +61,16 @@ export async function createArticle(formData: FormData): Promise { userId: staff.id, createdAt: now, updatedAt: now, + status: status || "published", + publishAt, + publishedAt: status === "published" ? now : null, + }); + + notify({ + action: "news_publish", + actor: staff.username, + target: title, + details: slug, }); } catch { // Database error — re-render unchanged with error. @@ -70,7 +85,10 @@ export async function updateArticle(formData: FormData): Promise { await requirePermission(PERMS.NEWS_EDIT); const id = BigInt(String(formData.get("id"))); const rawSlug = String(formData.get("slug") ?? "").trim(); + const status = String(formData.get("status") ?? "published"); + const rawPublishAt = String(formData.get("publishAt") ?? "").trim(); try { + const publishAt = rawPublishAt ? new Date(rawPublishAt) : null; await db .update(WebsiteArticles) .set({ @@ -90,6 +108,9 @@ export async function updateArticle(formData: FormData): Promise { .normalize("NFC") .trim() .slice(0, 255), + status, + publishAt, + publishedAt: status === "published" ? new Date() : undefined, updatedAt: new Date(), }) .where(eq(WebsiteArticles.id, id)); @@ -101,8 +122,13 @@ export async function updateArticle(formData: FormData): Promise { } export async function deleteArticle(formData: FormData): Promise { - await requirePermission(PERMS.NEWS_EDIT); + const staff = await requirePermission(PERMS.NEWS_EDIT); 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 { await db.transaction(async (tx) => { await tx @@ -113,6 +139,12 @@ export async function deleteArticle(formData: FormData): Promise { .where(eq(WebsiteArticleComments.articleId, id)); await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id)); }); + + notify({ + action: "news_delete", + actor: staff.username, + target: article?.title ?? String(id), + }); } catch { redirect("/admin/articles?error=Delete failed"); } diff --git a/src/actions/admin-guilds.ts b/src/actions/admin-guilds.ts index 26c827c5..430a7a29 100644 --- a/src/actions/admin-guilds.ts +++ b/src/actions/admin-guilds.ts @@ -2,7 +2,10 @@ import { eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -import { requirePermissionRateLimited } from "@/lib/admin/guard"; +import { + requirePermission, + requirePermissionRateLimited, +} from "@/lib/admin/guard"; import { db, GuildForumViews, @@ -63,3 +66,47 @@ export async function disbandGuild(formData: FormData): Promise { }); revalidatePath("/admin/guilds"); } + +export async function updateGuild(formData: FormData): Promise { + 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}`); +} diff --git a/src/actions/admin-rare-values.ts b/src/actions/admin-rare-values.ts index 8eac1cdb..9dff2ca4 100644 --- a/src/actions/admin-rare-values.ts +++ b/src/actions/admin-rare-values.ts @@ -115,3 +115,88 @@ export async function deleteValue(formData: FormData): Promise { } revalidatePath("/admin/rare-values"); } + +export async function updateCategory(formData: FormData): Promise { + 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 { + 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 = { + 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"); +} diff --git a/src/actions/admin-vouchers.ts b/src/actions/admin-vouchers.ts index 28feeb58..64021bf3 100644 --- a/src/actions/admin-vouchers.ts +++ b/src/actions/admin-vouchers.ts @@ -83,3 +83,58 @@ export async function deleteVoucher(input: { return actionError("Could not delete voucher"); } } + +export async function updateVoucher(input: { + id: string; + code: string; + amount: number; + maxUses: number; + expiresAt?: string; +}): Promise { + 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)"); + } +} diff --git a/src/actions/draw-badge.ts b/src/actions/draw-badge.ts index 5ab8903e..ff36600e 100644 --- a/src/actions/draw-badge.ts +++ b/src/actions/draw-badge.ts @@ -6,6 +6,7 @@ import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { db, User, UsersBadges, WebsiteDrawbadges } from "@/lib/db"; import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { logServerError } from "@/lib/server-log"; import { rcon } from "@/lib/services/rcon"; import { siteSettings } from "@/lib/services/site-settings"; @@ -127,7 +128,12 @@ export async function buyBadge(formData: FormData): Promise { } // 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"; boughtCode = code; diff --git a/src/actions/events.ts b/src/actions/events.ts index 634812f5..709d969e 100644 --- a/src/actions/events.ts +++ b/src/actions/events.ts @@ -15,6 +15,7 @@ import { PERMS } from "@/lib/permissions"; import { adminAction, authAction } from "@/lib/safe-action"; import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared"; import { logAudit } from "@/lib/services/audit"; +import { notify } from "@/lib/services/webhook"; import { createEventSchema, eventPrizeSchema, @@ -120,6 +121,11 @@ export const createEvent = adminAction( targetId: eventId, after: { title: ctx.data.title }, }); + notify({ + action: "event_create", + actor: ctx.session.user.username, + target: ctx.data.title, + }); return actionOk({ id: eventId }); }, ); @@ -155,6 +161,11 @@ export const updateEvent = adminAction( before: { title: existing.title, status: existing.status }, after: data, }); + notify({ + action: "event_update", + actor: ctx.session.user.username, + target: data.title ?? existing.title, + }); return actionOk({ id }); }, ); diff --git a/src/actions/help-tickets.ts b/src/actions/help-tickets.ts index 798bbf8e..f194c251 100644 --- a/src/actions/help-tickets.ts +++ b/src/actions/help-tickets.ts @@ -15,6 +15,7 @@ import { import { clientIp, rateLimit } from "@/lib/rate-limit"; import { moderateOrThrow } from "@/lib/services/moderation"; import { createOwnedTicketReply } from "@/lib/services/ticket-replies"; +import { notify } from "@/lib/services/webhook"; const ticketSchema = z.object({ title: z.string().min(1, "Title is required").max(255), @@ -160,6 +161,15 @@ export async function createTicket(formData: FormData): Promise { updatedAt: now, }); outcome = "created"; + + const sessionUser = session?.user; + if (sessionUser?.name) { + notify({ + action: "ticket_create", + actor: sessionUser.name, + target: ticketTitle, + }); + } } } } diff --git a/src/actions/password-reset.ts b/src/actions/password-reset.ts index fdd5f03b..ac6203b7 100644 --- a/src/actions/password-reset.ts +++ b/src/actions/password-reset.ts @@ -7,6 +7,7 @@ import { env } from "@/env"; import { hashPassword } from "@/lib/auth/password"; import { db, PasswordReset, User } from "@/lib/db"; import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { logServerError } from "@/lib/server-log"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { sendMail } from "@/lib/services/email"; @@ -123,7 +124,11 @@ export async function resetPassword(formData: FormData): Promise { await db .delete(PasswordReset) .where(eq(PasswordReset.email, email)) - .catch(() => {}); + .catch((error) => + logServerError("password.reset_delete_tokens_failed", error, { + email, + }), + ); } } } catch { diff --git a/src/actions/polls.ts b/src/actions/polls.ts index 63ea030c..0559ca25 100644 --- a/src/actions/polls.ts +++ b/src/actions/polls.ts @@ -13,6 +13,7 @@ import { PERMS } from "@/lib/permissions"; import { adminAction, authAction } from "@/lib/safe-action"; import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared"; import { logAudit } from "@/lib/services/audit"; +import { notify } from "@/lib/services/webhook"; import { createPollSchema, pollQuestionSchema, @@ -38,6 +39,11 @@ export const createPoll = adminAction( targetId: pollId, after: { title: ctx.data.title }, }); + notify({ + action: "poll_create", + actor: ctx.session.user.username, + target: ctx.data.title, + }); return actionOk({ id: pollId }); }, ); diff --git a/src/actions/rooms.ts b/src/actions/rooms.ts index 43381ddd..6b14181b 100644 --- a/src/actions/rooms.ts +++ b/src/actions/rooms.ts @@ -7,6 +7,7 @@ import { db, Items, Rooms } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { rcon } from "@/lib/services/rcon"; import { logStaffActivity } from "@/lib/services/staff-activity"; +import { notify } from "@/lib/services/webhook"; export async function updateRoomItem(payload: Record) { const staff = await requirePermission(PERMS.ROOMS_EDIT); @@ -75,11 +76,17 @@ export async function roomRconAction({ roomId: number; action: string; }) { - await requirePermission(PERMS.ROOMS_EDIT); + const staff = await requirePermission(PERMS.ROOMS_EDIT); if (action === "reload") { await rcon.send("reloadroom", { room_id: roomId }); } else if (action === "kick") { await rcon.send("kickall", { room_id: roomId }); + notify({ + action: "kick", + actor: staff.username, + target: String(roomId), + details: action, + }); } else if (action === "lock") { await rcon.send("updateroom", { room_id: roomId, state: "locked" }); } else if (action === "unlock") { @@ -89,6 +96,11 @@ export async function roomRconAction({ export async function deleteRoom({ id }: { id: number }) { 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 logStaffActivity({ staffId: staff.id, @@ -97,6 +109,11 @@ export async function deleteRoom({ id }: { id: number }) { targetType: "room", targetId: id, }); + notify({ + action: "room_delete", + actor: staff.username, + target: room?.name ?? `#${id}`, + }); revalidatePath("/admin/rooms"); } diff --git a/src/actions/shop.ts b/src/actions/shop.ts index e3b70a5f..33ec7283 100644 --- a/src/actions/shop.ts +++ b/src/actions/shop.ts @@ -6,6 +6,7 @@ import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { db, User, UsersBadges, WebsiteShopArticles } from "@/lib/db"; import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { logServerError } from "@/lib/server-log"; import { creditsPerUnit } from "@/lib/services/paypal"; import { rcon } from "@/lib/services/rcon"; import { currencyDb, sendCurrency } from "@/lib/services/send-currency"; @@ -183,7 +184,12 @@ export async function buyShopArticle(formData: FormData): Promise { ); 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"; diff --git a/src/actions/users.ts b/src/actions/users.ts index 746c6dca..d4526acc 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -97,7 +97,7 @@ export const createUser = adminAction( }); notify({ - action: "user_edit", + action: "user_create", actor: ctx.session.user.username, target: username, targetId: user.id, @@ -450,6 +450,13 @@ export const muteUser = adminAction( after: { duration: ctx.data.duration }, }); + notify({ + action: "hotel_alert", + actor: ctx.session.user.username, + target: _target.username, + details: "Muted", + }); + return actionOk(); }, ); @@ -463,7 +470,7 @@ const unmuteSchema = z.object({ export const unmuteUser = adminAction( { permission: PERMS.USERS_EDIT, schema: unmuteSchema }, 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); if (!success) throw new ActionError("Failed to unmute. Is the emulator running?"); @@ -475,6 +482,13 @@ export const unmuteUser = adminAction( targetId: ctx.data.userId, }); + notify({ + action: "hotel_alert", + actor: ctx.session.user.username, + target: target.username, + details: "Unmuted", + }); + return actionOk(); }, ); diff --git a/src/app/(site)/apply/staff/page.tsx b/src/app/(site)/apply/staff/page.tsx index 0b917712..9e9fd362 100644 --- a/src/app/(site)/apply/staff/page.tsx +++ b/src/app/(site)/apply/staff/page.tsx @@ -1,4 +1,5 @@ import { desc, eq, inArray } from "drizzle-orm"; +import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import type { CSSProperties } from "react"; @@ -14,6 +15,19 @@ import { import { formatDate } from "@/lib/format-date"; import { resolveHotelName } from "@/lib/hotel-name"; +export async function generateMetadata(): Promise { + 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). const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; diff --git a/src/app/(site)/apply/team/page.tsx b/src/app/(site)/apply/team/page.tsx index 82d4a130..b1699a64 100644 --- a/src/app/(site)/apply/team/page.tsx +++ b/src/app/(site)/apply/team/page.tsx @@ -1,4 +1,5 @@ import { asc, eq } from "drizzle-orm"; +import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import type { CSSProperties } from "react"; @@ -8,6 +9,19 @@ import { auth } from "@/lib/auth"; import { db, WebsiteStaffApplications, WebsiteTeams } from "@/lib/db"; import { resolveHotelName } from "@/lib/hotel-name"; +export async function generateMetadata(): Promise { + 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). const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; diff --git a/src/app/(site)/badges/page.tsx b/src/app/(site)/badges/page.tsx index 94f927c5..4de670b1 100644 --- a/src/app/(site)/badges/page.tsx +++ b/src/app/(site)/badges/page.tsx @@ -1,9 +1,21 @@ import { asc } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { db, WebsiteBadges } from "@/lib/db"; -export const metadata = { title: "Badges" }; +export async function generateMetadata(): Promise { + 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. const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584"; diff --git a/src/app/(site)/community/page.tsx b/src/app/(site)/community/page.tsx index c4139f7d..63576e72 100644 --- a/src/app/(site)/community/page.tsx +++ b/src/app/(site)/community/page.tsx @@ -1,8 +1,21 @@ +import type { Metadata } from "next"; import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; import { ContentCard } from "@/components/public/ui"; -export const metadata = { title: "Community" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("pages.community"); + return { + title: t("title"), + description: t("subtitle"), + openGraph: { + title: t("title"), + description: t("subtitle"), + type: "website", + }, + }; +} const LINKS = [ { diff --git a/src/app/(site)/developers/page.tsx b/src/app/(site)/developers/page.tsx index 7b17ebb2..b07f7565 100644 --- a/src/app/(site)/developers/page.tsx +++ b/src/app/(site)/developers/page.tsx @@ -1,9 +1,18 @@ +import type { Metadata } from "next"; import { ContentCard } from "@/components/public/ui"; // Public API documentation. Static, hand-maintained from the routes that // 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"; diff --git a/src/app/(site)/draw-badge/page.tsx b/src/app/(site)/draw-badge/page.tsx index 09a7cb8b..e1588657 100644 --- a/src/app/(site)/draw-badge/page.tsx +++ b/src/app/(site)/draw-badge/page.tsx @@ -1,4 +1,5 @@ import { desc, eq } from "drizzle-orm"; +import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { buyBadge } from "@/actions/draw-badge"; 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 // 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; diff --git a/src/app/(site)/events/page.tsx b/src/app/(site)/events/page.tsx index 26e78e65..f916b050 100644 --- a/src/app/(site)/events/page.tsx +++ b/src/app/(site)/events/page.tsx @@ -1,4 +1,5 @@ import { count, desc, eq, inArray } from "drizzle-orm"; +import type { Metadata } from "next"; import Image from "next/image"; import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; @@ -12,7 +13,18 @@ import { import { excerpt, slugify } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; -export const metadata = { title: "Events" }; +export async function generateMetadata(): Promise { + 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() { const t = await getTranslations("pages.events"); diff --git a/src/app/(site)/friends/page.tsx b/src/app/(site)/friends/page.tsx index b88c2ac4..946b7241 100644 --- a/src/app/(site)/friends/page.tsx +++ b/src/app/(site)/friends/page.tsx @@ -1,4 +1,5 @@ import { asc, eq, inArray, or } from "drizzle-orm"; +import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import type { CSSProperties } from "react"; @@ -9,7 +10,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { auth } from "@/lib/auth"; import { db, MessengerFriendships, User } from "@/lib/db"; -export const metadata = { title: "Friends" }; +export async function generateMetadata(): Promise { + 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 }>; diff --git a/src/app/(site)/guilds/page.tsx b/src/app/(site)/guilds/page.tsx index e7c9a7b4..8481b979 100644 --- a/src/app/(site)/guilds/page.tsx +++ b/src/app/(site)/guilds/page.tsx @@ -1,11 +1,23 @@ import { desc } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { db, Guilds } from "@/lib/db"; import { excerpt } from "@/lib/format"; -export const metadata = { title: "Guilds" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("pages.guilds"); + return { + title: t("title"), + description: t("subtitle"), + openGraph: { + title: t("title"), + description: t("subtitle"), + type: "website", + }, + }; +} type GuildCard = { id: number; diff --git a/src/app/(site)/help/page.tsx b/src/app/(site)/help/page.tsx index c8472e1e..077b8bd3 100644 --- a/src/app/(site)/help/page.tsx +++ b/src/app/(site)/help/page.tsx @@ -1,6 +1,21 @@ import { asc } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; + +export async function generateMetadata(): Promise { + 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 { db, diff --git a/src/app/(site)/layout.tsx b/src/app/(site)/layout.tsx index b4984653..fcb00e85 100644 --- a/src/app/(site)/layout.tsx +++ b/src/app/(site)/layout.tsx @@ -1,12 +1,22 @@ +import dynamic from "next/dynamic"; import type { ReactNode } from "react"; import MotionPageWrapper from "@/components/motion-page-wrapper"; import { Navigation } from "@/components/navigation"; -import RadioPlayerGate from "@/components/public/radio-player-gate"; import { SiteFooter } from "@/components/site-footer"; import { SiteHeader } from "@/components/site-header"; import { TopHeader } from "@/components/top-header"; import { auth } from "@/lib/auth"; +const RadioPlayerGate = dynamic( + () => import("@/components/public/radio-player-gate"), + { + loading: () => ( +
+ ), + ssr: false, + }, +); + /** * Public site chrome. Route group `(site)` keeps this off `/admin` and `/client`, * so housekeeping is never constrained by the public max-w-7xl grid. diff --git a/src/app/(site)/leaderboard/page.tsx b/src/app/(site)/leaderboard/page.tsx index ccc21b5f..238b3db5 100644 --- a/src/app/(site)/leaderboard/page.tsx +++ b/src/app/(site)/leaderboard/page.tsx @@ -1,4 +1,5 @@ import { desc, eq } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; 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 { db, User, UsersCurrency, UsersSettings } from "@/lib/db"; -export const metadata = { title: "Leaderboard" }; +export async function generateMetadata(): Promise { + 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): // Credits = -1 (lives on users.credits), Duckets = 0, Diamonds = 5. diff --git a/src/app/(site)/marketplace/page.tsx b/src/app/(site)/marketplace/page.tsx index f0a1fb61..853ada5a 100644 --- a/src/app/(site)/marketplace/page.tsx +++ b/src/app/(site)/marketplace/page.tsx @@ -1,10 +1,22 @@ import { desc, eq, inArray } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { ContentCard, EmptyState, StatBlock } from "@/components/public/ui"; import { db, MarketplaceItems, User } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; -export const metadata = { title: "Marketplace" }; +export async function generateMetadata(): Promise { + 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. const STATE_ACTIVE = 1; diff --git a/src/app/(site)/news/[...slug]/page.tsx b/src/app/(site)/news/[...slug]/page.tsx index 932c9d2e..11136a44 100644 --- a/src/app/(site)/news/[...slug]/page.tsx +++ b/src/app/(site)/news/[...slug]/page.tsx @@ -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 { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; @@ -56,7 +56,16 @@ export async function generateMetadata({ shortStory: WebsiteArticles.shortStory, }) .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) .catch(() => []); if (!article) return { title: "Article" }; @@ -108,7 +117,16 @@ export default async function ArticlePage({ updatedAt: WebsiteArticles.updatedAt, }) .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) .catch(() => []); return row ?? null; diff --git a/src/app/(site)/page.tsx b/src/app/(site)/page.tsx index 4a2835d4..8572b8c8 100644 --- a/src/app/(site)/page.tsx +++ b/src/app/(site)/page.tsx @@ -1,8 +1,22 @@ import { count, desc, eq } from "drizzle-orm"; +import type { Metadata } from "next"; import { headers } from "next/headers"; import Image from "next/image"; import { redirect } from "next/navigation"; 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 { AnimatedCounter } from "@/components/animated-counter"; import { HomeLoginForm } from "@/components/auth/home-login-form"; diff --git a/src/app/(site)/photos/page.tsx b/src/app/(site)/photos/page.tsx index f921d0f0..e9f59f0a 100644 --- a/src/app/(site)/photos/page.tsx +++ b/src/app/(site)/photos/page.tsx @@ -1,4 +1,5 @@ import { desc } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { type LightboxPhoto, @@ -9,7 +10,18 @@ import { cached } from "@/lib/cache"; import { CameraWeb, db } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; -export const metadata = { title: "Photos" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("pages.photos"); + return { + title: t("title"), + description: t("subtitle"), + openGraph: { + title: t("title"), + description: t("subtitle"), + type: "website", + }, + }; +} type Photo = { id: number; diff --git a/src/app/(site)/polls/page.tsx b/src/app/(site)/polls/page.tsx index 71becaf0..be741d5d 100644 --- a/src/app/(site)/polls/page.tsx +++ b/src/app/(site)/polls/page.tsx @@ -1,4 +1,5 @@ import { and, count, desc, inArray, isNull, lte, or } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; import { ContentCard, EmptyState } from "@/components/public/ui"; @@ -6,7 +7,18 @@ import { db, WebsitePoll, WebsitePollQuestion } from "@/lib/db"; import { excerpt } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; -export const metadata = { title: "Polls" }; +export async function generateMetadata(): Promise { + 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() { const t = await getTranslations("pages.polls"); diff --git a/src/app/(site)/radio/page.tsx b/src/app/(site)/radio/page.tsx index 208c360f..1aa6511b 100644 --- a/src/app/(site)/radio/page.tsx +++ b/src/app/(site)/radio/page.tsx @@ -1,6 +1,21 @@ import { asc, eq, inArray } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; + +export async function generateMetadata(): Promise { + 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 { db, RadioSchedules, User } from "@/lib/db"; import { siteSettings } from "@/lib/services/site-settings"; diff --git a/src/app/(site)/rankings/page.tsx b/src/app/(site)/rankings/page.tsx index 4ae06ead..7c0c6872 100644 --- a/src/app/(site)/rankings/page.tsx +++ b/src/app/(site)/rankings/page.tsx @@ -1,4 +1,5 @@ import { desc } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; import { @@ -12,7 +13,18 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { cached } from "@/lib/cache"; import { db, User } from "@/lib/db"; -export const metadata = { title: "Rankings" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("pages.rankings"); + return { + title: t("title"), + description: t("subtitle"), + openGraph: { + title: t("title"), + description: t("subtitle"), + type: "website", + }, + }; +} type TopUser = { username: string; diff --git a/src/app/(site)/rares/page.tsx b/src/app/(site)/rares/page.tsx index 4d5a4df8..747b470b 100644 --- a/src/app/(site)/rares/page.tsx +++ b/src/app/(site)/rares/page.tsx @@ -1,11 +1,23 @@ import { asc, count } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { db, WebsiteRareValueCategories, WebsiteRareValues } from "@/lib/db"; import { siteSettings } from "@/lib/services/site-settings"; -export const metadata = { title: "Rare values" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("pages.rares"); + return { + title: t("title"), + description: t("subtitle"), + openGraph: { + title: t("title"), + description: t("subtitle"), + type: "website", + }, + }; +} type CategoryRow = { id: bigint; diff --git a/src/app/(site)/search/page.tsx b/src/app/(site)/search/page.tsx index f8328f0f..131fa145 100644 --- a/src/app/(site)/search/page.tsx +++ b/src/app/(site)/search/page.tsx @@ -5,7 +5,15 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { SurfaceCard } from "@/components/surface-card"; 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 }>; diff --git a/src/app/(site)/shop/page.tsx b/src/app/(site)/shop/page.tsx index 6fe79e31..36ab4053 100644 --- a/src/app/(site)/shop/page.tsx +++ b/src/app/(site)/shop/page.tsx @@ -1,4 +1,5 @@ import { asc, eq } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import type { CSSProperties } from "react"; import { buyShopArticle } from "@/actions/shop"; @@ -10,7 +11,18 @@ import { db, WebsiteShopArticles, WebsiteShopCategories } from "@/lib/db"; import { excerpt } from "@/lib/format"; import { creditsPerUnit } from "@/lib/services/paypal"; -export const metadata = { title: "Shop" }; +export async function generateMetadata(): Promise { + 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 { const accent = diff --git a/src/app/(site)/staff/page.tsx b/src/app/(site)/staff/page.tsx index 83838c6c..eba60771 100644 --- a/src/app/(site)/staff/page.tsx +++ b/src/app/(site)/staff/page.tsx @@ -1,11 +1,23 @@ import { asc, desc, eq, gte } from "drizzle-orm"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { db, User, WebsiteTeams } from "@/lib/db"; import { siteSettings } from "@/lib/services/site-settings"; -export const metadata = { title: "Staff" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("pages.staff"); + return { + title: t("title"), + description: t("subtitle"), + openGraph: { + title: t("title"), + description: t("subtitle"), + type: "website", + }, + }; +} type StaffMember = { username: string; diff --git a/src/app/admin/analytics/economy/page.tsx b/src/app/admin/analytics/economy/page.tsx index 901dafe2..d773244b 100644 --- a/src/app/admin/analytics/economy/page.tsx +++ b/src/app/admin/analytics/economy/page.tsx @@ -1,8 +1,8 @@ import { and, count, eq, gte, sql, sum } from "drizzle-orm"; import { ArrowLeftRight } from "lucide-react"; +import dynamic from "next/dynamic"; import { redirect } from "next/navigation"; import { getLocale, getTranslations } from "next-intl/server"; -import { RevenueChart } from "@/components/admin/revenue-chart"; import { StatsCard } from "@/components/admin/stats-card"; import { CurrencyIcon } from "@/components/shared/currency-icon"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -16,6 +16,19 @@ import { import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { redisCache } from "@/lib/redis-cache"; +const RevenueChart = dynamic( + () => + import("@/components/admin/revenue-chart").then((m) => ({ + default: m.RevenueChart, + })), + { + loading: () => ( +
+ ), + ssr: false, + }, +); + type EconomyData = { totalCredits: number; totalPixels: number; diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index e38a7236..6789c935 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -7,16 +7,41 @@ import { TrendingUp, Users, } from "lucide-react"; +import dynamic from "next/dynamic"; import { redirect } from "next/navigation"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { redisCache } from "@/lib/redis-cache"; +const DashboardChart = dynamic( + () => + import("@/components/admin/dashboard-chart").then((m) => ({ + default: m.DashboardChart, + })), + { + loading: () => ( +
+ ), + ssr: false, + }, +); + +const PeakHoursHeatmap = dynamic( + () => + import("@/components/admin/peak-hours-heatmap").then((m) => ({ + default: m.PeakHoursHeatmap, + })), + { + loading: () => ( +
+ ), + ssr: false, + }, +); + type AnalyticsData = { totalUsers: number; onlineUsers: number; diff --git a/src/app/admin/applications/page.tsx b/src/app/admin/applications/page.tsx index 15195315..3b8affa5 100644 --- a/src/app/admin/applications/page.tsx +++ b/src/app/admin/applications/page.tsx @@ -1,13 +1,14 @@ import { desc, inArray } from "drizzle-orm"; import { redirect } from "next/navigation"; 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 Link from "@/components/link"; -import { Button } from "@/components/ui/button"; + import { db, User, WebsiteStaffApplications } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { logServerError } from "@/lib/server-log"; type ApplicationRow = { id: bigint; @@ -31,7 +32,8 @@ export default async function AdminApplications() { .from(WebsiteStaffApplications) .orderBy(desc(WebsiteStaffApplications.createdAt)) .limit(100); - } catch { + } catch (error) { + logServerError("applications.query_failed", error); applications = []; } @@ -45,8 +47,8 @@ export default async function AdminApplications() { .from(User) .where(inArray(User.id, ids)); for (const u of users) userMap.set(u.id, u.username); - } catch { - // no DB — fall back to raw ids + } catch (error) { + logServerError("applications.user_lookup_failed", error); } } @@ -93,12 +95,7 @@ export default async function AdminApplications() {

{a.content}

-
- - -
+ ); })} diff --git a/src/app/admin/catalog/maintenance/page.tsx b/src/app/admin/catalog/maintenance/page.tsx index c5de8160..fc05533b 100644 --- a/src/app/admin/catalog/maintenance/page.tsx +++ b/src/app/admin/catalog/maintenance/page.tsx @@ -1,5 +1,12 @@ +import { redirect } from "next/navigation"; 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 ; } diff --git a/src/app/admin/guilds/[id]/page.tsx b/src/app/admin/guilds/[id]/page.tsx index 595ad271..e28af501 100644 --- a/src/app/admin/guilds/[id]/page.tsx +++ b/src/app/admin/guilds/[id]/page.tsx @@ -1,7 +1,7 @@ import { asc, count, eq, inArray } from "drizzle-orm"; import { notFound, redirect } from "next/navigation"; 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 { Button } from "@/components/ui/button"; import { db, Guilds, GuildsForumsThreads, GuildsMembers, User } from "@/lib/db"; @@ -35,6 +35,10 @@ export default async function AdminGuildDetailPage({ dateCreated: Guilds.dateCreated, state: Guilds.state, forum: Guilds.forum, + readForum: Guilds.readForum, + postMessages: Guilds.postMessages, + postThreads: Guilds.postThreads, + modForum: Guilds.modForum, }) .from(Guilds) .where(eq(Guilds.id, id)) @@ -139,6 +143,142 @@ export default async function AdminGuildDetailPage({

) : null} + {canEdit ? ( +
+

{t("edit")}

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ ) : null} +
{t("colOwner")}
diff --git a/src/app/admin/media/page.tsx b/src/app/admin/media/page.tsx index 0f558853..17819b05 100644 --- a/src/app/admin/media/page.tsx +++ b/src/app/admin/media/page.tsx @@ -1,7 +1,20 @@ +import dynamic from "next/dynamic"; import { redirect } from "next/navigation"; -import { AdminMediaGrid } from "@/components/admin/media-grid"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +const AdminMediaGrid = dynamic( + () => + import("@/components/admin/media-grid").then((m) => ({ + default: m.AdminMediaGrid, + })), + { + loading: () => ( +
+ ), + ssr: false, + }, +); + export default async function AdminMediaPage() { const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) { diff --git a/src/app/admin/moderation/actions/mod-actions-client.tsx b/src/app/admin/moderation/actions/mod-actions-client.tsx index 323425a4..96ac65ba 100644 --- a/src/app/admin/moderation/actions/mod-actions-client.tsx +++ b/src/app/admin/moderation/actions/mod-actions-client.tsx @@ -104,6 +104,7 @@ export function ModActionsClient() { value={userId} onChange={(e) => setUserId(e.target.value)} placeholder="Enter user ID" + aria-label="User ID" />
@@ -144,6 +145,7 @@ export function ModActionsClient() { onChange={(e) => setMuteDuration(e.target.value)} className="w-24" placeholder="Min" + aria-label="Mute duration in minutes" />
-
- - -
+
+
+ + {t("editCategory")} + +
+ + + + + +
+
+
+ + +
+
@@ -141,32 +180,108 @@ export default async function AdminRareValues() { - {rows.map((v) => ( - - - - - - - - - - ))} + {rows.map((v) => { + const vId = String(v.id); + return ( + + + + + + + + + + ); + })}
{v.name} - {v.itemId ?? "—"} - {v.creditValue ?? "—"}{v.currencyValue ?? "—"} - - {v.currencyType} - - - {v.furnitureIcon} - -
- - -
-
{v.name} + {v.itemId ?? "—"} + {v.creditValue ?? "—"}{v.currencyValue ?? "—"} + + {v.currencyType} + + + {v.furnitureIcon} + +
+
+ + {t("editValue")} + +
+ + + + + + + + + +
+
+
+ + +
+
+
{rows.length === 0 ? ( diff --git a/src/app/admin/studio/maintenance/page.tsx b/src/app/admin/studio/maintenance/page.tsx index eeaaf587..61bc0540 100644 --- a/src/app/admin/studio/maintenance/page.tsx +++ b/src/app/admin/studio/maintenance/page.tsx @@ -1,6 +1,13 @@ +import { redirect } from "next/navigation"; 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 (
diff --git a/src/app/admin/vouchers/vouchers-client.tsx b/src/app/admin/vouchers/vouchers-client.tsx index b68d94bc..8671e5fd 100644 --- a/src/app/admin/vouchers/vouchers-client.tsx +++ b/src/app/admin/vouchers/vouchers-client.tsx @@ -2,7 +2,11 @@ import { useTranslations } from "next-intl"; 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 { StatusCard } from "@/components/admin/dashboard"; import { CurrencyIcon } from "@/components/shared/currency-icon"; @@ -33,10 +37,15 @@ export function VouchersClient({ const t = useTranslations("pages.admin.vouchers"); const { run, isPending } = useServerAction(); const [pendingDelete, setPendingDelete] = useState(null); + const [editingVoucher, setEditingVoucher] = useState(null); const [code, setCode] = useState(""); const [amount, setAmount] = useState(""); const [maxUses, setMaxUses] = useState("1"); 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) { 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 (
@@ -158,6 +197,79 @@ export function VouchersClient({ )} + {canEdit && editingVoucher && ( +
+

+ {t("edit")} — {editingVoucher.code} +

+
+ + + + +
+
+ + +
+
+ )} +

{t("title")} ({vouchers.length}) @@ -216,14 +328,24 @@ export function VouchersClient({ {canEdit && ( - +
+ + +
)} diff --git a/src/app/api/admin/search/route.ts b/src/app/api/admin/search/route.ts new file mode 100644 index 00000000..6864e0dd --- /dev/null +++ b/src/app/api/admin/search/route.ts @@ -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 } + > = { + 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) }); + }, +); diff --git a/src/app/api/articles/[slug]/route.ts b/src/app/api/articles/[slug]/route.ts index 79158a3d..8b4cb02c 100644 --- a/src/app/api/articles/[slug]/route.ts +++ b/src/app/api/articles/[slug]/route.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { and, eq, or, sql } from "drizzle-orm"; import { apiJson } from "@/lib/api"; import { db, WebsiteArticles } from "@/lib/db"; import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; @@ -30,7 +30,16 @@ export async function GET( updatedAt: WebsiteArticles.updatedAt, }) .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); if (!article) { diff --git a/src/app/api/articles/route.ts b/src/app/api/articles/route.ts index 722dabf0..6d63f5f1 100644 --- a/src/app/api/articles/route.ts +++ b/src/app/api/articles/route.ts @@ -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 { db, WebsiteArticles } from "@/lib/db"; import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; @@ -17,8 +17,18 @@ export async function GET(req: Request) { apiCacheKey(`articles:${page}:${perPage}`), 60, async () => { + const publishedFilter = and( + eq(WebsiteArticles.status, "published"), + or( + sql`${WebsiteArticles.publishAt} IS NULL`, + sql`${WebsiteArticles.publishAt} <= NOW()`, + ), + ); const [totalRows, articles] = await Promise.all([ - db.select({ total: count() }).from(WebsiteArticles), + db + .select({ total: count() }) + .from(WebsiteArticles) + .where(publishedFilter), db .select({ id: WebsiteArticles.id, @@ -29,6 +39,7 @@ export async function GET(req: Request) { createdAt: WebsiteArticles.createdAt, }) .from(WebsiteArticles) + .where(publishedFilter) .orderBy(desc(WebsiteArticles.createdAt)) .limit(take) .offset(skip), diff --git a/src/app/api/home/route.ts b/src/app/api/home/route.ts index 92593e05..1e4319ce 100644 --- a/src/app/api/home/route.ts +++ b/src/app/api/home/route.ts @@ -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 { apiJson } from "@/lib/api"; import { db, User, WebsiteArticles } from "@/lib/db"; @@ -24,6 +24,15 @@ export async function GET(_req: Request) { createdAt: WebsiteArticles.createdAt, }) .from(WebsiteArticles) + .where( + and( + eq(WebsiteArticles.status, "published"), + or( + sql`${WebsiteArticles.publishAt} IS NULL`, + sql`${WebsiteArticles.publishAt} <= NOW()`, + ), + ), + ) .orderBy(desc(WebsiteArticles.createdAt)) .limit(4), db.select({ total: count() }).from(User).where(eq(User.online, "1")), diff --git a/src/app/mod/bans/page.tsx b/src/app/mod/bans/page.tsx index 5b5692db..d7955420 100644 --- a/src/app/mod/bans/page.tsx +++ b/src/app/mod/bans/page.tsx @@ -74,10 +74,10 @@ export default async function ModBansPage() { - - - - + + + + diff --git a/src/app/mod/cfh/page.tsx b/src/app/mod/cfh/page.tsx index 24e5b5c7..4c19ad72 100644 --- a/src/app/mod/cfh/page.tsx +++ b/src/app/mod/cfh/page.tsx @@ -15,6 +15,7 @@ import { requireModPermission } from "@/lib/admin/guard"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { db, SupportTickets, User } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; +import { logServerError } from "@/lib/server-log"; const SORT_COLS = { id: SupportTickets.id, @@ -55,7 +56,8 @@ export default async function ModCfhListPage({ .where(like(User.username, `%${q}%`)) .limit(50); userIds = users.map((u) => u.id); - } catch { + } catch (error) { + logServerError("cfh.user_search_failed", error, { query: q }); userIds = []; } @@ -91,7 +93,10 @@ export default async function ModCfhListPage({ .from(SupportTickets) .where(where) .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 tickets = await db @@ -101,7 +106,10 @@ export default async function ModCfhListPage({ .orderBy(finalOrder) .offset(pagination.offset) .limit(pagination.perPage) - .catch(() => []); + .catch((error) => { + logServerError("cfh.query_failed", error); + return []; + }); const userIds = [ ...new Set([ diff --git a/src/app/mod/users/page.tsx b/src/app/mod/users/page.tsx index e3bcda42..7e37a595 100644 --- a/src/app/mod/users/page.tsx +++ b/src/app/mod/users/page.tsx @@ -8,6 +8,7 @@ import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { db, User } from "@/lib/db"; import { getAvatarUrl } from "@/lib/imager"; import { PERMS } from "@/lib/permissions"; +import { logServerError } from "@/lib/server-log"; import type { DataTableColumn } from "@/types/common"; type ModUserRow = { @@ -139,7 +140,10 @@ export default async function ModUsersPage({ .from(User) .where(where) .then((rows) => rows[0]?.total ?? 0) - .catch(() => 0), + .catch((error) => { + logServerError("users.count_failed", error); + return 0; + }), db .select({ id: User.id, @@ -155,7 +159,10 @@ export default async function ModUsersPage({ .orderBy(finalOrder) .offset((parsed.page - 1) * parsed.perPage) .limit(parsed.perPage) - .catch(() => [] as ModUserRow[]), + .catch((error) => { + logServerError("users.query_failed", error); + return [] as ModUserRow[]; + }), ]); const total = totals; diff --git a/src/components/admin/admin-topbar.tsx b/src/components/admin/admin-topbar.tsx index 93c4b37c..c08135f6 100644 --- a/src/components/admin/admin-topbar.tsx +++ b/src/components/admin/admin-topbar.tsx @@ -2,6 +2,7 @@ import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; +import { SearchDialog } from "@/components/admin/search-dialog"; import { LanguageSwitcher } from "@/components/language-switcher"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { findAdminHub } from "@/lib/admin-nav"; @@ -29,6 +30,7 @@ export function AdminTopbar({
+
diff --git a/src/components/admin/application-actions.tsx b/src/components/admin/application-actions.tsx new file mode 100644 index 00000000..5f69f536 --- /dev/null +++ b/src/components/admin/application-actions.tsx @@ -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 ( +
+ {confirmDialog} + + +
+ ); +} diff --git a/src/components/admin/article-form.tsx b/src/components/admin/article-form.tsx index f9f2ad27..4ccaaaec 100644 --- a/src/components/admin/article-form.tsx +++ b/src/components/admin/article-form.tsx @@ -18,6 +18,8 @@ export function ArticleForm({ image?: string; shortStory?: string; fullStory?: string; + status?: string; + publishAt?: string; }; }) { const t = useTranslations("pages.admin.articles.form"); @@ -26,6 +28,7 @@ export function ArticleForm({ const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug)); const [image, setImage] = useState(defaultValues?.image ?? ""); const [imageError, setImageError] = useState(false); + const [status, setStatus] = useState(defaultValues?.status ?? "published"); const suggestedSlug = useMemo(() => slugify(title), [title]); @@ -132,6 +135,39 @@ export function ArticleForm({ />
+
+ + + {status === "scheduled" ? ( + + ) : null} +
+ diff --git a/src/components/admin/catalog-manager/breadcrumb-bar.tsx b/src/components/admin/catalog-manager/breadcrumb-bar.tsx index e2f44817..cbdc8bb0 100644 --- a/src/components/admin/catalog-manager/breadcrumb-bar.tsx +++ b/src/components/admin/catalog-manager/breadcrumb-bar.tsx @@ -24,7 +24,9 @@ export function BreadcrumbBar() { dispatch({ type: "SET_ANCESTORS", ancestors: d.ancestors ?? [] }); } }) - .catch(() => {}); + .catch((error) => + console.error("[Breadcrumb] Failed to fetch ancestors:", error), + ); return () => ac.abort(); }, [selectedPageId, dispatch, catQs]); diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index b46ba78e..b1283666 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -667,7 +667,9 @@ export function SortableTree({ ids: pages.map((p) => p.id), }); }) - .catch(() => {}); + .catch((error) => + console.error("[SortableTree] Failed to load children:", error), + ); } } }, [activeTabId, childrenMap, nodes, dispatch, loadChildren]); diff --git a/src/components/admin/catalog/quick-add-furni.tsx b/src/components/admin/catalog/quick-add-furni.tsx index 47f2aa18..77a83a63 100644 --- a/src/components/admin/catalog/quick-add-furni.tsx +++ b/src/components/admin/catalog/quick-add-furni.tsx @@ -81,7 +81,7 @@ export function QuickAddFurni({ ) .then((r) => r.json()) .then((data) => setResults(data.results ?? [])) - .catch(() => {}) + .catch((error) => console.error("[QuickAdd] Furni search failed:", error)) .finally(() => setLoading(false)); return () => ac.abort(); }, [debounced, open]); diff --git a/src/components/admin/media-grid.tsx b/src/components/admin/media-grid.tsx index eb0207cb..ddb52413 100644 --- a/src/components/admin/media-grid.tsx +++ b/src/components/admin/media-grid.tsx @@ -101,7 +101,7 @@ export function AdminMediaGrid() { setCopied(url); setTimeout(() => setCopied(null), 1500); }) - .catch(() => {}); + .catch((error) => console.error("[Media] Clipboard copy failed:", error)); } async function remove(name: string) { diff --git a/src/components/admin/search-dialog.tsx b/src/components/admin/search-dialog.tsx new file mode 100644 index 00000000..7e173d07 --- /dev/null +++ b/src/components/admin/search-dialog.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const debouncedQuery = useDebounce(query, 250); + const abortRef = useRef(null); + + const groupLabels: Record = { + 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 ( + + + + + + + {loading && query.length >= 2 ? ( +
+ … +
+ ) : null} + {t("noResults")} + {Object.entries(grouped).map(([group, items]) => ( + + {items.map((item) => ( + handleSelect(item.url)} + className="flex flex-col items-start gap-0.5 py-2" + > + {item.title} + {item.subtitle ? ( + + {item.subtitle} + + ) : null} + + ))} + + ))} +
+
+
+
+ ); +} + +function groupResults(results: SearchResult[]) { + const grouped: Record = {}; + for (const r of results) { + if (!grouped[r.type]) grouped[r.type] = []; + grouped[r.type].push(r); + } + return grouped; +} diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx index 9231e5cc..cccad1eb 100644 --- a/src/components/mobile-nav.tsx +++ b/src/components/mobile-nav.tsx @@ -2,7 +2,13 @@ import { AnimatePresence, motion } from "motion/react"; 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"; interface MobileNavProps { @@ -20,6 +26,57 @@ export function MobileNav({ }: MobileNavProps) { const [open, setOpen] = useState(false); const detailsRef = useRef(null); + const menuRef = useRef(null); + const MENU_ID = "mobile-nav-menu"; + + const handleEscape = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape" && open) { + setOpen(false); + detailsRef.current?.querySelector("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(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 (
@@ -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)]" aria-label={open ? closeLabel : menuLabel} aria-expanded={open} + aria-haspopup="true" + aria-controls={MENU_ID} > {open && (
{ if (data !== null) setConfig(parseConfig(data)); }) - .catch(() => {}); + .catch((error) => console.error("[Radio] Config fetch failed:", error)); }, []); // Keep the
{t("colUserId")}{t("colType")}{t("colReason")}{t("colExpires")}{t("colUserId")}{t("colType")}{t("colReason")}{t("colExpires")}