Compare commits

...

10 Commits

42 changed files with 2136 additions and 900 deletions

View File

@ -27,10 +27,11 @@
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@sentry/nextjs": "^9.29.0",
"@sentry/nextjs": "^9.30.0",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.0",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.80.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.510.0",
@ -39,26 +40,27 @@
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.57.0",
"react-hook-form": "^7.58.1",
"require-in-the-middle": "^7.5.2",
"sonner": "^2.0.5",
"vaul": "^1.1.2",
"zod": "^3.25.64"
"zod": "^3.25.67"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.10",
"@tanstack/eslint-plugin-query": "^5.78.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^20.19.0",
"@types/node": "^20.19.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"eslint": "^9.29.0",
"eslint-config-next": "^15.3.3",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.1",
"eslint-plugin-prettier": "^5.5.0",
"import-in-the-middle": "^1.14.2",
"postcss": "^8.5.5",
"postcss": "^8.5.6",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.12",
"tailwind-merge": "^3.3.1",
@ -66,7 +68,7 @@
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"typescript-eslint": "^8.34.0"
"typescript-eslint": "^8.34.1"
},
"ct3aMetadata": {
"initVersion": "7.39.3"

408
pnpm-lock.yaml generated
View File

@ -10,7 +10,7 @@ importers:
dependencies:
'@hookform/resolvers':
specifier: ^5.1.1
version: 5.1.1(react-hook-form@7.57.0(react@19.1.0))
version: 5.1.1(react-hook-form@7.58.1(react@19.1.0))
'@radix-ui/react-avatar':
specifier: ^1.1.10
version: 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -39,8 +39,8 @@ importers:
specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.8)(react@19.1.0)
'@sentry/nextjs':
specifier: ^9.29.0
version: 9.29.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.9)
specifier: ^9.30.0
version: 9.30.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.9)
'@supabase/ssr':
specifier: ^0.6.1
version: 0.6.1(@supabase/supabase-js@2.50.0)
@ -49,7 +49,10 @@ importers:
version: 2.50.0
'@t3-oss/env-nextjs':
specifier: ^0.12.0
version: 0.12.0(typescript@5.8.3)(zod@3.25.64)
version: 0.12.0(typescript@5.8.3)(zod@3.25.67)
'@tanstack/react-query':
specifier: ^5.80.7
version: 5.80.7(react@19.1.0)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -75,8 +78,8 @@ importers:
specifier: ^19.1.0
version: 19.1.0(react@19.1.0)
react-hook-form:
specifier: ^7.57.0
version: 7.57.0(react@19.1.0)
specifier: ^7.58.1
version: 7.58.1(react@19.1.0)
require-in-the-middle:
specifier: ^7.5.2
version: 7.5.2
@ -87,8 +90,8 @@ importers:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
zod:
specifier: ^3.25.64
version: 3.25.64
specifier: ^3.25.67
version: 3.25.67
devDependencies:
'@eslint/eslintrc':
specifier: ^3.3.1
@ -96,6 +99,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.1.10
version: 4.1.10
'@tanstack/eslint-plugin-query':
specifier: ^5.78.0
version: 5.78.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
@ -103,8 +109,8 @@ importers:
specifier: ^5.0.3
version: 5.0.3
'@types/node':
specifier: ^20.19.0
version: 20.19.0
specifier: ^20.19.1
version: 20.19.1
'@types/react':
specifier: ^19.1.8
version: 19.1.8
@ -121,14 +127,14 @@ importers:
specifier: ^10.1.5
version: 10.1.5(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-prettier:
specifier: ^5.4.1
version: 5.4.1(@types/eslint@9.6.1)(eslint-config-prettier@10.1.5(eslint@9.29.0(jiti@2.4.2)))(eslint@9.29.0(jiti@2.4.2))(prettier@3.5.3)
specifier: ^5.5.0
version: 5.5.0(@types/eslint@9.6.1)(eslint-config-prettier@10.1.5(eslint@9.29.0(jiti@2.4.2)))(eslint@9.29.0(jiti@2.4.2))(prettier@3.5.3)
import-in-the-middle:
specifier: ^1.14.2
version: 1.14.2
postcss:
specifier: ^8.5.5
version: 8.5.5
specifier: ^8.5.6
version: 8.5.6
prettier:
specifier: ^3.5.3
version: 3.5.3
@ -151,8 +157,8 @@ importers:
specifier: ^5.8.3
version: 5.8.3
typescript-eslint:
specifier: ^8.34.0
version: 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
specifier: ^8.34.1
version: 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
packages:
@ -1116,8 +1122,8 @@ packages:
rollup:
optional: true
'@rollup/pluginutils@5.1.4':
resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==}
'@rollup/pluginutils@5.2.0':
resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
@ -1226,28 +1232,28 @@ packages:
'@rushstack/eslint-patch@1.11.0':
resolution: {integrity: sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==}
'@sentry-internal/browser-utils@9.29.0':
resolution: {integrity: sha512-Wp6UJCDVV2KVK+TG8GwdLZyDy4GtUYDmVhGMpHKPS3G/Qgpf36cY/XHwChwaHZ5P9Bk1sjS9Ok698J59S8L2nw==}
'@sentry-internal/browser-utils@9.30.0':
resolution: {integrity: sha512-e6ZlN8oWheCB0YJSGlBNUlh6UPnY5Ecj1P+/cgeKBhNm7c3bIx4J50485hB8LQsu+b7Q11L2o/wucZ//Pb6FCg==}
engines: {node: '>=18'}
'@sentry-internal/feedback@9.29.0':
resolution: {integrity: sha512-ADvetGrtr+RfYcQKrQxah4fHs/xDJ/VjbStVMSuaNllzwWPYNkWIGFE6YjQ7wZszj0DQIu5/H+B6lZKsFYk4xw==}
'@sentry-internal/feedback@9.30.0':
resolution: {integrity: sha512-qAZ7xxLqZM7GlEvmSUmTHnoueg+fc7esMQD4vH8pS7HI3n9C5MjGn3HHlndRpD8lL7iUUQ0TPZQgU6McbzMDyw==}
engines: {node: '>=18'}
'@sentry-internal/replay-canvas@9.29.0':
resolution: {integrity: sha512-TrQYhSAVPhyenvu0fNkon7BznFibu1mzS5bCudxhgOWajZluUVrXcbp8Q3WZ3R+AogrcgA3Vy6aumP/+fMKdwg==}
'@sentry-internal/replay-canvas@9.30.0':
resolution: {integrity: sha512-I4MxS27rfV7vnOU29L80y4baZ4I1XqpnYvC/yLN7C17nA8eDCufQ8WVomli41y8JETnfcxlm68z7CS0sO4RCSA==}
engines: {node: '>=18'}
'@sentry-internal/replay@9.29.0':
resolution: {integrity: sha512-we/1JPRje8sNowQCyogOV1OYWuDOP/3XmDi48XoFG2HB0XMl2HfL5LI8AvgAvC/5nrqVAAo4ktbjoVLm1fb7rg==}
'@sentry-internal/replay@9.30.0':
resolution: {integrity: sha512-+6wkqQGLJuFUzvGRzbh3iIhFGyxQx/Oxc0ODDKmz9ag2xYRjCYb3UUQXmQX9navAF0HXUsq8ajoJPm2L1ZyWVg==}
engines: {node: '>=18'}
'@sentry/babel-plugin-component-annotate@3.5.0':
resolution: {integrity: sha512-s2go8w03CDHbF9luFGtBHKJp4cSpsQzNVqgIa9Pfa4wnjipvrK6CxVT4icpLA3YO6kg5u622Yoa5GF3cJdippw==}
engines: {node: '>= 14'}
'@sentry/browser@9.29.0':
resolution: {integrity: sha512-+GFX/yb+rh6V1fSgTYM6ttAgledl2aUR3T3Rg86HNuegbdX8ym6lOtUOIZ0j9jPK015HR47KIPyIZVZZJ7Rj9g==}
'@sentry/browser@9.30.0':
resolution: {integrity: sha512-sRyW6A9nIieTTI26MYXk1DmWEhmphTjZevusNWla+vvUigCmSjuH+xZw19w43OyvF3bu261Skypnm/mAalOTwg==}
engines: {node: '>=18'}
'@sentry/bundler-plugin-core@3.5.0':
@ -1300,22 +1306,22 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@sentry/core@9.29.0':
resolution: {integrity: sha512-wDyNe45PM+RCGtUn1tK7LzJ08ksv8i8KRUHrst7lsinEfRm83YH+wbWrPmwkVNEngUZvYkHwGLbNXM7xgFUuDQ==}
'@sentry/core@9.30.0':
resolution: {integrity: sha512-JfEpeQ8a1qVJEb9DxpFTFy1J1gkNdlgKAPiqYGNnm4yQbnfl2Kb/iEo1if70FkiHc52H8fJwISEF90pzMm6lPg==}
engines: {node: '>=18'}
'@sentry/nextjs@9.29.0':
resolution: {integrity: sha512-chMSvo/CWsUw3bkGnURiOejW2hI95sofvFQQL2W98KGRhznfkfXhIh6U60fDpO2KaAbXDbbUCgcvBALdmILu9g==}
'@sentry/nextjs@9.30.0':
resolution: {integrity: sha512-9Ouf0Tng1HAPeYPaT9xUSj6jt/qV+h9/6Vf2yzIEGR4j1FbP5wdccGMs8LRdkp9msqEwv2ERnU////zHL1fpyA==}
engines: {node: '>=18'}
peerDependencies:
next: ^13.2.0 || ^14.0 || ^15.0.0-rc.0
'@sentry/node@9.29.0':
resolution: {integrity: sha512-oABipgC/fClRuvyMeK43rigv9F+OAaoR84UaMKB7aPXN6iz634wBRVsaoZAwiR3xLL+R7MafEPPA/s9XqlG7ag==}
'@sentry/node@9.30.0':
resolution: {integrity: sha512-jHuSSKro2DUaccGcYSBbB8Rj0sG+LRh1iSWrJ+4c4Pj7tJFN9MbeMybC1buMSzAp+rwHUMZ3+ws0kgNVtsRJJg==}
engines: {node: '>=18'}
'@sentry/opentelemetry@9.29.0':
resolution: {integrity: sha512-QTUmre8i5+832RjzQW+g8IQ3UmBe5fbQXGbCF5hQ0UNuHle9r3Z8UZcIff5W8tm5AXMxPqvptTnDEZUUXHgBiA==}
'@sentry/opentelemetry@9.30.0':
resolution: {integrity: sha512-LhTmyGGLAP/LAxs/oXuPs0LC5Z80QSLL1oUoBRB5/+MitK7Huug6n8ZFjPTP3/6bT67XOVqILCdj8BwMlBeXhA==}
engines: {node: '>=18'}
peerDependencies:
'@opentelemetry/api': ^1.9.0
@ -1325,14 +1331,14 @@ packages:
'@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.0.0
'@opentelemetry/semantic-conventions': ^1.34.0
'@sentry/react@9.29.0':
resolution: {integrity: sha512-Wb8oKkIr/1yZ0GRz1UH4CRcjIU48iQLSDLXFKZ8YwPpPdC5qq7l4ALraxlcdB+uWq0JIgEjN5FSLamdt/NHX/w==}
'@sentry/react@9.30.0':
resolution: {integrity: sha512-asA49AkZ/g9CCeW0eA0Ent0DF60S4k2IHxbu+Q1mqgbRRmbn859oL2Bgsu/EvzWf5edeQtuUml8LIo4YoFwfMA==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
'@sentry/vercel-edge@9.29.0':
resolution: {integrity: sha512-Op31XnkkLwWImIXPVpX2ErEPGmXaWt5YvVxiikyCgH0OzfWUnBKXDl9lcPC2Kn02JCQOop8o9tmQUyOagfJrog==}
'@sentry/vercel-edge@9.30.0':
resolution: {integrity: sha512-zhxXEXQbf1ggyyR6pf/ZED8cj5Ubb2iObnpZaGHNWRccsToq7EecW0LKUJjdWKqDSqlv1DaI5yUAmn4oheQ4zQ==}
engines: {node: '>=18'}
'@sentry/webpack-plugin@3.5.0':
@ -1493,6 +1499,19 @@ packages:
'@tailwindcss/postcss@4.1.10':
resolution: {integrity: sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==}
'@tanstack/eslint-plugin-query@5.78.0':
resolution: {integrity: sha512-hYkhWr3UP0CkAsn/phBVR98UQawbw8CmTSgWtdgEBUjI60/GBaEIkpgi/Bp/2I8eIDK4+vdY7ac6jZx+GR+hEQ==}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
'@tanstack/query-core@5.80.7':
resolution: {integrity: sha512-s09l5zeUKC8q7DCCCIkVSns8zZrK4ZDT6ryEjxNBFi68G4z2EBobBS7rdOY3r6W1WbUDpc1fe5oY+YO/+2UVUg==}
'@tanstack/react-query@5.80.7':
resolution: {integrity: sha512-u2F0VK6+anItoEvB3+rfvTO9GEh2vb00Je05OwlUe/A0lkJBgW1HckiY3f9YZa+jx6IOe4dHPh10dyp9aY3iRQ==}
peerDependencies:
react: ^18 || ^19
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
@ -1538,8 +1557,8 @@ packages:
'@types/mysql@2.15.26':
resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==}
'@types/node@20.19.0':
resolution: {integrity: sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q==}
'@types/node@20.19.1':
resolution: {integrity: sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==}
'@types/pg-pool@2.0.6':
resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==}
@ -1579,63 +1598,63 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.34.0':
resolution: {integrity: sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w==}
'@typescript-eslint/eslint-plugin@8.34.1':
resolution: {integrity: sha512-STXcN6ebF6li4PxwNeFnqF8/2BNDvBupf2OPx2yWNzr6mKNGF7q49VM00Pz5FaomJyqvbXpY6PhO+T9w139YEQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.34.0
'@typescript-eslint/parser': ^8.34.1
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0'
'@typescript-eslint/parser@8.34.0':
resolution: {integrity: sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==}
'@typescript-eslint/parser@8.34.1':
resolution: {integrity: sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0'
'@typescript-eslint/project-service@8.34.0':
resolution: {integrity: sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw==}
'@typescript-eslint/project-service@8.34.1':
resolution: {integrity: sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <5.9.0'
'@typescript-eslint/scope-manager@8.34.0':
resolution: {integrity: sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw==}
'@typescript-eslint/scope-manager@8.34.1':
resolution: {integrity: sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.34.0':
resolution: {integrity: sha512-+W9VYHKFIzA5cBeooqQxqNriAP0QeQ7xTiDuIOr71hzgffm3EL2hxwWBIIj4GuofIbKxGNarpKqIq6Q6YrShOA==}
'@typescript-eslint/tsconfig-utils@8.34.1':
resolution: {integrity: sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <5.9.0'
'@typescript-eslint/type-utils@8.34.0':
resolution: {integrity: sha512-n7zSmOcUVhcRYC75W2pnPpbO1iwhJY3NLoHEtbJwJSNlVAZuwqu05zY3f3s2SDWWDSo9FdN5szqc73DCtDObAg==}
'@typescript-eslint/type-utils@8.34.1':
resolution: {integrity: sha512-Tv7tCCr6e5m8hP4+xFugcrwTOucB8lshffJ6zf1mF1TbU67R+ntCc6DzLNKM+s/uzDyv8gLq7tufaAhIBYeV8g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0'
'@typescript-eslint/types@8.34.0':
resolution: {integrity: sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA==}
'@typescript-eslint/types@8.34.1':
resolution: {integrity: sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.34.0':
resolution: {integrity: sha512-rOi4KZxI7E0+BMqG7emPSK1bB4RICCpF7QD3KCLXn9ZvWoESsOMlHyZPAHyG04ujVplPaHbmEvs34m+wjgtVtg==}
'@typescript-eslint/typescript-estree@8.34.1':
resolution: {integrity: sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <5.9.0'
'@typescript-eslint/utils@8.34.0':
resolution: {integrity: sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ==}
'@typescript-eslint/utils@8.34.1':
resolution: {integrity: sha512-mqOwUdZ3KjtGk7xJJnLbHxTuWVn3GO2WZZuM+Slhkun4+qthLdXx32C8xIXbO1kfCECb3jIs3eoxK3eryk7aoQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0'
'@typescript-eslint/visitor-keys@8.34.0':
resolution: {integrity: sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==}
'@typescript-eslint/visitor-keys@8.34.1':
resolution: {integrity: sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@unrs/resolver-binding-android-arm-eabi@1.9.0':
@ -2072,8 +2091,8 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
electron-to-chromium@1.5.167:
resolution: {integrity: sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==}
electron-to-chromium@1.5.170:
resolution: {integrity: sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@ -2193,8 +2212,8 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
eslint-plugin-prettier@5.4.1:
resolution: {integrity: sha512-9dF+KuU/Ilkq27A8idRP7N2DH8iUR6qXcjF3FR2wETY21PZdBrIjwCau8oboyGj9b7etWmTGEeM8e7oOed6ZWg==}
eslint-plugin-prettier@5.5.0:
resolution: {integrity: sha512-8qsOYwkkGrahrgoUv76NZi23koqXOGiiEzXMrT8Q7VcYaUISR+5MorIUxfWqYXN0fN/31WbSrxCxFkVQ43wwrA==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
'@types/eslint': '>=8.0.0'
@ -2976,8 +2995,8 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.5:
resolution: {integrity: sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
@ -3089,8 +3108,8 @@ packages:
peerDependencies:
react: ^19.1.0
react-hook-form@7.57.0:
resolution: {integrity: sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==}
react-hook-form@7.58.1:
resolution: {integrity: sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
@ -3391,8 +3410,8 @@ packages:
uglify-js:
optional: true
terser@5.42.0:
resolution: {integrity: sha512-UYCvU9YQW2f/Vwl+P0GfhxJxbUGLwd+5QrrGgLajzWAtC/23AX0vcise32kkP7Eu0Wu9VlzzHAXkLObgjQfFlQ==}
terser@5.43.0:
resolution: {integrity: sha512-CqNNxKSGKSZCunSvwKLTs8u8sGGlp27sxNZ4quGh0QeNuyHM0JSEM/clM9Mf4zUp6J+tO2gUXhgXT2YMMkwfKQ==}
engines: {node: '>=10'}
hasBin: true
@ -3446,8 +3465,8 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typescript-eslint@8.34.0:
resolution: {integrity: sha512-MRpfN7uYjTrTGigFCt8sRyNqJFhjN0WwZecldaqhWm+wy0gaRt8Edb/3cuUy0zdq2opJWT6iXINKAtewnDOltQ==}
typescript-eslint@8.34.1:
resolution: {integrity: sha512-XjS+b6Vg9oT1BaIUfkW3M3LvqZE++rbzAMEHuccCfO/YkP43ha6w3jTEMilQxMF92nVOYCcdjv1ZUhAa1D/0ow==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
@ -3594,8 +3613,8 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod@3.25.64:
resolution: {integrity: sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g==}
zod@3.25.67:
resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==}
snapshots:
@ -3785,10 +3804,10 @@ snapshots:
'@floating-ui/utils@0.2.9': {}
'@hookform/resolvers@5.1.1(react-hook-form@7.57.0(react@19.1.0))':
'@hookform/resolvers@5.1.1(react-hook-form@7.58.1(react@19.1.0))':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.57.0(react@19.1.0)
react-hook-form: 7.58.1(react@19.1.0)
'@humanfs/core@0.19.1': {}
@ -4565,7 +4584,7 @@ snapshots:
'@rollup/plugin-commonjs@28.0.1(rollup@4.35.0)':
dependencies:
'@rollup/pluginutils': 5.1.4(rollup@4.35.0)
'@rollup/pluginutils': 5.2.0(rollup@4.35.0)
commondir: 1.0.1
estree-walker: 2.0.2
fdir: 6.4.6(picomatch@4.0.2)
@ -4575,7 +4594,7 @@ snapshots:
optionalDependencies:
rollup: 4.35.0
'@rollup/pluginutils@5.1.4(rollup@4.35.0)':
'@rollup/pluginutils@5.2.0(rollup@4.35.0)':
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
@ -4644,33 +4663,33 @@ snapshots:
'@rushstack/eslint-patch@1.11.0': {}
'@sentry-internal/browser-utils@9.29.0':
'@sentry-internal/browser-utils@9.30.0':
dependencies:
'@sentry/core': 9.29.0
'@sentry/core': 9.30.0
'@sentry-internal/feedback@9.29.0':
'@sentry-internal/feedback@9.30.0':
dependencies:
'@sentry/core': 9.29.0
'@sentry/core': 9.30.0
'@sentry-internal/replay-canvas@9.29.0':
'@sentry-internal/replay-canvas@9.30.0':
dependencies:
'@sentry-internal/replay': 9.29.0
'@sentry/core': 9.29.0
'@sentry-internal/replay': 9.30.0
'@sentry/core': 9.30.0
'@sentry-internal/replay@9.29.0':
'@sentry-internal/replay@9.30.0':
dependencies:
'@sentry-internal/browser-utils': 9.29.0
'@sentry/core': 9.29.0
'@sentry-internal/browser-utils': 9.30.0
'@sentry/core': 9.30.0
'@sentry/babel-plugin-component-annotate@3.5.0': {}
'@sentry/browser@9.29.0':
'@sentry/browser@9.30.0':
dependencies:
'@sentry-internal/browser-utils': 9.29.0
'@sentry-internal/feedback': 9.29.0
'@sentry-internal/replay': 9.29.0
'@sentry-internal/replay-canvas': 9.29.0
'@sentry/core': 9.29.0
'@sentry-internal/browser-utils': 9.30.0
'@sentry-internal/feedback': 9.30.0
'@sentry-internal/replay': 9.30.0
'@sentry-internal/replay-canvas': 9.30.0
'@sentry/core': 9.30.0
'@sentry/bundler-plugin-core@3.5.0':
dependencies:
@ -4726,19 +4745,19 @@ snapshots:
- encoding
- supports-color
'@sentry/core@9.29.0': {}
'@sentry/core@9.30.0': {}
'@sentry/nextjs@9.29.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.9)':
'@sentry/nextjs@9.30.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.9)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.34.0
'@rollup/plugin-commonjs': 28.0.1(rollup@4.35.0)
'@sentry-internal/browser-utils': 9.29.0
'@sentry/core': 9.29.0
'@sentry/node': 9.29.0
'@sentry/opentelemetry': 9.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.34.0)
'@sentry/react': 9.29.0(react@19.1.0)
'@sentry/vercel-edge': 9.29.0
'@sentry-internal/browser-utils': 9.30.0
'@sentry/core': 9.30.0
'@sentry/node': 9.30.0
'@sentry/opentelemetry': 9.30.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.34.0)
'@sentry/react': 9.30.0(react@19.1.0)
'@sentry/vercel-edge': 9.30.0
'@sentry/webpack-plugin': 3.5.0(webpack@5.99.9)
chalk: 3.0.0
next: 15.3.3(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -4755,7 +4774,7 @@ snapshots:
- supports-color
- webpack
'@sentry/node@9.29.0':
'@sentry/node@9.30.0':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0)
@ -4787,14 +4806,14 @@ snapshots:
'@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.34.0
'@prisma/instrumentation': 6.8.2(@opentelemetry/api@1.9.0)
'@sentry/core': 9.29.0
'@sentry/opentelemetry': 9.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.34.0)
'@sentry/core': 9.30.0
'@sentry/opentelemetry': 9.30.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.34.0)
import-in-the-middle: 1.14.2
minimatch: 9.0.5
transitivePeerDependencies:
- supports-color
'@sentry/opentelemetry@9.29.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.34.0)':
'@sentry/opentelemetry@9.30.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.34.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0)
@ -4802,19 +4821,19 @@ snapshots:
'@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.34.0
'@sentry/core': 9.29.0
'@sentry/core': 9.30.0
'@sentry/react@9.29.0(react@19.1.0)':
'@sentry/react@9.30.0(react@19.1.0)':
dependencies:
'@sentry/browser': 9.29.0
'@sentry/core': 9.29.0
'@sentry/browser': 9.30.0
'@sentry/core': 9.30.0
hoist-non-react-statics: 3.3.2
react: 19.1.0
'@sentry/vercel-edge@9.29.0':
'@sentry/vercel-edge@9.30.0':
dependencies:
'@opentelemetry/api': 1.9.0
'@sentry/core': 9.29.0
'@sentry/core': 9.30.0
'@sentry/webpack-plugin@3.5.0(webpack@5.99.9)':
dependencies:
@ -4881,17 +4900,17 @@ snapshots:
dependencies:
tslib: 2.8.1
'@t3-oss/env-core@0.12.0(typescript@5.8.3)(zod@3.25.64)':
'@t3-oss/env-core@0.12.0(typescript@5.8.3)(zod@3.25.67)':
optionalDependencies:
typescript: 5.8.3
zod: 3.25.64
zod: 3.25.67
'@t3-oss/env-nextjs@0.12.0(typescript@5.8.3)(zod@3.25.64)':
'@t3-oss/env-nextjs@0.12.0(typescript@5.8.3)(zod@3.25.67)':
dependencies:
'@t3-oss/env-core': 0.12.0(typescript@5.8.3)(zod@3.25.64)
'@t3-oss/env-core': 0.12.0(typescript@5.8.3)(zod@3.25.67)
optionalDependencies:
typescript: 5.8.3
zod: 3.25.64
zod: 3.25.67
'@tailwindcss/node@4.1.10':
dependencies:
@ -4962,9 +4981,24 @@ snapshots:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.1.10
'@tailwindcss/oxide': 4.1.10
postcss: 8.5.5
postcss: 8.5.6
tailwindcss: 4.1.10
'@tanstack/eslint-plugin-query@5.78.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)':
dependencies:
'@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.29.0(jiti@2.4.2)
transitivePeerDependencies:
- supports-color
- typescript
'@tanstack/query-core@5.80.7': {}
'@tanstack/react-query@5.80.7(react@19.1.0)':
dependencies:
'@tanstack/query-core': 5.80.7
react: 19.1.0
'@tybys/wasm-util@0.9.0':
dependencies:
tslib: 2.8.1
@ -4973,15 +5007,15 @@ snapshots:
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 20.19.0
'@types/node': 20.19.1
'@types/connect@3.4.38':
dependencies:
'@types/node': 20.19.0
'@types/node': 20.19.1
'@types/cors@2.8.19':
dependencies:
'@types/node': 20.19.0
'@types/node': 20.19.1
'@types/eslint-scope@3.7.7':
dependencies:
@ -4999,7 +5033,7 @@ snapshots:
'@types/express-serve-static-core@5.0.6':
dependencies:
'@types/node': 20.19.0
'@types/node': 20.19.1
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 0.17.5
@ -5020,9 +5054,9 @@ snapshots:
'@types/mysql@2.15.26':
dependencies:
'@types/node': 20.19.0
'@types/node': 20.19.1
'@types/node@20.19.0':
'@types/node@20.19.1':
dependencies:
undici-types: 6.21.0
@ -5032,7 +5066,7 @@ snapshots:
'@types/pg@8.6.1':
dependencies:
'@types/node': 20.19.0
'@types/node': 20.19.1
pg-protocol: 1.10.0
pg-types: 2.2.0
@ -5053,32 +5087,32 @@ snapshots:
'@types/send@0.17.5':
dependencies:
'@types/mime': 1.3.5
'@types/node': 20.19.0
'@types/node': 20.19.1
'@types/serve-static@1.15.8':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 20.19.0
'@types/node': 20.19.1
'@types/send': 0.17.5
'@types/shimmer@1.2.0': {}
'@types/tedious@4.0.14':
dependencies:
'@types/node': 20.19.0
'@types/node': 20.19.1
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.19.0
'@types/node': 20.19.1
'@typescript-eslint/eslint-plugin@8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)':
'@typescript-eslint/eslint-plugin@8.34.1(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/scope-manager': 8.34.0
'@typescript-eslint/type-utils': 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/utils': 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.34.0
'@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/scope-manager': 8.34.1
'@typescript-eslint/type-utils': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.34.1
eslint: 9.29.0(jiti@2.4.2)
graphemer: 1.4.0
ignore: 7.0.5
@ -5088,40 +5122,40 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)':
'@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.34.0
'@typescript-eslint/types': 8.34.0
'@typescript-eslint/typescript-estree': 8.34.0(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.34.0
'@typescript-eslint/scope-manager': 8.34.1
'@typescript-eslint/types': 8.34.1
'@typescript-eslint/typescript-estree': 8.34.1(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.34.1
debug: 4.4.1
eslint: 9.29.0(jiti@2.4.2)
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.34.0(typescript@5.8.3)':
'@typescript-eslint/project-service@8.34.1(typescript@5.8.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.34.0(typescript@5.8.3)
'@typescript-eslint/types': 8.34.0
'@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.8.3)
'@typescript-eslint/types': 8.34.1
debug: 4.4.1
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.34.0':
'@typescript-eslint/scope-manager@8.34.1':
dependencies:
'@typescript-eslint/types': 8.34.0
'@typescript-eslint/visitor-keys': 8.34.0
'@typescript-eslint/types': 8.34.1
'@typescript-eslint/visitor-keys': 8.34.1
'@typescript-eslint/tsconfig-utils@8.34.0(typescript@5.8.3)':
'@typescript-eslint/tsconfig-utils@8.34.1(typescript@5.8.3)':
dependencies:
typescript: 5.8.3
'@typescript-eslint/type-utils@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)':
'@typescript-eslint/type-utils@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)':
dependencies:
'@typescript-eslint/typescript-estree': 8.34.0(typescript@5.8.3)
'@typescript-eslint/utils': 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/typescript-estree': 8.34.1(typescript@5.8.3)
'@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
debug: 4.4.1
eslint: 9.29.0(jiti@2.4.2)
ts-api-utils: 2.1.0(typescript@5.8.3)
@ -5129,14 +5163,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.34.0': {}
'@typescript-eslint/types@8.34.1': {}
'@typescript-eslint/typescript-estree@8.34.0(typescript@5.8.3)':
'@typescript-eslint/typescript-estree@8.34.1(typescript@5.8.3)':
dependencies:
'@typescript-eslint/project-service': 8.34.0(typescript@5.8.3)
'@typescript-eslint/tsconfig-utils': 8.34.0(typescript@5.8.3)
'@typescript-eslint/types': 8.34.0
'@typescript-eslint/visitor-keys': 8.34.0
'@typescript-eslint/project-service': 8.34.1(typescript@5.8.3)
'@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.8.3)
'@typescript-eslint/types': 8.34.1
'@typescript-eslint/visitor-keys': 8.34.1
debug: 4.4.1
fast-glob: 3.3.3
is-glob: 4.0.3
@ -5147,20 +5181,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)':
'@typescript-eslint/utils@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)':
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.4.2))
'@typescript-eslint/scope-manager': 8.34.0
'@typescript-eslint/types': 8.34.0
'@typescript-eslint/typescript-estree': 8.34.0(typescript@5.8.3)
'@typescript-eslint/scope-manager': 8.34.1
'@typescript-eslint/types': 8.34.1
'@typescript-eslint/typescript-estree': 8.34.1(typescript@5.8.3)
eslint: 9.29.0(jiti@2.4.2)
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.34.0':
'@typescript-eslint/visitor-keys@8.34.1':
dependencies:
'@typescript-eslint/types': 8.34.0
'@typescript-eslint/types': 8.34.1
eslint-visitor-keys: 4.2.1
'@unrs/resolver-binding-android-arm-eabi@1.9.0':
@ -5457,7 +5491,7 @@ snapshots:
browserslist@4.25.0:
dependencies:
caniuse-lite: 1.0.30001723
electron-to-chromium: 1.5.167
electron-to-chromium: 1.5.170
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.0)
@ -5618,7 +5652,7 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
electron-to-chromium@1.5.167: {}
electron-to-chromium@1.5.170: {}
emoji-regex@9.2.2: {}
@ -5738,12 +5772,12 @@ snapshots:
dependencies:
'@next/eslint-plugin-next': 15.3.3
'@rushstack/eslint-patch': 1.11.0
'@typescript-eslint/eslint-plugin': 8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/parser': 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/eslint-plugin': 8.34.1(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.29.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-react: 7.37.5(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-react-hooks: 5.2.0(eslint@9.29.0(jiti@2.4.2))
@ -5777,22 +5811,22 @@ snapshots:
tinyglobby: 0.2.14
unrs-resolver: 1.9.0
optionalDependencies:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.4.2))
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.4.2)):
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.4.2)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.29.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.29.0(jiti@2.4.2))
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.4.2)):
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.4.2)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -5803,7 +5837,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.29.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.4.2))
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.4.2))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -5815,7 +5849,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@ -5840,7 +5874,7 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
eslint-plugin-prettier@5.4.1(@types/eslint@9.6.1)(eslint-config-prettier@10.1.5(eslint@9.29.0(jiti@2.4.2)))(eslint@9.29.0(jiti@2.4.2))(prettier@3.5.3):
eslint-plugin-prettier@5.5.0(@types/eslint@9.6.1)(eslint-config-prettier@10.1.5(eslint@9.29.0(jiti@2.4.2)))(eslint@9.29.0(jiti@2.4.2))(prettier@3.5.3):
dependencies:
eslint: 9.29.0(jiti@2.4.2)
prettier: 3.5.3
@ -6292,7 +6326,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 20.19.0
'@types/node': 20.19.1
merge-stream: 2.0.0
supports-color: 8.1.1
@ -6623,7 +6657,7 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.5:
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
@ -6674,7 +6708,7 @@ snapshots:
react: 19.1.0
scheduler: 0.26.0
react-hook-form@7.57.0(react@19.1.0):
react-hook-form@7.58.1(react@19.1.0):
dependencies:
react: 19.1.0
@ -7053,10 +7087,10 @@ snapshots:
jest-worker: 27.5.1
schema-utils: 4.3.2
serialize-javascript: 6.0.2
terser: 5.42.0
terser: 5.43.0
webpack: 5.99.9
terser@5.42.0:
terser@5.43.0:
dependencies:
'@jridgewell/source-map': 0.3.6
acorn: 8.15.0
@ -7128,11 +7162,11 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typescript-eslint@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3):
typescript-eslint@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/parser': 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/utils': 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/eslint-plugin': 8.34.1(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.29.0(jiti@2.4.2)
typescript: 5.8.3
transitivePeerDependencies:
@ -7324,4 +7358,4 @@ snapshots:
yocto-queue@0.1.0: {}
zod@3.25.64: {}
zod@3.25.67: {}

View File

@ -2,15 +2,13 @@ import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Forgot Password'
title: 'Forgot Password',
};
};
const ForgotPasswordLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<div>
{children}
</div>
);
const ForgotPasswordLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default ForgotPasswordLayout;

View File

@ -2,15 +2,13 @@ import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Profile'
title: 'Profile',
};
};
const ProfileLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<div>
{children}
</div>
);
const ProfileLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default ProfileLayout;

View File

@ -2,15 +2,13 @@ import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Sign In'
title: 'Sign In',
};
};
const SignInLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<div>
{children}
</div>
);
const SignInLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default SignInLayout;

View File

@ -2,15 +2,13 @@ import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Sign Up'
title: 'Sign Up',
};
};
const SignUpLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<div>
{children}
</div>
);
const SignUpLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default SignUpLayout;

View File

@ -4,6 +4,7 @@ import { Geist } from 'next/font/google';
import { cn } from '@/lib/utils';
import {
AuthProvider,
QueryProvider,
ThemeProvider,
TVModeProvider,
} from '@/components/context';
@ -389,7 +390,8 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
<body
className={cn('bg-background text-foreground font-sans antialiased')}
className={cn('bg-background text-foreground font-sans antialiased m-10\
leading-relaxed px-10')}
>
<ThemeProvider
attribute='class'
@ -397,6 +399,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
enableSystem
disableTransitionOnChange
>
<QueryProvider>
<AuthProvider>
<PlausibleProvider
domain='techtracker.gbrown.org'
@ -414,6 +417,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
</TVModeProvider>
</PlausibleProvider>
</AuthProvider>
</QueryProvider>
</ThemeProvider>
</body>
</html>

View File

@ -8,22 +8,7 @@ const Home = async () => {
if (!userResponse.success) {
redirect('/sign-in');
} else if (userResponse.data) {
redirect('/status');
redirect('/status/list');
} else return <div />;
};
export default Home;
//'use client';
////import { TechTable } from '@/components/status';
//import { redirect } from 'next/navigation';
//import { useAuth } from '@/components/context';
//const HomePage = () => {
//const { isAuthenticated } = useAuth();
//if (!isAuthenticated) {
//redirect('/sign-in');
//}
//redirect('/profile');
//};
//export default HomePage;

View File

@ -1,16 +0,0 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Status Table'
};
};
const StatusLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<div>
{children}
</div>
);
};
export default StatusLayout;

View File

@ -0,0 +1,18 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Status List',
};
};
const SignInLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return (
<div className=''>
{children}
</div>
);
};
export default SignInLayout;

View File

@ -0,0 +1,18 @@
'use server';
import { StatusList } from '@/components/status';
import { getUser, getRecentUsersWithStatuses } from '@/lib/actions';
import { redirect } from 'next/navigation';
const Status = async () => {
const userRespoonse = await getUser();
if (!userRespoonse.success) {
redirect('/sign-in');
} else {
const response = await getRecentUsersWithStatuses();
if (!response.success) throw new Error(response.error);
const usersWithStatuses = response.data;
return <StatusList initialStatuses={usersWithStatuses} />;
}
};
export default Status;

View File

@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Status Table',
};
};
const SignInLayout = ({
children,
}: Readonly<{ children: React.ReactNode }>) => {
return <div>{children}</div>;
};
export default SignInLayout;

View File

@ -10,7 +10,7 @@ import React, {
} from 'react';
import {
getProfile,
getSignedUrl,
getProfileWithAvatar,
getUser,
updateProfile as updateProfileAction,
} from '@/lib/hooks';
@ -20,7 +20,6 @@ import { toast } from 'sonner';
type AuthContextType = {
user: User | null;
profile: Profile | null;
avatarUrl: string | null;
isLoading: boolean;
isAuthenticated: boolean;
updateProfile: (data: {
@ -36,7 +35,6 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const fetchingRef = useRef(false);
@ -53,32 +51,16 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
}
const userResponse = await getUser();
const profileResponse = await getProfile();
const profileResponse = await getProfileWithAvatar();
if (!userResponse.success || !profileResponse.success) {
setUser(null);
setProfile(null);
setAvatarUrl(null);
return;
}
setUser(userResponse.data);
setProfile(profileResponse.data);
// Get avatar URL if available
if (profileResponse.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: profileResponse.data.avatar_url,
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
} else {
setAvatarUrl(null);
}
} else {
setAvatarUrl(null);
}
} catch (error) {
console.error(
'Auth fetch error: ',
@ -110,7 +92,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(async (event, _session) => {
console.log('Auth state change:', event); // Debug log
if (event === 'SIGNED_IN') {
// Background refresh without loading state
@ -118,7 +99,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
} else if (event === 'SIGNED_OUT') {
setUser(null);
setProfile(null);
setAvatarUrl(null);
setIsLoading(false);
} else if (event === 'TOKEN_REFRESHED') {
// Silent refresh - don't show loading
@ -158,7 +138,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
} else if (event === 'SIGNED_OUT') {
setUser(null);
setProfile(null);
setAvatarUrl(null);
setIsLoading(false);
} else if (event === 'TOKEN_REFRESHED') {
console.log('Token refreshed, updating user data');
@ -184,18 +163,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
throw new Error(result.error ?? 'Failed to update profile');
}
setProfile(result.data);
// If avatar was updated, refresh the avatar URL
if (data.avatar_url && result.data.avatar_url) {
const avatarResponse = await getSignedUrl({
bucket: 'avatars',
url: result.data.avatar_url,
transform: { width: 128, height: 128 },
});
if (avatarResponse.success) {
setAvatarUrl(avatarResponse.data);
}
}
toast.success('Profile updated successfully!');
return { success: true, data: result.data };
} catch (error) {
@ -216,7 +183,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const value = {
user,
profile,
avatarUrl,
isLoading,
isAuthenticated: !!user,
updateProfile,

View File

@ -0,0 +1,90 @@
// src/components/providers/query-provider.tsx
'use client';
import {
QueryClient,
QueryClientProvider,
QueryCache,
MutationCache,
} from '@tanstack/react-query';
import { useState } from 'react';
import { toast } from 'sonner';
// Define error codes for different types of errors
export const enum QueryErrorCodes {
USERS_FETCH_FAILED = 'USERS_FETCH_FAILED',
STATUS_UPDATE_FAILED = 'STATUS_UPDATE_FAILED',
// Add more as needed
}
const queryCacheOnError = (error: unknown, query: any) => {
const errorMessage =
error instanceof Error ? error.message : 'Something went wrong';
switch (query.meta?.errCode) {
case QueryErrorCodes.USERS_FETCH_FAILED:
// Don't show toast for user fetch errors - handle in component
break;
default:
// Only show generic errors for unexpected failures
console.error('Query error:', error);
break;
}
};
const mutationCacheOnError = (
error: unknown,
variables: unknown,
context: unknown,
mutation: any,
) => {
const errorMessage =
error instanceof Error ? error.message : 'Something went wrong';
switch (mutation.meta?.errCode) {
case QueryErrorCodes.STATUS_UPDATE_FAILED:
toast.error(`Failed to update status: ${errorMessage}`);
break;
default:
toast.error(`Operation failed: ${errorMessage}`);
break;
}
};
type QueryProviderProps = {
children: React.ReactNode;
};
export const QueryProvider = ({ children }: QueryProviderProps) => {
const [queryClient] = useState(
() =>
new QueryClient({
queryCache: new QueryCache({
onError: queryCacheOnError,
}),
mutationCache: new MutationCache({
onError: mutationCacheOnError,
}),
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30 seconds
refetchOnWindowFocus: true,
retry: (failureCount, error) => {
// Don't retry on 4xx errors
if (error instanceof Error && error.message.includes('4')) {
return false;
}
return failureCount < 3;
},
},
mutations: {
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};

View File

@ -1,3 +1,4 @@
export { AuthProvider, useAuth } from './Auth';
export { ThemeProvider, ThemeToggle } from './Theme';
export { TVModeProvider, useTVMode, TVToggle } from './TVMode';
export * from './Query';

View File

@ -1,5 +1,5 @@
'use client';
import { signInWithApple } from '@/lib/actions';
import { signInWithApple, getProfile, updateProfile } from '@/lib/actions';
import { StatusMessage, SubmitButton } from '@/components/default';
import { useAuth } from '@/components/context';
import { useRouter } from 'next/navigation';
@ -34,8 +34,25 @@ export const SignInWithApple = ({
const result = await signInWithApple();
if (result?.success && result.data) {
const profileResponse = await getProfile();
if (profileResponse.success) {
const profile = profileResponse.data;
if (!profile.provider) {
const updateResponse = await updateProfile({
provider: result.data.provider,
});
if (!updateResponse.success)
throw new Error('Could not update provider!');
} else {
const updateResponse = await updateProfile({
provider: profile.provider + ' ' + result.data.provider,
});
if (!updateResponse.success)
throw new Error('Could not update provider!');
}
}
// Redirect to Apple OAuth page
window.location.href = result.data;
window.location.href = result.data.url;
} else {
setStatusMessage(`Error signing in with Apple!`);
}

View File

@ -7,6 +7,7 @@ import Image from 'next/image';
import { type buttonVariants } from '@/components/ui';
import { type ComponentProps } from 'react';
import { type VariantProps } from 'class-variance-authority';
import { getProfile, updateProfile } from '@/lib/hooks';
type SignInWithMicrosoftProps = {
className?: ComponentProps<'div'>['className'];
@ -32,8 +33,24 @@ export const SignInWithMicrosoft = ({
const result = await signInWithMicrosoft();
if (result?.success && result.data) {
// Redirect to Microsoft OAuth page
window.location.href = result.data;
const profileResponse = await getProfile();
if (profileResponse.success) {
const profile = profileResponse.data;
if (!profile.provider) {
const updateResponse = await updateProfile({
provider: result.data.provider,
});
if (!updateResponse.success)
throw new Error('Could not update provider!');
} else {
const updateResponse = await updateProfile({
provider: profile.provider + ' ' + result.data.provider,
});
if (!updateResponse.success)
throw new Error('Could not update provider!');
}
}
window.location.href = result.data.url;
} else {
setStatusMessage(`Error: Could not sign in with Microsoft!`);
}

View File

@ -14,7 +14,12 @@ const Footer = () => {
hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F]
flex items-center gap-2 transition-all duration-200'
>
<Image src='/icons/misc/gitea.svg' alt='Gitea' width={20} height={20} />
<Image
src='/icons/misc/gitea.svg'
alt='Gitea'
width={20}
height={20}
/>
<span className='text-white'>View Source Code on Gitea</span>
</Link>
</div>

View File

@ -2,9 +2,7 @@
import Link from 'next/link';
import {
Avatar,
AvatarFallback,
AvatarImage,
BasedAvatar,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@ -15,10 +13,9 @@ import {
import { useAuth, useTVMode } from '@/components/context';
import { useRouter } from 'next/navigation';
import { signOut } from '@/lib/actions';
import { User } from 'lucide-react';
const AvatarDropdown = () => {
const { profile, avatarUrl, refreshUserData } = useAuth();
const { profile, refreshUserData } = useAuth();
const router = useRouter();
const { toggleTVMode, tvMode } = useTVMode();
@ -30,36 +27,16 @@ const AvatarDropdown = () => {
}
};
const getInitials = (name: string | null | undefined): string => {
if (!name) return '';
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase();
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar className='cursor-pointer scale-125'>
{avatarUrl ? (
<AvatarImage
src={avatarUrl}
alt={getInitials(profile?.full_name)}
width={64}
height={64}
<BasedAvatar
src={profile?.avatar_url}
fullName={profile?.full_name}
className='lg:h-12 lg:w-12 my-auto'
fallbackClassName='text-xl font-semibold'
userIconSize={32}
/>
) : (
<AvatarFallback className='text-md'>
{profile?.full_name ? (
getInitials(profile.full_name)
) : (
<User size={64} />
)}
</AvatarFallback>
)}
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel className='font-bold'>

View File

@ -1,5 +1,4 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { ThemeToggle, useTVMode } from '@/components/context';
@ -9,27 +8,38 @@ import AvatarDropdown from './AvatarDropdown';
const Header = () => {
const { tvMode } = useTVMode();
const { isAuthenticated } = useAuth();
return tvMode ? (
<div className='w-full py-2 pt-6 md:py-5'>
<div className='absolute top-8 right-24'>
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4'>
{isAuthenticated && <AvatarDropdown />}
</div>
</div>
</div>
) : (
<header className='w-full py-2 pt-6 md:py-5'>
<div className='absolute top-8 right-16'>
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4 md:pr-8'>
// Controls component for both modes
const Controls = () => (
<div className='flex flex-row items-center'>
<ThemeToggle className='mr-4' />
{isAuthenticated && <AvatarDropdown />}
</div>
);
if (tvMode) {
return (
<div className='absolute top-10 right-37'>
<Controls />
</div>
);
}
return (
<header className='w-full mb-8'>
<div className='container mx-auto px-4 md:px-6 lg:px-20'>
<div className='flex items-center justify-between'>
{/* Left spacer for perfect centering */}
<div className='flex flex-1 justify-start'>
<div className='sm:w-[120px] md:w-[160px]' />
</div>
{/* Centered logo and title */}
<div className='flex-shrink-0'>
<Link
href='/'
scroll={false}
className='flex flex-row items-center text-center
justify-center sm:ml-0 p-4 mt-10 sm:mt-0'
className='flex flex-row items-center justify-center px-4'
>
<Image
src='/favicon.png'
@ -38,15 +48,24 @@ const Header = () => {
height={100}
className='max-w-[40px] md:max-w-[120px]'
/>
<h1
className='title-text text-sm md:text-4xl lg:text-8xl
bg-gradient-to-r from-[#281A65] via-[#363354] to-accent-foreground dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]
<h1 className='title-text text-sm md:text-4xl lg:text-8xl
bg-gradient-to-r from-[#281A65] via-[#363354] to-accent-foreground
dark:from-[#bec8e6] dark:via-[#F0EEE4] dark:to-[#FFF8E7]
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
>
Tech Tracker
</h1>
</Link>
</div>
{/* Right-aligned controls */}
<div className='flex-1 flex justify-end'>
<Controls />
</div>
</div>
</div>
</header>
);
};
export default Header;

View File

@ -1,19 +1,14 @@
import { useFileUpload } from '@/lib/hooks/useFileUpload';
import { useAuth } from '@/components/context';
import {
Avatar,
AvatarFallback,
AvatarImage,
CardContent,
} from '@/components/ui';
import { Loader2, Pencil, Upload, User } from 'lucide-react';
import { BasedAvatar, CardContent } from '@/components/ui';
import { Loader2, Pencil, Upload } from 'lucide-react';
type AvatarUploadProps = {
onAvatarUploaded: (path: string) => Promise<void>;
};
export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
const { profile, avatarUrl } = useAuth();
const { profile } = useAuth();
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
const handleAvatarClick = () => {
@ -40,15 +35,6 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
}
};
const getInitials = (name: string | null | undefined): string => {
if (!name) return '';
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase();
};
return (
<CardContent>
<div className='flex flex-col items-center'>
@ -56,24 +42,13 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
className='relative group cursor-pointer mb-4'
onClick={handleAvatarClick}
>
<Avatar className='h-32 w-32'>
{avatarUrl ? (
<AvatarImage
src={avatarUrl}
alt={getInitials(profile?.full_name)}
width={128}
height={128}
<BasedAvatar
src={profile?.avatar_url}
fullName={profile?.full_name}
className='h-32 w-32'
fallbackClassName='text-4xl font-semibold'
userIconSize={100}
/>
) : (
<AvatarFallback className='text-4xl'>
{profile?.full_name ? (
getInitials(profile.full_name)
) : (
<User size={32} />
)}
</AvatarFallback>
)}
</Avatar>
<div
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50
transition-all flex items-center justify-center'

View File

@ -0,0 +1,68 @@
'use client';
import { Wifi, WifiOff, RefreshCw } from 'lucide-react';
import { Badge, Button } from '@/components/ui';
import type { ConnectionStatus as ConnectionStatusType } from '@/lib/hooks';
type ConnectionStatusProps = {
status: ConnectionStatusType;
onReconnect?: () => void;
showAsButton?: boolean;
className?: string;
}
const getConnectionIcon = (status: ConnectionStatusType) => {
switch (status) {
case 'connected':
return <Wifi className='w-4 h-4 text-green-500' />;
case 'connecting':
return <Wifi className='w-4 h-4 text-yellow-500 animate-pulse' />;
case 'disconnected':
return <WifiOff className='w-4 h-4 text-red-500' />;
case 'updating':
return <RefreshCw className='w-4 h-4 text-blue-500 animate-spin' />;
}
};
const getConnectionText = (status: ConnectionStatusType) => {
switch (status) {
case 'connected':
return 'Connected';
case 'connecting':
return 'Connecting...';
case 'disconnected':
return 'Disconnected';
case 'updating':
return 'Updating...';
}
};
export const ConnectionStatus = ({
status,
onReconnect,
showAsButton = false,
className = '',
}: ConnectionStatusProps) => {
if (showAsButton && status === 'disconnected' && onReconnect) {
return (
<Button
variant='outline'
size='sm'
onClick={onReconnect}
className={`flex items-center gap-2 cursor-pointer ${className}`}
>
{getConnectionIcon(status)}
<span className='text-base'>{getConnectionText(status)}</span>
</Button>
);
}
return (
<Badge
variant='outline'
className={`flex items-center gap-2 ${className}`}
>
{getConnectionIcon(status)}
<span className='text-base'>{getConnectionText(status)}</span>
</Badge>
);
};

View File

@ -106,7 +106,7 @@ export const HistoryDrawer: React.FC<HistoryDrawerProps> = ({
className='w-8 h-8 md:w-12 md:h-12'
/>
<h1 className='text-lg md:text-2xl lg:text-4xl font-bold pl-2 md:pl-4'>
{user && user.id !== '' ? 'User History' : 'All History'}
{user && user.id !== '' ? `${user.full_name}'s History` : 'All History'}
</h1>
</div>
{totalCount > 0 && (

View File

@ -0,0 +1,373 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth, useTVMode } from '@/components/context';
import type { UserWithStatus } from '@/lib/hooks';
import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui';
import { StatusMessage, SubmitButton } from '@/components/default';
import { ConnectionStatus, HistoryDrawer } from '@/components/status';
import type { Profile } from '@/utils/supabase';
import { makeConditionalClassName } from '@/lib/utils';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { RefreshCw, Clock, Calendar } from 'lucide-react';
import { useStatusData, useSharedStatusSubscription } from '@/lib/hooks';
import { formatTime, formatDate } from '@/lib/utils';
import Link from 'next/link';
type ListProps = {
initialStatuses: UserWithStatus[]
};
export const StatusList = ({ initialStatuses = [] }: ListProps) => {
const { isAuthenticated } = useAuth();
const { tvMode } = useTVMode();
const [selectedUsers, setSelectedUsers] = useState<UserWithStatus[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState('');
const [selectedHistoryUser, setSelectedHistoryUser] =
useState<Profile | null>(null);
const [updateStatusMessage, setUpdateStatusMessage] = useState('');
const {
data: usersWithStatuses = initialStatuses,
isLoading: loading,
error,
refetch,
newStatuses,
updateStatusMutation,
} = useStatusData({
initialData: initialStatuses,
enabled: isAuthenticated,
});
// In your StatusList component
const { connectionStatus, connect: reconnect } = useSharedStatusSubscription(() => {
refetch().catch((error) => {
console.error('Error refetching statuses:', error);
});
});
//const { connectionStatus, connect: reconnect } = useStatusSubscription({
//enabled: isAuthenticated,
//onStatusUpdate: () => {
//refetch().catch((error) => {
//console.error('Error refetching statuses:', error);
//});
//},
//});
const handleUpdateStatus = () => {
if (!isAuthenticated) {
setUpdateStatusMessage(
'Error: You must be signed in to update technician statuses!'
);
return;
}
if (statusInput.length < 3 || statusInput.length > 80) {
setUpdateStatusMessage(
'Error: Your status must be between 3 & 80 characters long!'
);
return;
}
updateStatusMutation.mutate({
usersWithStatuses: selectedUsers,
status: statusInput.trim(),
});
setSelectedUsers([]);
setStatusInput('');
setUpdateStatusMessage('');
};
const handleCheckboxChange = (user: UserWithStatus) => {
setSelectedUsers((prev) =>
prev.some((u) => u.user.id === user.user.id)
? prev.filter((prevUser) => prevUser.user.id !== user.user.id)
: [...prev, user]
);
};
const handleSelectAllChange = () => {
if (selectAll) {
setSelectedUsers([]);
} else {
setSelectedUsers(usersWithStatuses);
}
setSelectAll(!selectAll);
};
useEffect(() => {
setSelectAll(
selectedUsers.length === usersWithStatuses.length &&
usersWithStatuses.length > 0
);
}, [selectedUsers.length, usersWithStatuses.length]);
if (loading) {
return (
<div className='flex justify-center items-center min-h-[400px]'>
<Loading className='w-full' alpha={0.5} />
</div>
);
}
if (error) {
return (
<div className='flex flex-col justify-center items-center min-h-[400px] gap-4'>
<p className='text-red-500'>Error loading status updates</p>
<Button onClick={() => refetch()} variant='outline'>
<RefreshCw className='w-4 h-4 mr-2' />
Retry
</Button>
</div>
);
}
const containerClassName = makeConditionalClassName({
context: tvMode,
defaultClassName: 'flex flex-col mx-auto space-y-4 items-center',
on: 'lg:w-11/12 w-full mt-15',
off: 'sm:w-5/6 md:3/4 lg:w-1/2',
});
const headerClassName = makeConditionalClassName({
context: tvMode,
defaultClassName: 'w-full',
on: 'hidden',
off: 'flex mb-4 justify-between',
});
const cardContainerClassName = makeConditionalClassName({
context: tvMode,
defaultClassName: '',
on: '',
off: 'space-y-3 items-center justify-center w-full',
});
const cardClassName = makeConditionalClassName({
context: tvMode,
defaultClassName:
'transition-all duration-300 hover:shadow-md hover:bg-muted/50 cursor-pointer',
on: 'lg:text-4xl',
off: 'lg:text-base lg:w-full',
});
return (
<div className={containerClassName}>
<div className={headerClassName}>
<div className='flex items-center gap-10'>
<div className='flex gap-2'>
<Checkbox
id='select-all'
checked={selectAll}
onCheckedChange={handleSelectAllChange}
className='size-6'
/>
<label htmlFor='select-all' className='font-medium'>
Select All
</label>
</div>
{!tvMode && (
<div className='flex flex-row gap-2'>
<p>Miss the old table?</p>
<Link
href='/status/table'
className='italic font-semibold text-accent-foreground'
>
Find it here!
</Link>
</div>
)}
</div>
<div className='flex items-center gap-2'>
<ConnectionStatus
status={connectionStatus}
onReconnect={reconnect}
showAsButton={connectionStatus === 'disconnected'}
/>
</div>
</div>
<div className={cardContainerClassName}>
{usersWithStatuses.map((userWithStatus) => {
const isSelected = selectedUsers.some(
(u) => u.user.id === userWithStatus.user.id
);
const isNewStatus = newStatuses.has(userWithStatus);
const isUpdatedByOther =
userWithStatus.updated_by &&
userWithStatus.updated_by.id !== userWithStatus.user.id;
return (
<Card
key={userWithStatus.user.id}
className={`
${cardClassName}
${isSelected ? 'ring-2 ring-primary' : ''}
${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''}
`}
>
<CardHeader className='pb-3'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
{!tvMode && (
<Checkbox
checked={isSelected}
onCheckedChange={() =>
handleCheckboxChange(userWithStatus)
}
onClick={(e) => e.stopPropagation()}
/>
)}
<BasedAvatar
src={userWithStatus.user.avatar_url}
fullName={userWithStatus.user.full_name}
className={tvMode ? 'w-24 h-24' : 'w-16 h-16'}
/>
<div className='my-auto'>
<h3
className={`font-semibold ${tvMode ? 'text-5xl' : 'text-2xl'}`}
>
{userWithStatus.user.full_name}
</h3>
{isUpdatedByOther && (
<div className='flex items-center gap-1 text-muted-foreground'>
<BasedAvatar
src={userWithStatus.updated_by?.avatar_url}
fullName={userWithStatus.updated_by?.full_name}
className='w-5 h-5'
/>
<span
className={`${tvMode ? 'text-3xl' : 'text-sm'}`}
>
Updated by {userWithStatus.updated_by?.full_name}
</span>
</div>
)}
</div>
</div>
<div className='my-auto'>
<div className='flex items-center gap-2 text-muted-foreground'>
<Clock className={`${tvMode ? 'w-8 h-8' : 'w-6 h-6'}`} />
<span className={`${tvMode ? 'text-3xl' : 'text-xl'}`}>
{formatTime(userWithStatus.created_at)}
</span>
</div>
<div className='flex items-center gap-2 text-muted-foreground'>
<Calendar
className={`${tvMode ? 'w-8 h-8' : 'w-6 h-6'}`}
/>
<span className={`${tvMode ? 'text-3xl' : 'text-xl'}`}>
{formatDate(userWithStatus.created_at)}
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent className='pt-0'>
<Drawer>
<DrawerTrigger asChild>
<div
className={`
p-4 rounded-lg bg-muted/30 hover:bg-muted/50
transition-colors cursor-pointer text-left
${tvMode ? 'text-4xl' : 'text-xl'}
`}
onClick={() =>
setSelectedHistoryUser(userWithStatus.user)
}
>
<p className='font-medium'>{userWithStatus.status}</p>
</div>
</DrawerTrigger>
{selectedHistoryUser === userWithStatus.user && (
<HistoryDrawer user={selectedHistoryUser} />
)}
</Drawer>
</CardContent>
</Card>
);
})}
</div>
{usersWithStatuses.length === 0 && (
<Card className='p-8 text-center'>
<p
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
>
No status updates have been made in the past day.
</p>
</Card>
)}
{!tvMode && (
<Card className='p-6 mt-6'>
<div className='flex flex-col gap-4'>
<h3 className='text-lg font-semibold'>Update Status</h3>
<div className='flex flex-col gap-4'>
<div className='flex gap-4'>
<Input
autoFocus
type='text'
placeholder='Enter status'
className='flex-1 text-base'
value={statusInput}
disabled={updateStatusMutation.isPending}
onChange={(e) => setStatusInput(e.target.value)}
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!updateStatusMutation.isPending
) {
e.preventDefault();
handleUpdateStatus();
}
}}
/>
<SubmitButton
onClick={handleUpdateStatus}
disabled={updateStatusMutation.isPending}
className='px-6'
>
{selectedUsers.length > 0
? `Update status for ${selectedUsers.length}
${selectedUsers.length > 1 ? 'users' : 'user'}`
: 'Update status'
}
</SubmitButton>
</div>
{updateStatusMessage &&
(updateStatusMessage.includes('Error') ||
updateStatusMessage.includes('error') ||
updateStatusMessage.includes('failed') ||
updateStatusMessage.includes('invalid') ? (
<StatusMessage message={{ error: updateStatusMessage }} />
) : (
<StatusMessage message={{ message: updateStatusMessage }} />
))}
</div>
<div className='flex justify-center mt-2'>
<Drawer>
<DrawerTrigger asChild>
<Button
variant='outline'
className={tvMode ? 'text-3xl p-6' : ''}
>
View All Status History
</Button>
</DrawerTrigger>
<HistoryDrawer />
</Drawer>
</div>
</div>
</Card>
)}
</div>
);
};

View File

@ -0,0 +1,377 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth, useTVMode } from '@/components/context';
import type { UserWithStatus } from '@/lib/hooks';
import { BasedAvatar, Drawer, DrawerTrigger, Loading } from '@/components/ui';
import { StatusMessage, SubmitButton } from '@/components/default';
import { ConnectionStatus, HistoryDrawer } from '@/components/status';
import type { Profile } from '@/utils/supabase';
import { makeConditionalClassName } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { RefreshCw, Clock, Calendar } from 'lucide-react';
import { useSharedStatusSubscription, useStatusData } from '@/lib/hooks';
import { formatTime, formatDate } from '@/lib/utils';
import Link from 'next/link';
type TableProps = {
initialStatuses: UserWithStatus[];
};
export const TechTable = ({ initialStatuses = [] }: TableProps) => {
const { isAuthenticated } = useAuth();
const { tvMode } = useTVMode();
const [selectedUsers, setSelectedUsers] = useState<UserWithStatus[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState('');
const [selectedHistoryUser, setSelectedHistoryUser] =
useState<Profile | null>(null);
const [updateStatusMessage, setUpdateStatusMessage] = useState('');
const {
data: usersWithStatuses = initialStatuses,
isLoading: loading,
error,
refetch,
newStatuses,
updateStatusMutation,
} = useStatusData({
initialData: initialStatuses,
enabled: isAuthenticated,
});
// In your StatusList component
const { connectionStatus, connect: reconnect } = useSharedStatusSubscription(() => {
refetch().catch((error) => {
console.error('Error refetching statuses:', error);
});
});
//const { connectionStatus, connect: reconnect } = useStatusSubscription({
//enabled: isAuthenticated,
//onStatusUpdate: () => {
//refetch().catch((error) => {
//console.error('Error refetching statuses:', error);
//});
//},
//});
const handleUpdateStatus = () => {
if (!isAuthenticated) {
setUpdateStatusMessage(
'Error: You must be signed in to update technician statuses!'
);
return;
}
if (statusInput.length < 3 || statusInput.length > 80) {
setUpdateStatusMessage(
'Error: Your status must be between 3 & 80 characters long!'
);
return;
}
updateStatusMutation.mutate({
usersWithStatuses: selectedUsers,
status: statusInput.trim(),
});
setSelectedUsers([]);
setStatusInput('');
setUpdateStatusMessage('');
};
const handleCheckboxChange = (user: UserWithStatus) => {
setSelectedUsers((prev) =>
prev.some((u) => u.user.id === user.user.id)
? prev.filter((prevUser) => prevUser.user.id !== user.user.id)
: [...prev, user]
);
};
const handleSelectAllChange = () => {
if (selectAll) {
setSelectedUsers([]);
} else {
setSelectedUsers(usersWithStatuses);
}
setSelectAll(!selectAll);
};
useEffect(() => {
setSelectAll(
selectedUsers.length === usersWithStatuses.length &&
usersWithStatuses.length > 0
);
}, [selectedUsers.length, usersWithStatuses.length]);
if (loading) {
return (
<div className='flex justify-center items-center min-h-[400px]'>
<Loading className='w-full' alpha={0.5} />
</div>
);
}
if (error) {
return (
<div className='flex flex-col justify-center items-center min-h-[400px] gap-4'>
<p className='text-red-500'>Error loading status updates</p>
<Button onClick={() => refetch()} variant='outline'>
<RefreshCw className='w-4 h-4 mr-2' />
Retry
</Button>
</div>
);
}
const containerClassName = makeConditionalClassName({
context: tvMode,
defaultClassName: 'mx-auto',
on: 'lg:w-11/12 w-full',
off: 'w-5/6',
});
const headerClassName = makeConditionalClassName({
context: tvMode,
defaultClassName: 'w-full mb-2 flex justify-between',
on: 'mt-25',
off: 'mb-2',
});
const thClassName = makeConditionalClassName({
context: tvMode,
defaultClassName: 'py-4 px-4 border font-semibold ',
on: 'lg:text-6xl xl:min-w-[420px]',
off: 'lg:text-5xl xl:min-w-[300px]',
});
const tdClassName = makeConditionalClassName({
context: tvMode,
defaultClassName: 'py-2 px-2 border',
on: 'lg:text-5xl',
off: 'lg:text-4xl',
});
const tCheckboxClassName = `py-3 px-4 border`;
const checkBoxClassName = `lg:scale-200 cursor-pointer`;
return (
<div className={containerClassName}>
<div className={headerClassName}>
<div className='flex items-center gap-2'>
<ConnectionStatus
status={connectionStatus}
onReconnect={reconnect}
showAsButton={connectionStatus === 'disconnected'}
/>
{!tvMode && (
<div className='flex flex-row gap-2'>
<p>Tired of the old table? {' '}</p>
<Link
href='/status/list'
className='italic font-semibold text-accent-foreground'
>
Try out the new status list!
</Link>
</div>
)}
</div>
</div>
<table className='w-full text-center rounded-md'>
<thead>
<tr className='bg-muted'>
{!tvMode && (
<th className={tCheckboxClassName}>
<input
type='checkbox'
className={checkBoxClassName}
checked={selectAll}
onChange={handleSelectAllChange}
/>
</th>
)}
<th className={thClassName}>Technician</th>
<th className={thClassName}>
<Drawer>
<DrawerTrigger className='hover:text-foreground/60 cursor-pointer'>
Status
</DrawerTrigger>
<HistoryDrawer />
</Drawer>
</th>
<th className={thClassName}>Updated At</th>
</tr>
</thead>
<tbody>
{usersWithStatuses.map((userWithStatus, index) => {
const isSelected = selectedUsers.some(
(u) => u.user.id === userWithStatus.user.id,
);
const isNewStatus = newStatuses.has(userWithStatus);
return (
<tr
key={userWithStatus.user.id}
className={`
${index % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
${isSelected ? 'ring-2 ring-primary' : ''}
${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''}
hover:bg-muted/75 transition-all duration-300
`}
>
{!tvMode && (
<td className={tCheckboxClassName}>
<input
type='checkbox'
className={checkBoxClassName}
checked={isSelected}
onChange={() => handleCheckboxChange(userWithStatus)}
/>
</td>
)}
<td className={tdClassName}>
<div className='flex items-center gap-3'>
<BasedAvatar
src={userWithStatus.user.avatar_url}
fullName={userWithStatus.user.full_name}
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
/>
<div>
<p
className={`font-semibold ${tvMode ? 'text-5xl' : 'text-4xl'}`}
>
{userWithStatus.user.full_name ?? 'Unknown User'}
</p>
{userWithStatus.updated_by &&
userWithStatus.updated_by.id !==
userWithStatus.user.id && (
<div className='flex items-center gap-1 text-muted-foreground'>
<BasedAvatar
src={userWithStatus.updated_by?.avatar_url}
fullName={userWithStatus.updated_by?.full_name}
className='w-5 h-5'
/>
<span
className={tvMode ? 'text-lg' : 'text-base'}
>
Updated by {userWithStatus.updated_by.full_name}
</span>
</div>
)}
</div>
</div>
</td>
<td className={tdClassName}>
<Drawer>
<DrawerTrigger
className='text-center w-full p-2 rounded-md hover:bg-muted transition-colors'
onClick={() =>
setSelectedHistoryUser(userWithStatus.user)
}
>
{userWithStatus.status}
</DrawerTrigger>
{selectedHistoryUser === userWithStatus.user && (
<HistoryDrawer user={selectedHistoryUser} />
)}
</Drawer>
</td>
<td className={tdClassName}>
<div className='flex w-full'>
<div className='flex items-start xl:w-1/6'></div>
<div className='flex flex-col my-auto items-start'>
<div className='flex gap-4 my-1'>
<Clock className={`${tvMode ? 'lg:w-11 lg:h-11' : 'lg:w-9 lg:h-9'}`} />
{formatTime(userWithStatus.created_at)}
</div>
<div className='flex gap-4 my-1'>
<Calendar
className={`${tvMode ? 'lg:w-11 lg:h-11' : 'lg:w-9 lg:h-9'}`}
/>
{formatDate(userWithStatus.created_at)}
</div>
</div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{usersWithStatuses.length === 0 && (
<div className='p-8 text-center'>
<p
className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}
>
No status updates yet
</p>
</div>
)}
{updateStatusMessage &&
(updateStatusMessage.includes('Error') ||
updateStatusMessage.includes('error') ||
updateStatusMessage.includes('failed') ||
updateStatusMessage.includes('invalid') ? (
<StatusMessage message={{ error: updateStatusMessage }} />
) : (
<StatusMessage message={{ message: updateStatusMessage }} />
))}
{!tvMode && (
<div className='mx-auto flex flex-row items-center justify-center py-5 gap-4'>
<Input
autoFocus
type='text'
placeholder='New Status'
className={
'min-w-[120px] lg:max-w-[400px] py-6 px-3 rounded-xl \
border bg-background lg:text-2xl focus:outline-none \
focus:ring-2 focus:ring-primary'
}
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !updateStatusMutation.isPending) {
e.preventDefault();
handleUpdateStatus();
}
}}
disabled={updateStatusMutation.isPending}
/>
<SubmitButton
size='xl'
className={
'px-8 rounded-xl font-semibold lg:text-2xl \
disabled:opacity-50 disabled:cursor-not-allowed \
cursor-pointer'
}
onClick={handleUpdateStatus}
disabled={updateStatusMutation.isPending}
pendingText='Updating...'
>
{selectedUsers.length > 0
? `Update status for ${selectedUsers.length}
${selectedUsers.length > 1 ? 'users' : 'user'}`
: 'Update status'
}
</SubmitButton>
</div>
)}
{/* Global Status History Drawer */}
{!tvMode && (
<div className='flex justify-center mt-6'>
<Drawer>
<DrawerTrigger asChild>
<Button variant='outline' className={tvMode ? 'text-3xl p-6' : ''}>
View All Status History
</Button>
</DrawerTrigger>
<HistoryDrawer />
</Drawer>
</div>
)}
</div>
);
};

View File

@ -1,324 +0,0 @@
'use client';
import { createClient } from '@/utils/supabase';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useAuth, useTVMode } from '@/components/context';
import {
getRecentUsersWithStatuses,
updateStatuses,
updateUserStatus,
type UserWithStatus,
} from '@/lib/hooks';
import { Drawer, DrawerTrigger, Loading } from '@/components/ui';
import { SubmitButton } from '@/components/default';
import { toast } from 'sonner';
import { HistoryDrawer } from '@/components/status';
import type { Profile } from '@/utils/supabase';
import type { RealtimeChannel } from '@supabase/supabase-js';
import { makeConditionalClassName } from '@/lib/utils';
type TechTableProps = {
initialStatuses: UserWithStatus[];
};
export const TechTable = ({
initialStatuses = [],
}: TechTableProps) => {
const { isAuthenticated } = useAuth();
const { tvMode } = useTVMode();
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState('');
const [usersWithStatuses, setUsersWithStatuses] =
useState<UserWithStatus[]>(initialStatuses);
const [selectedHistoryUser, setSelectedHistoryUser] =
useState<Profile | null>(null);
const channelRef = useRef<RealtimeChannel | null>(null);
const fetchRecentUsersWithStatuses = useCallback(async () => {
try {
const response = await getRecentUsersWithStatuses();
if (!response.success) throw new Error(response.error);
return response.data;
} catch (error) {
toast.error(`Error fetching technicians: ${error as Error}`);
return [];
}
}, []);
// Initial load
useEffect(() => {
const loadData = async () => {
const data = await fetchRecentUsersWithStatuses();
setUsersWithStatuses(data);
setLoading(false);
};
loadData().catch((error) => {
console.error('Error loading data:', error);
});
}, [fetchRecentUsersWithStatuses, isAuthenticated]);
const updateStatus = useCallback(async () => {
if (!isAuthenticated) {
toast.error('You must be signed in to update statuses.');
return;
}
if (!statusInput.trim()) {
toast.error('Please enter a valid status.');
return;
}
try {
if (selectedIds.length === 0) {
const result = await updateUserStatus(statusInput);
if (!result.success) throw new Error(result.error);
toast.success(`Status updated for signed in user.`);
} else {
const result = await updateStatuses(selectedIds, statusInput);
if (!result.success) throw new Error(result.error);
toast.success(
`Status updated for ${selectedIds.length} selected users.`,
);
}
setSelectedIds([]);
setStatusInput('');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to update status: ${errorMessage}`);
}
}, [isAuthenticated, statusInput, selectedIds]);
const handleCheckboxChange = (id: string) => {
setSelectedIds((prev) =>
prev.includes(id)
? prev.filter((prevId) => prevId !== id)
: [...prev, id],
);
};
const handleSelectAllChange = () => {
if (selectAll) {
setSelectedIds([]);
} else {
setSelectedIds(usersWithStatuses.map((tech) => tech.user.id));
}
setSelectAll(!selectAll);
};
useEffect(() => {
setSelectAll(
selectedIds.length === usersWithStatuses.length &&
usersWithStatuses.length > 0,
);
}, [selectedIds.length, usersWithStatuses.length]);
useEffect(() => {
if (!isAuthenticated) return;
const supabase = createClient();
const channel = supabase
.channel('status_updates', {
config: { broadcast: { self: true }}
})
.on('broadcast', { event: 'status_updated' }, (payload) => {
const { user_status } = payload.payload as {
user_status: UserWithStatus;
timestamp: string;
};
console.log('Received status update:', user_status);
setUsersWithStatuses((prevUsers) => {
const existingUserIndex = prevUsers.findIndex((u) =>
u.user.id === user_status.user.id,
);
if (existingUserIndex !== -1) {
const updatedUsers = [...prevUsers];
updatedUsers[existingUserIndex] = {
user: user_status.user, // Use the user from the broadcast
status: user_status.status,
created_at: user_status.created_at,
updated_by: user_status.updated_by,
};
return updatedUsers;
} else {
// Add new user to list!
return [user_status, ...prevUsers];
}
});
})
.subscribe((status) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (status === 'SUBSCRIBED') {
console.log('Successfully subscribed to status updates!');
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
} else if (status === 'CHANNEL_ERROR') {
console.error('Error subscribing to status updates.')
}
});
channelRef.current = channel;
return () => {
if (channelRef.current) {
supabase.removeChannel(channelRef.current).catch((error) => {
console.error(`Error unsubscribing from status updates: ${error}`);
});
channelRef.current = null;
}
};
}, [isAuthenticated]);
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const time = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: 'numeric',
});
const day = date.getDate();
const month = date.toLocaleString('default', { month: 'long' });
return `${time} - ${month} ${day}`;
};
if (loading) {
return (
<div className='flex justify-center items-center min-h-[400px]'>
<Loading className='w-full' alpha={0.5} />
</div>
);
}
const containerClassName = makeConditionalClassName({
context: tvMode,
defaultClassName: 'mx-auto',
on: 'lg:w-11/12 w-full mt-15',
off: 'w-5/6',
});
const thClassName = makeConditionalClassName({
context: tvMode,
defaultClassName: 'py-3 px-4 border font-semibold',
on: 'lg:text-6xl',
off: 'lg:text-5xl',
});
const tdClassName = makeConditionalClassName({
context: tvMode,
defaultClassName: 'py-3 px-4 border',
on: 'lg:text-5xl',
off: 'lg:text-4xl',
});
const tCheckboxClassName = `py-3 px-4 border`;
const checkBoxClassName = `lg:scale-200 cursor-pointer`;
return (
<div className={containerClassName}>
<table className='w-full text-center rounded-md'>
<thead>
<tr className='bg-muted'>
{!tvMode && (
<th className={tCheckboxClassName}>
<input
type='checkbox'
className={checkBoxClassName}
checked={selectAll}
onChange={handleSelectAllChange}
/>
</th>
)}
<th className={thClassName}>Name</th>
<th className={thClassName}>
<Drawer>
<DrawerTrigger
className='hover:text-foreground/60 cursor-pointer'
>
Status
</DrawerTrigger>
<HistoryDrawer />
</Drawer>
</th>
<th className={thClassName}>Updated At</th>
</tr>
</thead>
<tbody>
{usersWithStatuses.map((userWithStatus, index) => (
<tr
key={userWithStatus.user.id}
className={`
${index % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
hover:bg-muted/75 transition-all duration-300
`}
>
{!tvMode && (
<td className={tCheckboxClassName}>
<input
type='checkbox'
className={checkBoxClassName}
checked={selectedIds.includes(userWithStatus.user.id)}
onChange={() =>
handleCheckboxChange(userWithStatus.user.id)
}
/>
</td>
)}
<td className={tdClassName}>
{userWithStatus.user.full_name ?? 'Unknown User'}
</td>
<td className={tdClassName}>
<Drawer>
<DrawerTrigger
className='text-center w-full p-2 rounded-md hover:bg-muted transition-colors'
onClick={() => setSelectedHistoryUser(userWithStatus.user)}
>
{userWithStatus.status}
</DrawerTrigger>
{selectedHistoryUser === userWithStatus.user && (
<HistoryDrawer user={selectedHistoryUser} />
)}
</Drawer>
</td>
<td className={tdClassName}>
{formatTime(userWithStatus.created_at)}
</td>
</tr>
))}
</tbody>
</table>
{!tvMode && (
<div className='mx-auto flex flex-row items-center justify-center py-5 gap-4'>
<input
autoFocus
type='text'
placeholder='New Status'
className={
'min-w-[120px] lg:min-w-[400px] py-2 px-3 rounded-xl \
border bg-background lg:text-2xl focus:outline-none \
focus:ring-2 focus:ring-primary'
}
value={statusInput}
onChange={(e) => setStatusInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
updateStatus().catch((error) => {
toast.error(`Failed to update status: ${error as Error}`);
});
}
}}
/>
<SubmitButton
size='xl'
className={
'px-8 rounded-xl font-semibold lg:text-2xl \
disabled:opacity-50 disabled:cursor-not-allowed \
cursor-pointer'
}
onClick={() => updateStatus()}
disabled={!statusInput.trim()}
disabledNotLoading={true}
>
Update
</SubmitButton>
</div>
)}
</div>
);
};

View File

@ -1,2 +1,4 @@
export * from './ConnectionStatus';
export * from './HistoryDrawer';
export * from './TechTable';
export * from './List';
export * from './Table';

View File

@ -2,9 +2,60 @@
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { User } from 'lucide-react';
import { cn } from '@/lib/utils';
type BasedAvatarProps = React.ComponentProps<typeof AvatarPrimitive.Root> & {
src?: string | null;
fullName?: string | null;
imageClassName?: string;
fallbackClassName?: string;
userIconSize?: number;
};
function BasedAvatar({
src = null,
fullName = null,
imageClassName = '',
fallbackClassName = '',
userIconSize = 32,
className,
...props
}: BasedAvatarProps) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
className={cn(
'cursor-pointer relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
>
{src ? (
<AvatarImage src={src} className={imageClassName} />
) : (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
fallbackClassName,
)}
>
{fullName ? (
fullName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
) : (
<User size={userIconSize} />
)}
</AvatarPrimitive.Fallback>
)}
</AvatarPrimitive.Root>
);
}
function Avatar({
className,
...props
@ -37,7 +88,9 @@ function AvatarImage({
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
}: React.ComponentProps<
typeof AvatarPrimitive.Fallback & { fullName: string }
>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
@ -50,4 +103,4 @@ function AvatarFallback({
);
}
export { Avatar, AvatarImage, AvatarFallback };
export { Avatar, BasedAvatar, AvatarImage, AvatarFallback };

View File

@ -32,7 +32,7 @@ export const Loading: React.FC<Loading_Props> = ({
}, [intervalMs, alpha]);
return (
<div className="items-center justify-center w-1/3 m-auto pt-20">
<div className='items-center justify-center w-1/3 m-auto pt-20'>
<Progress value={progress} className={className} {...props} />
</div>
);

View File

@ -3,8 +3,7 @@
import 'server-only';
import { createServerClient } from '@/utils/supabase';
import { headers } from 'next/headers';
import type { User } from '@/utils/supabase';
import type { Result } from '.';
import type { User, Result } from '@/utils/supabase';
export const signUp = async (
formData: FormData,
@ -58,31 +57,37 @@ export const signIn = async (formData: FormData): Promise<Result<null>> => {
}
};
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
type OAuthReturn = {
provider: string;
url: string;
};
export const signInWithMicrosoft = async (): Promise<Result<OAuthReturn>> => {
const supabase = await createServerClient();
const origin = (await headers()).get('origin');
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'azure',
options: {
scopes: 'openid, profile email offline_access',
scopes: 'openid profile email offline_access',
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
},
});
if (error) return { success: false, error: error.message };
return { success: true, data: data.url };
return { success: true, data };
};
export const signInWithApple = async (): Promise<Result<string>> => {
export const signInWithApple = async (): Promise<Result<OAuthReturn>> => {
const supabase = await createServerClient();
const origin = process.env.BASE_URL!;
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
scopes: 'openid profile email offline_access',
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
},
});
if (error) return { success: false, error: error.message };
return { success: true, data: data.url };
return { success: true, data };
};
export const forgotPassword = async (

View File

@ -1,22 +1,66 @@
'use server';
'use client';
import 'server-only';
import { createServerClient, type Profile } from '@/utils/supabase';
import { getUser } from '@/lib/actions';
import { getSignedUrl, getUser } from '@/lib/actions';
import type { Result } from '.';
export const getProfile = async (): Promise<Result<Profile>> => {
export const getProfile = async (
userId: string | null = null,
): Promise<Result<Profile>> => {
try {
if (userId == null) {
const user = await getUser();
if (!user.success || user.data === undefined)
throw new Error('User not found');
if (!user.success || !user.data.id) throw new Error('User not found');
userId = user.data.id;
}
const supabase = await createServerClient();
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', user.data.id)
.eq('id', userId)
.single();
if (error) throw error;
return { success: true, data: data as Profile };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error getting profile',
};
}
};
export const getProfileWithAvatar = async (
userId: string | null = null,
): Promise<Result<Profile>> => {
try {
if (userId === null) {
const user = await getUser();
if (!user.success || user.data === undefined)
throw new Error('User not found');
userId = user.data.id;
}
const supabase = await createServerClient();
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
if (error) throw error;
if (data.avatar_url) {
const avatarUrl = await getSignedUrl({
bucket: 'avatars',
url: data.avatar_url,
transform: { width: 128, height: 128 },
});
if (avatarUrl.success) {
data.avatar_url = avatarUrl.data;
}
}
return { success: true, data: data as Profile };
} catch (error) {
return {
@ -33,18 +77,21 @@ type updateProfileProps = {
full_name?: string;
email?: string;
avatar_url?: string;
provider?: string;
};
export const updateProfile = async ({
full_name,
email,
avatar_url,
provider,
}: updateProfileProps): Promise<Result<Profile>> => {
try {
if (
full_name === undefined &&
email === undefined &&
avatar_url === undefined
avatar_url === undefined &&
provider === undefined
)
throw new Error('No profile data provided');
@ -59,11 +106,21 @@ export const updateProfile = async ({
...(full_name !== undefined && { full_name }),
...(email !== undefined && { email }),
...(avatar_url !== undefined && { avatar_url }),
...(provider !== undefined && { provider }),
})
.eq('id', userResponse.data.id)
.select()
.single();
if (error) throw error;
if (data.avatar_url) {
const avatarUrl = await getSignedUrl({
bucket: 'avatars',
url: data.avatar_url,
transform: { width: 128, height: 128 },
});
if (avatarUrl.success) data.avatar_url = avatarUrl.data;
}
return {
success: true,
data: data as Profile,

View File

@ -1,7 +1,7 @@
'use server';
import { createServerClient } from '@/utils/supabase';
import type { Profile, Result } from '@/utils/supabase';
import { getUser, getProfile } from '@/lib/hooks';
import { getUser, getProfileWithAvatar, getSignedUrl } from '@/lib/actions';
export type UserWithStatus = {
id?: string;
@ -24,6 +24,16 @@ type PaginatedHistory = {
export const getRecentUsersWithStatuses = async (): Promise<
Result<UserWithStatus[]>
> => {
const getAvatarUrl = async (url: string | null | undefined) => {
if (!url) return null;
const avatarUrl = await getSignedUrl({
bucket: 'avatars',
url,
transform: { width: 128, height: 128 },
});
if (avatarUrl.success) return avatarUrl.data;
else return null;
};
try {
const supabase = await createServerClient();
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
@ -55,7 +65,20 @@ export const getRecentUsersWithStatuses = async (): Promise<
return true;
});
return { success: true, data: filtered };
const filteredWithAvatars = new Array<UserWithStatus>();
for (const userWithStatus of filtered) {
if (userWithStatus.user.avatar_url)
userWithStatus.user.avatar_url = await getAvatarUrl(
userWithStatus.user.avatar_url,
);
if (userWithStatus.updated_by?.avatar_url)
userWithStatus.updated_by.avatar_url = await getAvatarUrl(
userWithStatus.updated_by?.avatar_url,
);
filteredWithAvatars.push(userWithStatus);
}
return { success: true, data: filteredWithAvatars };
} catch (error) {
return { success: false, error: `Error: ${error as Error}` };
}
@ -91,48 +114,40 @@ export const broadcastStatusUpdates = async (
};
export const updateStatuses = async (
userIds: string[],
usersWithStatuses: UserWithStatus[],
status: string,
): Promise<Result<void>> => {
try {
const supabase = await createServerClient();
const userResponse = await getUser();
if (!userResponse.success) throw new Error('Not authenticated!');
const profileResponse = await getProfile();
if (!profileResponse.success) throw new Error(profileResponse.error);
const user = userResponse.data;
const userProfile = profileResponse.data;
const inserts = userIds.map((usersId) => ({
user_id: usersId,
status,
updated_by_id: user.id,
}));
const profileResponse = await getProfileWithAvatar();
if (!profileResponse.success) throw new Error('Not authenticated!');
const user = profileResponse.data;
const { data: insertedStatuses, error: insertedStatusesError } =
await supabase.from('statuses').insert(inserts).select();
if (insertedStatusesError) throw insertedStatusesError as Error;
await supabase
.from('statuses')
.insert(
usersWithStatuses.map((userWithStatus) => ({
user_id: userWithStatus.user.id,
status,
updated_by_id: user.id,
})),
)
.select();
if (insertedStatuses) {
const broadcastArray = new Array<UserWithStatus>(insertedStatuses.length);
for (const insertedStatus of insertedStatuses) {
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('*')
.eq('id', insertedStatus.user_id)
.single();
if (profileError) throw profileError as Error;
if (profile) {
broadcastArray.push({
user: profile,
status: insertedStatus.status,
created_at: insertedStatus.created_at,
updated_by: userProfile,
});
}
}
await broadcastStatusUpdates(broadcastArray);
if (insertedStatusesError) throw new Error("Couldn't insert statuses!");
else if (insertedStatuses) {
const createdAtFallback = new Date(Date.now()).toISOString();
await broadcastStatusUpdates(
usersWithStatuses.map((s, i) => {
return {
user: s.user,
status: status,
created_at: insertedStatuses[i]?.created_at ?? createdAtFallback,
updated_by: user,
};
}),
);
}
return { success: true, data: undefined };
} catch (error) {
@ -148,33 +163,31 @@ export const updateUserStatus = async (
): Promise<Result<void>> => {
try {
const supabase = await createServerClient();
const userResponse = await getUser();
if (!userResponse.success)
throw new Error(`Not authenticated! ${userResponse.error}`);
const profileResponse = await getProfile();
const profileResponse = await getProfileWithAvatar();
if (!profileResponse.success)
throw new Error(`Could not get profile! ${profileResponse.error}`);
const user = userResponse.data;
throw new Error(`Not authenticated! ${profileResponse.error}`);
const userProfile = profileResponse.data;
const { data: insertedStatus, error: insertedStatusError } = await supabase
.from('statuses')
.insert({
user_id: user.id,
user_id: userProfile.id,
status,
updated_by_id: user.id,
updated_by_id: userProfile.id,
})
.select()
.single();
if (insertedStatusError) throw insertedStatusError as Error;
const userStatus: UserWithStatus = {
await broadcastStatusUpdates([
{
user: userProfile,
status: insertedStatus.status,
created_at: insertedStatus.created_at,
};
updated_by: userProfile,
},
]);
await broadcastStatusUpdates([userStatus]);
return { success: true, data: undefined };
} catch (error) {
return {
@ -220,14 +233,6 @@ export const getUserHistory = async (
};
if (statusesError) throw statusesError as Error;
const { data: profile, error: profileError } = (await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single()) as { data: Profile; error: unknown };
if (profileError) throw profileError as Error;
if (!profile) throw new Error('User profile not found!');
const totalCount = count ?? 0;
const totalPages = Math.ceil(totalCount / perPage);

View File

@ -54,7 +54,12 @@ export const signIn = async (formData: FormData): Promise<Result<null>> => {
}
};
export const signInWithMicrosoft = async (): Promise<Result<string>> => {
type OAuthReturn = {
provider: string;
url: string;
};
export const signInWithMicrosoft = async (): Promise<Result<OAuthReturn>> => {
const supabase = createClient();
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
const { data, error } = await supabase.auth.signInWithOAuth({
@ -65,20 +70,21 @@ export const signInWithMicrosoft = async (): Promise<Result<string>> => {
},
});
if (error) return { success: false, error: error.message };
return { success: true, data: data.url };
return { success: true, data };
};
export const signInWithApple = async (): Promise<Result<string>> => {
export const signInWithApple = async (): Promise<Result<OAuthReturn>> => {
const supabase = createClient();
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: {
scopes: 'openid profile email offline_access',
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
},
});
if (error) return { success: false, error: error.message };
return { success: true, data: data.url };
return { success: true, data };
};
export const forgotPassword = async (

View File

@ -3,6 +3,8 @@ export * from './public';
export * from './status';
export * from './storage';
export * from './useFileUpload';
export * from './useSharedStatusSubscription';
export * from './useStatusData';
export type Result<T> =
| { success: true; data: T }

View File

@ -1,21 +1,66 @@
'use client';
import { createClient, type Profile } from '@/utils/supabase';
import { getUser } from '@/lib/hooks';
import { getSignedUrl, getUser } from '@/lib/hooks';
import type { Result } from '.';
export const getProfile = async (): Promise<Result<Profile>> => {
export const getProfile = async (
userId: string | null = null,
): Promise<Result<Profile>> => {
try {
if (userId == null) {
const user = await getUser();
if (!user.success || user.data === undefined)
throw new Error('User not found');
if (!user.success || !user.data.id) throw new Error('User not found');
userId = user.data.id;
}
const supabase = createClient();
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', user.data.id)
.eq('id', userId)
.single();
if (error) throw error;
return { success: true, data: data as Profile };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error getting profile',
};
}
};
export const getProfileWithAvatar = async (
userId: string | null = null,
): Promise<Result<Profile>> => {
try {
if (userId === null) {
const user = await getUser();
if (!user.success || user.data === undefined)
throw new Error('User not found');
userId = user.data.id;
}
const supabase = createClient();
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
if (error) throw error;
if (data.avatar_url) {
const avatarUrl = await getSignedUrl({
bucket: 'avatars',
url: data.avatar_url,
transform: { width: 128, height: 128 },
});
if (avatarUrl.success) {
data.avatar_url = avatarUrl.data;
}
}
return { success: true, data: data as Profile };
} catch (error) {
return {
@ -32,18 +77,21 @@ type updateProfileProps = {
full_name?: string;
email?: string;
avatar_url?: string;
provider?: string;
};
export const updateProfile = async ({
full_name,
email,
avatar_url,
provider,
}: updateProfileProps): Promise<Result<Profile>> => {
try {
if (
full_name === undefined &&
email === undefined &&
avatar_url === undefined
avatar_url === undefined &&
provider === undefined
)
throw new Error('No profile data provided');
@ -58,11 +106,21 @@ export const updateProfile = async ({
...(full_name !== undefined && { full_name }),
...(email !== undefined && { email }),
...(avatar_url !== undefined && { avatar_url }),
...(provider !== undefined && { provider }),
})
.eq('id', userResponse.data.id)
.select()
.single();
if (error) throw error;
if (data.avatar_url) {
const avatarUrl = await getSignedUrl({
bucket: 'avatars',
url: data.avatar_url,
transform: { width: 128, height: 128 },
});
if (avatarUrl.success) data.avatar_url = avatarUrl.data;
}
return {
success: true,
data: data as Profile,

View File

@ -1,7 +1,7 @@
'use client';
import { createClient } from '@/utils/supabase';
import type { Profile, Result } from '@/utils/supabase';
import { getUser, getProfile } from '@/lib/hooks';
import { getUser, getProfileWithAvatar, getSignedUrl } from '@/lib/hooks';
export type UserWithStatus = {
id?: string;
@ -24,18 +24,30 @@ type PaginatedHistory = {
export const getRecentUsersWithStatuses = async (): Promise<
Result<UserWithStatus[]>
> => {
const getAvatarUrl = async (url: string | null | undefined) => {
if (!url) return null;
const avatarUrl = await getSignedUrl({
bucket: 'avatars',
url,
transform: { width: 128, height: 128 },
});
if (avatarUrl.success) return avatarUrl.data;
else return null;
};
try {
const supabase = createClient();
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
const { data, error } = (await supabase
.from('statuses')
.select(`
.select(
`
user:profiles!user_id(*),
status,
created_at,
updated_by:profiles!updated_by_id(*)
`)
`,
)
.gte('created_at', oneDayAgo.toISOString())
.order('created_at', { ascending: false })) as {
data: UserWithStatus[];
@ -53,7 +65,20 @@ export const getRecentUsersWithStatuses = async (): Promise<
return true;
});
return { success: true, data: filtered };
const filteredWithAvatars = new Array<UserWithStatus>();
for (const userWithStatus of filtered) {
if (userWithStatus.user.avatar_url)
userWithStatus.user.avatar_url = await getAvatarUrl(
userWithStatus.user.avatar_url,
);
if (userWithStatus.updated_by?.avatar_url)
userWithStatus.updated_by.avatar_url = await getAvatarUrl(
userWithStatus.updated_by?.avatar_url,
);
filteredWithAvatars.push(userWithStatus);
}
return { success: true, data: filteredWithAvatars };
} catch (error) {
return { success: false, error: `Error: ${error as Error}` };
}
@ -89,92 +114,81 @@ export const broadcastStatusUpdates = async (
};
export const updateStatuses = async (
userIds: string[],
usersWithStatuses: UserWithStatus[],
status: string,
): Promise<Result<void>> => {
): Promise<Result<UserWithStatus[]>> => {
try {
const supabase = createClient();
const userResponse = await getUser();
if (!userResponse.success) throw new Error('Not authenticated!');
const profileResponse = await getProfile();
if (!profileResponse.success) throw new Error(profileResponse.error);
const user = userResponse.data;
const userProfile = profileResponse.data;
const inserts = userIds.map((usersId) => ({
user_id: usersId,
status,
updated_by_id: user.id,
}));
const profileResponse = await getProfileWithAvatar();
if (!profileResponse.success) throw new Error('Not authenticated!');
const user = profileResponse.data;
const { data: insertedStatuses, error: insertedStatusesError } =
await supabase.from('statuses').insert(inserts).select();
if (insertedStatusesError) throw insertedStatusesError as Error;
await supabase
.from('statuses')
.insert(
usersWithStatuses.map((userWithStatus) => ({
user_id: userWithStatus.user.id,
status,
updated_by_id: user.id,
})),
)
.select();
if (insertedStatuses) {
const broadcastArray = new Array<UserWithStatus>(insertedStatuses.length);
for (const insertedStatus of insertedStatuses) {
const { data: profile, error: profileError } = await supabase
.from('profiles')
.select('*')
.eq('id', insertedStatus.user_id)
.single();
if (profileError) throw profileError as Error;
if (profile) {
broadcastArray.push({
user: profile,
status: insertedStatus.status,
created_at: insertedStatus.created_at,
updated_by: userProfile,
if (insertedStatusesError) throw new Error('Error inserting statuses!');
else if (insertedStatuses) {
const createdAtFallback = new Date(Date.now()).toISOString();
const statusUpdates = usersWithStatuses.map((s, i) => {
return {
user: s.user,
status: status,
created_at: insertedStatuses[i]?.created_at ?? createdAtFallback,
updated_by: user,
};
});
await broadcastStatusUpdates(statusUpdates);
return { success: true, data: statusUpdates };
} else {
return { success: false, error: 'No inserted statuses returned!' };
}
}
await broadcastStatusUpdates(broadcastArray);
}
return { success: true, data: undefined };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
error: `Error updating statuses: ${error as Error}`,
};
}
};
export const updateUserStatus = async (
status: string,
): Promise<Result<void>> => {
): Promise<Result<UserWithStatus[]>> => {
try {
const supabase = createClient();
const userResponse = await getUser();
if (!userResponse.success)
throw new Error(`Not authenticated! ${userResponse.error}`);
const profileResponse = await getProfile();
const profileResponse = await getProfileWithAvatar();
if (!profileResponse.success)
throw new Error(`Could not get profile! ${profileResponse.error}`);
const user = userResponse.data;
throw new Error(`Not authenticated! ${profileResponse.error}`);
const userProfile = profileResponse.data;
const { data: insertedStatus, error: insertedStatusError } = await supabase
.from('statuses')
.insert({
user_id: user.id,
user_id: userProfile.id,
status,
updated_by_id: user.id,
updated_by_id: userProfile.id,
})
.select()
.single();
if (insertedStatusError) throw insertedStatusError as Error;
const userStatus: UserWithStatus = {
const statusUpdate = {
user: userProfile,
status: insertedStatus.status,
created_at: insertedStatus.created_at,
updated_by: userProfile,
};
await broadcastStatusUpdates([statusUpdate]);
await broadcastStatusUpdates([userStatus]);
return { success: true, data: undefined };
return { success: true, data: [statusUpdate] };
} catch (error) {
return {
success: false,
@ -219,14 +233,6 @@ export const getUserHistory = async (
};
if (statusesError) throw statusesError as Error;
const { data: profile, error: profileError } = (await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single()) as { data: Profile; error: unknown };
if (profileError) throw profileError as Error;
if (!profile) throw new Error('User profile not found!');
const totalCount = count ?? 0;
const totalPages = Math.ceil(totalCount / perPage);

View File

@ -0,0 +1,131 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { createClient } from '@/utils/supabase';
import type { RealtimeChannel } from '@supabase/supabase-js';
export type ConnectionStatus =
| 'connecting'
| 'connected'
| 'disconnected'
| 'updating';
// Singleton state
let sharedChannel: RealtimeChannel | null = null;
let sharedConnectionStatus: ConnectionStatus = 'disconnected';
const subscribers = new Set<(status: ConnectionStatus) => void>();
const statusUpdateCallbacks = new Set<() => void>();
//const subscribers: Set<(status: ConnectionStatus) => void> = new Set();
//const statusUpdateCallbacks: Set<() => void> = new Set();
let reconnectAttempts = 0;
let reconnectTimeout: NodeJS.Timeout | undefined;
const supabase = createClient();
const notifySubscribers = (status: ConnectionStatus) => {
sharedConnectionStatus = status;
subscribers.forEach(callback => callback(status));
};
const notifyStatusUpdate = () => {
statusUpdateCallbacks.forEach(callback => callback());
};
const cleanup = () => {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = undefined;
}
if (sharedChannel) {
supabase.removeChannel(sharedChannel).catch((error) => {
console.error('Error removing shared channel:', error);
});
sharedChannel = null;
}
};
const connect = () => {
if (sharedChannel) return; // Already connected or connecting
cleanup();
notifySubscribers('connecting');
const channel = supabase
.channel('shared_status_updates', {
config: { broadcast: {self: true }}
})
.on('broadcast', { event: 'status_updated' }, () => {
notifyStatusUpdate();
})
.subscribe((status) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (status === 'SUBSCRIBED') {
notifySubscribers('connected');
reconnectAttempts = 0;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
} else if (status === 'CHANNEL_ERROR' || status === 'CLOSED') {
notifySubscribers('disconnected');
if (reconnectAttempts < 5) {
reconnectAttempts++;
const delay = 2000 * reconnectAttempts;
reconnectTimeout = setTimeout(() => {
if (subscribers.size > 0) { // Only reconnect if there are active subscribers
connect();
}
}, delay);
}
}
});
sharedChannel = channel;
};
const disconnect = () => {
cleanup();
notifySubscribers('disconnected');
};
export const useSharedStatusSubscription = (onStatusUpdate?: () => void) => {
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(sharedConnectionStatus);
useEffect(() => {
// Subscribe to status changes
subscribers.add(setConnectionStatus);
// Subscribe to status updates
if (onStatusUpdate) {
statusUpdateCallbacks.add(onStatusUpdate);
}
// Connect if this is the first subscriber
if (subscribers.size === 1) {
const timeout = setTimeout(connect, 1000);
return () => clearTimeout(timeout);
}
return () => {
// Cleanup subscriptions
subscribers.delete(setConnectionStatus);
if (onStatusUpdate) {
statusUpdateCallbacks.delete(onStatusUpdate);
}
// Disconnect if no more subscribers
if (subscribers.size === 0) {
disconnect();
}
};
}, [onStatusUpdate]);
const reconnect = useCallback(() => {
reconnectAttempts = 0;
connect();
}, []);
return {
connectionStatus,
connect: reconnect,
disconnect,
};
};

View File

@ -0,0 +1,132 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getRecentUsersWithStatuses,
updateStatuses,
updateUserStatus,
type UserWithStatus,
} from '@/lib/hooks';
import { QueryErrorCodes } from '@/components/context';
import { toast } from 'sonner';
type UseStatusDataOptions = {
initialData?: UserWithStatus[];
enabled?: boolean;
}
export const useStatusData = ({
initialData = [],
enabled = true
}: UseStatusDataOptions = {}) => {
const queryClient = useQueryClient();
const [newStatuses, setNewStatuses] = useState<Set<UserWithStatus>>(
new Set()
);
const query = useQuery({
queryKey: ['users-with-statuses'],
queryFn: async () => {
try {
const response = await getRecentUsersWithStatuses();
if (!response.success) throw new Error(response.error);
return response.data;
} catch (error) {
toast.error(`Error fetching technicians: ${error as Error}`);
throw error;
}
},
enabled,
refetchInterval: 30000,
refetchOnWindowFocus: true,
refetchOnMount: true,
initialData,
meta: { errCode: QueryErrorCodes.USERS_FETCH_FAILED },
});
const updateStatusMutation = useMutation({
mutationFn: async ({
usersWithStatuses,
status,
}: {
usersWithStatuses: UserWithStatus[];
status: string;
}) => {
try {
if (usersWithStatuses.length <= 0) {
const result = await updateUserStatus(status);
if (!result.success) throw new Error(result.error);
return result.data;
} else {
const result = await updateStatuses(usersWithStatuses, status);
if (!result.success) throw new Error(result.error);
return result.data;
}
} catch (error) {
console.error(`Error updating statuses: ${error as Error}`);
throw error;
}
},
meta: { errCode: QueryErrorCodes.STATUS_UPDATE_FAILED },
onMutate: async ({ usersWithStatuses, status }) => {
await queryClient.cancelQueries({ queryKey: ['users-with-statuses'] });
const previousData = queryClient.getQueryData<UserWithStatus[]>([
'users-with-statuses',
]);
if (previousData && usersWithStatuses.length > 0) {
const now = new Date().toISOString();
const optimisticData = previousData.map((userStatus) => {
if (
usersWithStatuses.some(
(selected) => selected.user.id === userStatus.user.id
)
) {
return { ...userStatus, status, created_at: now };
}
return userStatus;
});
queryClient.setQueryData(['users-with-statuses'], optimisticData);
// Add animation to optimistically updated statuses
setNewStatuses((prev) => new Set([...prev, ...usersWithStatuses]));
setTimeout(() => {
setNewStatuses((prev) => {
const updated = new Set(prev);
usersWithStatuses.forEach((updatedStatus) =>
updated.delete(updatedStatus)
);
return updated;
});
}, 1000);
}
return { previousData };
},
onSuccess: (data) => {
queryClient
.invalidateQueries({ queryKey: ['users-with-statuses'] })
.catch((error) => console.error(`Error invalidating query: ${error}`));
if (!data) return;
data.forEach((statusUpdate) => {
toast.success(
`${statusUpdate.user.full_name}'s status updated to '${statusUpdate.status}'.`
);
});
},
onError: (error, _variables, context) => {
if (context?.previousData) {
queryClient.setQueryData(['users-with-statuses'], context.previousData);
}
toast.error(`Error updating statuses: ${error}`);
},
});
return {
...query,
newStatuses,
updateStatusMutation,
};
};

View File

@ -0,0 +1,147 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { createClient } from '@/utils/supabase';
import type { RealtimeChannel } from '@supabase/supabase-js';
export type ConnectionStatus =
| 'connecting'
| 'connected'
| 'disconnected'
| 'updating';
type UseStatusSubscriptionOptions = {
enabled?: boolean;
onStatusUpdate?: () => void;
maxReconnectAttempts?: number;
reconnectDelay?: number;
}
export const useStatusSubscription = ({
enabled = true,
onStatusUpdate,
maxReconnectAttempts = 5,
reconnectDelay = 2000,
}: UseStatusSubscriptionOptions = {}) => {
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>('disconnected');
const channelRef = useRef<RealtimeChannel | null>(null);
const supabaseRef = useRef(createClient());
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isComponentMountedRef = useRef(true);
const visibilityTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const cleanup = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = undefined;
}
if (visibilityTimeoutRef.current) {
clearTimeout(visibilityTimeoutRef.current);
visibilityTimeoutRef.current = undefined;
}
if (channelRef.current) {
supabaseRef.current.removeChannel(channelRef.current).catch((error) => {
console.error('❌ cleanup: Error removing channel:', error);
});
channelRef.current = null;
}
}, []);
const connect = useCallback(() => {
if (!enabled || !isComponentMountedRef.current) return;
cleanup();
setConnectionStatus('connecting');
const channel = supabaseRef.current
.channel('status_updates', {
config: { broadcast: {self: true }}
});
channel
.on('broadcast', { event: 'status_updated' }, (payload) => {
onStatusUpdate?.();
})
.subscribe((status) => {
if (!isComponentMountedRef.current) return;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (status === 'SUBSCRIBED') {
setConnectionStatus('connected');
reconnectAttemptsRef.current = 0;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
} else if (status === 'CHANNEL_ERROR' || status === 'CLOSED') {
setConnectionStatus('disconnected');
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
const delay = reconnectDelay * reconnectAttemptsRef.current;
reconnectTimeoutRef.current = setTimeout(() => {
if (isComponentMountedRef.current) connect();
}, delay);
} else {
console.warn('⚠️ connect: Max reconnection attempts reached');
setConnectionStatus('disconnected');
}
}
});
channelRef.current = channel;
}, [enabled, onStatusUpdate, maxReconnectAttempts, reconnectDelay, cleanup]);
const disconnect = useCallback(() => {
cleanup();
setConnectionStatus('disconnected');
}, [cleanup]);
const reconnect = useCallback(() => {
reconnectAttemptsRef.current = 0;
connect();
}, [connect]);
// Handle visibility change for better reconnection
useEffect(() => {
const handleVisibilityChange = () => {
if (!enabled) return;
if (document.visibilityState === 'visible') {
visibilityTimeoutRef.current = setTimeout(() => {
if (connectionStatus === 'disconnected' && isComponentMountedRef.current) {
reconnect();
}
}, 1000);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [enabled, connectionStatus, reconnect]);
// Initial connection - SIMPLIFIED to avoid dependency issues
useEffect(() => {
if (!enabled) {
disconnect();
return;
}
const initialTimeout = setTimeout(() => {
if (isComponentMountedRef.current) connect();
}, 1000);
return () => {
clearTimeout(initialTimeout);
};
}, [enabled]);
// Cleanup on unmount
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
return {
connectionStatus,
connect: reconnect,
disconnect,
};
};

View File

@ -18,3 +18,19 @@ export const makeConditionalClassName = ({
}) => {
return defaultClassName + ' ' + (context ? on : off);
};
export const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const time = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: 'numeric',
});
return time;
};
export const formatDate = (timestamp: string) => {
const date = new Date(timestamp);
const day = date.getDate();
const month = date.toLocaleString('default', { month: 'long' });
return `${month} ${day}`;
};

View File

@ -4,6 +4,8 @@ import { updateSession } from '@/utils/supabase/middleware';
// In-memory store for tracking IPs (use Redis in production)
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
const bannedIPs = new Set<string>();
// Ban Arctic Wolf Explicitly
bannedIPs.add('::ffff:10.0.1.49');
// Suspicious patterns that indicate malicious activity
const MALICIOUS_PATTERNS = [
@ -93,7 +95,7 @@ export const middleware = async (request: NextRequest) => {
// Check if IP is already banned
if (bannedIPs.has(ip)) {
console.log(`🚫 Blocked banned IP: ${ip} trying to access ${pathname}`);
//console.log(`🚫 Blocked banned IP: ${ip} trying to access ${pathname}`);
return new NextResponse('Access denied.', { status: 403 });
}
@ -102,13 +104,15 @@ export const middleware = async (request: NextRequest) => {
const isSuspiciousMethod = isMethodSuspicious(method);
if (isSuspiciousPath || isSuspiciousMethod) {
console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`);
//console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`);
const shouldBan = updateIPAttempts(ip);
if (shouldBan) {
console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
return new NextResponse('Access denied - IP banned', { status: 403 });
return new NextResponse('Access denied - IP banned. Please fuck off.', {
status: 403,
});
}
// Return 404 to not reveal the blocking mechanism