Compare commits
21 Commits
55fdeb23b3
...
main
Author | SHA1 | Date | |
---|---|---|---|
ed1e2ff046 | |||
a895a05f8c | |||
c3f6eadabe | |||
bb0dd14ec9 | |||
3001f90a48 | |||
219549d35a | |||
d0533b78e8 | |||
3dabc27d58 | |||
4a9c1a7fec | |||
301a9acec0 | |||
dd0ba7f894 | |||
b7e8237dce | |||
43acc20a40 | |||
a28af1f629 | |||
bc915275cf | |||
6c85c973b9 | |||
d78c139ffb | |||
0e62bafa45 | |||
7e755535fe | |||
55e283731d | |||
0ba218e521 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -46,3 +46,5 @@ yarn-error.log*
|
|||||||
.idea
|
.idea
|
||||||
# Sentry Config File
|
# Sentry Config File
|
||||||
.env.sentry-build-plugin
|
.env.sentry-build-plugin
|
||||||
|
|
||||||
|
src/server/docker/volumes/db/data/
|
||||||
|
125
README.md
125
README.md
@ -1,19 +1,124 @@
|
|||||||
# T3 Template with Self Hosted Supabase
|
<h1 align="center">
|
||||||
|
<br>
|
||||||
|
<a href="https://techtracker.gbrown.org"><img src="https://git.gbrown.org/gib/tech-tracker-next/raw/branch/main/public/favicon.png" alt="Tech Tracker Logo" width="100"></a>
|
||||||
|
<br>
|
||||||
|
<b>Tech Tracker</b>
|
||||||
|
<br>
|
||||||
|
</h1>
|
||||||
|
|
||||||
This is my template for self hosting both Next.js & Supabase in order to create a perfect app!!
|
# [Find Here](https://techtracker.gbrown.org/)
|
||||||
|
|
||||||
## What to do
|
- Application used by COG employees to update their status & location throughout the day.
|
||||||
|
|
||||||
- [Self Host Supabase](https://supabase.com/docs/guides/self-hosting/docker)
|
<details>
|
||||||
- You will need to make sure you have some way to connect to the postgres database from the host. I had to remove the database port from the supabase-pooler and add it to the supabase-db in order to directly connect to it. This will be important for generating our types.
|
<summary>
|
||||||
- Clone this repo.
|
<h2>How to run:</h2>
|
||||||
- Go to src/server/db/schema.sql & run this SQL in the SQL editor on the Web UI of your Supabase instance.
|
</summary>
|
||||||
- Generate your types
|
|
||||||
- This part is potentially super weird if you are self hosting. If you are connecting directly to your database that you plan to use for production, you will need to clone your repo on the host running supabase so that you can then use the supabase cli tool. Once you have done that, you will need to install the supabase-cli tool with sudo. I just run something like `sudo npx supabase --help` and then accept the prompt to install the program. Once you have done this, you can then run the following command, replacing the password and the port to match your supabase database. You can also try running the provided script `./scripts/generate_types`
|
### Clone the Repository & Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.gbrown.org/gib/tech-tracker-next.git
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tech-tracker-next
|
||||||
|
```
|
||||||
|
|
||||||
|
I would recommend using [pnpm](https://pnpm.io/) to install dependencies.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
You will also need docker installed on whatever host you plan to run the Supabase instance from, whether locally, or on a home server or a VPS or whatever. Or you can just use the Supabase SaaS if you want to have a much easier time, probably. I wouldn't know!
|
||||||
|
|
||||||
|
### Add your environment variables
|
||||||
|
|
||||||
|
Copy the example environment variable files and paste them in the same directory named `.env`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ./env.example ./.env
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ./src/server/docker/env.example ./src/server/docker/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Add your secrets to the `.env` files you just copied.
|
||||||
|
|
||||||
|
### Host Supabase Locally
|
||||||
|
|
||||||
|
- Follow the instructions [here](https://supabase.com/docs/guides/self-hosting/docker) to host Supabase with Docker.
|
||||||
|
- You will need to make sure you have some way to connect to the postgres database from the host. I had to remove the database port from the supabase-pooler and add it to the supabase-db in order to directly connect to it. This will be important for generating our types. This is not strictly necessary, and honestly I think I may even just have the docker compose set up to do this already, as I can't figure out why I would want to port to the spooler open on my host anyways.
|
||||||
|
|
||||||
|
### Create your database schema & generate your types.
|
||||||
|
|
||||||
|
- Copy the contents of the schema file located in `./src/server/db/schema.sql` & paste it into the SQL editor on the Web UI of your Supabase instance. Run the SQL. There should be no errors & you should now be able to see the profiles & statuses tables in the table editor.
|
||||||
|
```bash
|
||||||
|
cat ./src/server/db/schema.sql | wl-copy # If you are on Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
- Generate your types.
|
||||||
|
- This can be a bit weird depending on what your setup is. If you are running Supabase locally on the same host that you are running your dev server, then this should be straightforward. If you are using the Supabase SaaS, then this is even more straightforward. If you are like me, and you are connecting to a self hosted instance of Supabase on your home server while developing, then you must clone this reposity on your server so that the command line tool can generate the types from your open postgres port on your Host, which is why the docker compose is configured how it was & why I mentioned this earlier.
|
||||||
|
|
||||||
|
You will need to run the supabase cli tool with sudo in my experience. What I would recommend to you is to run the command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo npx supabase --help
|
||||||
|
```
|
||||||
|
|
||||||
|
You will be prompted to install the supabase cli tool if you do not already have it installed, which you probably don't since root is running this. After that, you can run the following command below, replacing the password and the port to match your own Supabase Postgres Database port & password.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo npx supabase gen types typescript \
|
sudo npx supabase gen types typescript \
|
||||||
--db-url "postgres://postgres:password@localhost:5432/postgres" \
|
--db-url "postgres://postgres:password@localhost:5432/postgres" \
|
||||||
--schema public \
|
--schema public \
|
||||||
> ./src/lib/types
|
> ./src/utils/supabase/types.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
There is also a script in the `scripts` folder called `generate_types` which *should* do this for you.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/generate_types
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start your development environment.
|
||||||
|
|
||||||
|
Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
to start your development environment with turbopack
|
||||||
|
|
||||||
|
You can also run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev:slow
|
||||||
|
```
|
||||||
|
|
||||||
|
to start your development environment with webpack
|
||||||
|
|
||||||
|
### Start your Production Environment.
|
||||||
|
|
||||||
|
There are Dockerfiles & docker compose files that can be found in the `./scripts/docker` folder for the Next.js website. There is also a script called `reload_container` located in the `./scripts/` folder which was created to quickly update the container, but this will give you a better idea of what you need to do. First, build the image with
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose -f ./scripts/docker/production/compose.yml build
|
||||||
|
```
|
||||||
|
|
||||||
|
then you can run the container with
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose -f ./scripts/docker/production/compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, you may end up with some build errors. The `reload_containers` script swaps out the next config before it runs the docker build to skip any build errors, so you may want to do this as well, though you are welcome to fix the build errors as well, of course!
|
||||||
|
|
||||||
|
### Fin
|
||||||
|
|
||||||
|
I am sure I am missing a lot of stuff so feel free to open an issue if you have any questions or if you feel that I should add something here!
|
||||||
|
|
||||||
|
</details>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { FlatCompat } from '@eslint/eslintrc';
|
import { FlatCompat } from '@eslint/eslintrc';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
//import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
const compat = new FlatCompat({
|
||||||
baseDirectory: import.meta.dirname,
|
baseDirectory: import.meta.dirname,
|
||||||
|
@ -45,7 +45,7 @@ const sentryConfig = {
|
|||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||||
org: 'gib',
|
org: 'gib',
|
||||||
project: 't3-supabase-template',
|
project: 'tech-tracker-next',
|
||||||
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
// Only print logs for uploading source maps in CI
|
// Only print logs for uploading source maps in CI
|
||||||
|
24
package.json
24
package.json
@ -27,46 +27,48 @@
|
|||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@sentry/nextjs": "^9.29.0",
|
"@sentry/nextjs": "^9.30.0",
|
||||||
"@supabase/ssr": "^0.6.1",
|
"@supabase/ssr": "^0.6.1",
|
||||||
"@supabase/supabase-js": "^2.50.0",
|
"@supabase/supabase-js": "^2.50.0",
|
||||||
"@t3-oss/env-nextjs": "^0.12.0",
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
|
"@tanstack/react-query": "^5.80.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.510.0",
|
"lucide-react": "^0.510.0",
|
||||||
"next": "^15.3.3",
|
"next": "^15.3.4",
|
||||||
"next-plausible": "^3.12.4",
|
"next-plausible": "^3.12.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^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",
|
"require-in-the-middle": "^7.5.2",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.25.64"
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@tailwindcss/postcss": "^4.1.10",
|
"@tailwindcss/postcss": "^4.1.10",
|
||||||
|
"@tanstack/eslint-plugin-query": "^5.78.0",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^20.19.0",
|
"@types/node": "^20.19.1",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"eslint": "^9.28.0",
|
"eslint": "^9.29.0",
|
||||||
"eslint-config-next": "^15.3.3",
|
"eslint-config-next": "^15.3.4",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"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",
|
"import-in-the-middle": "^1.14.2",
|
||||||
"postcss": "^8.5.5",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.12",
|
"prettier-plugin-tailwindcss": "^0.6.13",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tw-animate-css": "^1.3.4",
|
"tw-animate-css": "^1.3.4",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.34.0"
|
"typescript-eslint": "^8.34.1"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
|
612
pnpm-lock.yaml
generated
612
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,16 +1,16 @@
|
|||||||
services:
|
services:
|
||||||
t3-template:
|
techtracker-next:
|
||||||
build:
|
build:
|
||||||
context: ../../../
|
context: ../../..
|
||||||
dockerfile: docker/development/Dockerfile
|
dockerfile: scripts/docker/development/Dockerfile
|
||||||
image: with-docker-multi-env-development
|
image: with-docker-multi-env-development
|
||||||
container_name: t3-template
|
container_name: techtracker-next
|
||||||
networks:
|
networks:
|
||||||
- nginx-bridge
|
- techtracker
|
||||||
#ports:
|
ports:
|
||||||
#- '3000:3000'
|
- '3111:3000'
|
||||||
tty: true
|
tty: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
nginx-bridge:
|
techtracker:
|
||||||
external: true
|
external: true
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
services:
|
services:
|
||||||
t3-template:
|
techtracker-next:
|
||||||
build:
|
build:
|
||||||
context: ../../../
|
context: ../../..
|
||||||
dockerfile: docker/production/Dockerfile
|
dockerfile: scripts/docker/production/Dockerfile
|
||||||
image: with-docker-multi-env-development
|
image: with-docker-multi-env-development
|
||||||
container_name: t3-template
|
container_name: techtracker-next
|
||||||
networks:
|
networks:
|
||||||
- nginx-bridge
|
- techtracker
|
||||||
#ports:
|
ports:
|
||||||
#- '3000:3000'
|
- '3111:3000'
|
||||||
tty: true
|
tty: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
nginx-bridge:
|
techtracker:
|
||||||
external: true
|
external: true
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
/**
|
/* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
|
||||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
* This is especially useful for Docker builds.
|
||||||
* for Docker builds.
|
|
||||||
*/
|
*/
|
||||||
import './src/env.js';
|
import './src/env.js';
|
||||||
import { withSentryConfig } from '@sentry/nextjs';
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
|
import { withPlausibleProxy } from 'next-plausible';
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = withPlausibleProxy({
|
||||||
|
customDomain: 'https://plausible.gbrown.org',
|
||||||
|
})({
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@ -28,22 +30,29 @@ const config = {
|
|||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
//turbopack: {
|
turbopack: {
|
||||||
//rules: {
|
rules: {
|
||||||
//'*.svg': {
|
'*.svg': {
|
||||||
//loaders: ['@svgr/webpack'],
|
loaders: [
|
||||||
//as: '*.js',
|
{
|
||||||
//},
|
loader: '@svgr/webpack',
|
||||||
//},
|
options: {
|
||||||
//},
|
icon: true,
|
||||||
};
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
as: '*.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const sentryConfig = {
|
const sentryConfig = {
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||||
org: 'gib',
|
org: 'gib',
|
||||||
project: 't3-supabase-template',
|
project: 'tech-tracker-next',
|
||||||
sentryUrl: process.env.SENTRY_URL,
|
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
// Only print logs for uploading source maps in CI
|
// Only print logs for uploading source maps in CI
|
||||||
silent: !process.env.CI,
|
silent: !process.env.CI,
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
/**
|
/* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation.
|
||||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
* This is especially useful for Docker builds.
|
||||||
* for Docker builds.
|
|
||||||
*/
|
*/
|
||||||
import './src/env.js';
|
import './src/env.js';
|
||||||
import { withSentryConfig } from '@sentry/nextjs';
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
|
import { withPlausibleProxy } from 'next-plausible';
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = withPlausibleProxy({
|
||||||
|
customDomain: 'https://plausible.gbrown.org',
|
||||||
|
})({
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@ -22,22 +24,29 @@ const config = {
|
|||||||
bodySizeLimit: '10mb',
|
bodySizeLimit: '10mb',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
//turbopack: {
|
turbopack: {
|
||||||
//rules: {
|
rules: {
|
||||||
//'*.svg': {
|
'*.svg': {
|
||||||
//loaders: ['@svgr/webpack'],
|
loaders: [
|
||||||
//as: '*.js',
|
{
|
||||||
//},
|
loader: '@svgr/webpack',
|
||||||
//},
|
options: {
|
||||||
//},
|
icon: true,
|
||||||
};
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
as: '*.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const sentryConfig = {
|
const sentryConfig = {
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||||
org: 'gib',
|
org: 'gib',
|
||||||
project: 't3-supabase-template',
|
project: 'tech-tracker-next',
|
||||||
sentryUrl: process.env.SENTRY_URL,
|
sentryUrl: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
// Only print logs for uploading source maps in CI
|
// Only print logs for uploading source maps in CI
|
||||||
silent: !process.env.CI,
|
silent: !process.env.CI,
|
||||||
|
@ -1,15 +1,9 @@
|
|||||||
// This file configures the initialization of Sentry on the server.
|
|
||||||
// The config you add here will be used whenever the server handles a request.
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
import './src/env.js';
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: 'https://0468176d5291bc2b914261147bfef117@sentry.gbrown.org/6',
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
tracesSampleRate: 1, // Define how likely traces are sampled or use tracesSampler for more control.
|
||||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
debug: false, // Print useful debugging info while setting up Sentry.
|
||||||
tracesSampleRate: 1,
|
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
|
||||||
debug: false,
|
|
||||||
});
|
});
|
||||||
|
@ -2,15 +2,13 @@ import type { Metadata } from 'next';
|
|||||||
|
|
||||||
export const generateMetadata = (): Metadata => {
|
export const generateMetadata = (): Metadata => {
|
||||||
return {
|
return {
|
||||||
title: 'Forgot Password'
|
title: 'Forgot Password',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ForgotPasswordLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
const ForgotPasswordLayout = ({
|
||||||
return (
|
children,
|
||||||
<div>
|
}: Readonly<{ children: React.ReactNode }>) => {
|
||||||
{children}
|
return <div>{children}</div>;
|
||||||
</div>
|
};
|
||||||
);
|
|
||||||
};
|
|
||||||
export default ForgotPasswordLayout;
|
export default ForgotPasswordLayout;
|
||||||
|
@ -2,15 +2,13 @@ import type { Metadata } from 'next';
|
|||||||
|
|
||||||
export const generateMetadata = (): Metadata => {
|
export const generateMetadata = (): Metadata => {
|
||||||
return {
|
return {
|
||||||
title: 'Profile'
|
title: 'Profile',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProfileLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
const ProfileLayout = ({
|
||||||
return (
|
children,
|
||||||
<div>
|
}: Readonly<{ children: React.ReactNode }>) => {
|
||||||
{children}
|
return <div>{children}</div>;
|
||||||
</div>
|
};
|
||||||
);
|
|
||||||
};
|
|
||||||
export default ProfileLayout;
|
export default ProfileLayout;
|
||||||
|
@ -2,15 +2,13 @@ import type { Metadata } from 'next';
|
|||||||
|
|
||||||
export const generateMetadata = (): Metadata => {
|
export const generateMetadata = (): Metadata => {
|
||||||
return {
|
return {
|
||||||
title: 'Sign In'
|
title: 'Sign In',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const SignInLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
const SignInLayout = ({
|
||||||
return (
|
children,
|
||||||
<div>
|
}: Readonly<{ children: React.ReactNode }>) => {
|
||||||
{children}
|
return <div>{children}</div>;
|
||||||
</div>
|
};
|
||||||
);
|
|
||||||
};
|
|
||||||
export default SignInLayout;
|
export default SignInLayout;
|
||||||
|
@ -2,15 +2,13 @@ import type { Metadata } from 'next';
|
|||||||
|
|
||||||
export const generateMetadata = (): Metadata => {
|
export const generateMetadata = (): Metadata => {
|
||||||
return {
|
return {
|
||||||
title: 'Sign Up'
|
title: 'Sign Up',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const SignUpLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
const SignUpLayout = ({
|
||||||
return (
|
children,
|
||||||
<div>
|
}: Readonly<{ children: React.ReactNode }>) => {
|
||||||
{children}
|
return <div>{children}</div>;
|
||||||
</div>
|
};
|
||||||
);
|
|
||||||
};
|
|
||||||
export default SignUpLayout;
|
export default SignUpLayout;
|
||||||
|
@ -4,6 +4,7 @@ import { Geist } from 'next/font/google';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
AuthProvider,
|
AuthProvider,
|
||||||
|
QueryProvider,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
TVModeProvider,
|
TVModeProvider,
|
||||||
} from '@/components/context';
|
} from '@/components/context';
|
||||||
@ -389,7 +390,10 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
|||||||
return (
|
return (
|
||||||
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
|
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
|
||||||
<body
|
<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
|
<ThemeProvider
|
||||||
attribute='class'
|
attribute='class'
|
||||||
@ -397,23 +401,25 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<AuthProvider>
|
<QueryProvider>
|
||||||
<PlausibleProvider
|
<AuthProvider>
|
||||||
domain='techtracker.gbrown.org'
|
<PlausibleProvider
|
||||||
customDomain='https://plausible.gbrown.org'
|
domain='techtracker.gbrown.org'
|
||||||
trackOutboundLinks={true}
|
customDomain='https://plausible.gbrown.org'
|
||||||
selfHosted={true}
|
trackOutboundLinks={true}
|
||||||
>
|
selfHosted={true}
|
||||||
<TVModeProvider>
|
>
|
||||||
<main className='min-h-screen'>
|
<TVModeProvider>
|
||||||
<Header />
|
<main className='min-h-screen'>
|
||||||
{children}
|
<Header />
|
||||||
<Toaster />
|
{children}
|
||||||
</main>
|
<Toaster />
|
||||||
<Footer />
|
</main>
|
||||||
</TVModeProvider>
|
<Footer />
|
||||||
</PlausibleProvider>
|
</TVModeProvider>
|
||||||
</AuthProvider>
|
</PlausibleProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -8,22 +8,7 @@ const Home = async () => {
|
|||||||
if (!userResponse.success) {
|
if (!userResponse.success) {
|
||||||
redirect('/sign-in');
|
redirect('/sign-in');
|
||||||
} else if (userResponse.data) {
|
} else if (userResponse.data) {
|
||||||
redirect('/status');
|
redirect('/status/list');
|
||||||
} else return <div />;
|
} else return <div />;
|
||||||
};
|
};
|
||||||
export default Home;
|
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;
|
|
||||||
|
@ -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;
|
|
14
src/app/status/list/layout.tsx
Normal file
14
src/app/status/list/layout.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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;
|
18
src/app/status/list/page.tsx
Normal file
18
src/app/status/list/page.tsx
Normal 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;
|
14
src/app/status/table/layout.tsx
Normal file
14
src/app/status/table/layout.tsx
Normal 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;
|
@ -10,7 +10,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
getProfile,
|
getProfile,
|
||||||
getSignedUrl,
|
getProfileWithAvatar,
|
||||||
getUser,
|
getUser,
|
||||||
updateProfile as updateProfileAction,
|
updateProfile as updateProfileAction,
|
||||||
} from '@/lib/hooks';
|
} from '@/lib/hooks';
|
||||||
@ -20,7 +20,6 @@ import { toast } from 'sonner';
|
|||||||
type AuthContextType = {
|
type AuthContextType = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
avatarUrl: string | null;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
updateProfile: (data: {
|
updateProfile: (data: {
|
||||||
@ -36,7 +35,6 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const fetchingRef = useRef(false);
|
const fetchingRef = useRef(false);
|
||||||
@ -53,32 +51,16 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userResponse = await getUser();
|
const userResponse = await getUser();
|
||||||
const profileResponse = await getProfile();
|
const profileResponse = await getProfileWithAvatar();
|
||||||
|
|
||||||
if (!userResponse.success || !profileResponse.success) {
|
if (!userResponse.success || !profileResponse.success) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setAvatarUrl(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(userResponse.data);
|
setUser(userResponse.data);
|
||||||
setProfile(profileResponse.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) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'Auth fetch error: ',
|
'Auth fetch error: ',
|
||||||
@ -110,7 +92,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const {
|
const {
|
||||||
data: { subscription },
|
data: { subscription },
|
||||||
} = supabase.auth.onAuthStateChange(async (event, _session) => {
|
} = supabase.auth.onAuthStateChange(async (event, _session) => {
|
||||||
|
|
||||||
console.log('Auth state change:', event); // Debug log
|
console.log('Auth state change:', event); // Debug log
|
||||||
if (event === 'SIGNED_IN') {
|
if (event === 'SIGNED_IN') {
|
||||||
// Background refresh without loading state
|
// Background refresh without loading state
|
||||||
@ -118,7 +99,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
} else if (event === 'SIGNED_OUT') {
|
} else if (event === 'SIGNED_OUT') {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setAvatarUrl(null);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} else if (event === 'TOKEN_REFRESHED') {
|
} else if (event === 'TOKEN_REFRESHED') {
|
||||||
// Silent refresh - don't show loading
|
// Silent refresh - don't show loading
|
||||||
@ -158,7 +138,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
} else if (event === 'SIGNED_OUT') {
|
} else if (event === 'SIGNED_OUT') {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setAvatarUrl(null);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} else if (event === 'TOKEN_REFRESHED') {
|
} else if (event === 'TOKEN_REFRESHED') {
|
||||||
console.log('Token refreshed, updating user data');
|
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');
|
throw new Error(result.error ?? 'Failed to update profile');
|
||||||
}
|
}
|
||||||
setProfile(result.data);
|
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!');
|
toast.success('Profile updated successfully!');
|
||||||
return { success: true, data: result.data };
|
return { success: true, data: result.data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -216,7 +183,6 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const value = {
|
const value = {
|
||||||
user,
|
user,
|
||||||
profile,
|
profile,
|
||||||
avatarUrl,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
90
src/components/context/Query.tsx
Normal file
90
src/components/context/Query.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,3 +1,4 @@
|
|||||||
export { AuthProvider, useAuth } from './Auth';
|
export { AuthProvider, useAuth } from './Auth';
|
||||||
export { ThemeProvider, ThemeToggle } from './Theme';
|
export { ThemeProvider, ThemeToggle } from './Theme';
|
||||||
export { TVModeProvider, useTVMode, TVToggle } from './TVMode';
|
export { TVModeProvider, useTVMode, TVToggle } from './TVMode';
|
||||||
|
export * from './Query';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { signInWithApple } from '@/lib/actions';
|
import { signInWithApple, getProfile, updateProfile } from '@/lib/actions';
|
||||||
import { StatusMessage, SubmitButton } from '@/components/default';
|
import { StatusMessage, SubmitButton } from '@/components/default';
|
||||||
import { useAuth } from '@/components/context';
|
import { useAuth } from '@/components/context';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@ -34,8 +34,25 @@ export const SignInWithApple = ({
|
|||||||
const result = await signInWithApple();
|
const result = await signInWithApple();
|
||||||
|
|
||||||
if (result?.success && result.data) {
|
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
|
// Redirect to Apple OAuth page
|
||||||
window.location.href = result.data;
|
window.location.href = result.data.url;
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage(`Error signing in with Apple!`);
|
setStatusMessage(`Error signing in with Apple!`);
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import Image from 'next/image';
|
|||||||
import { type buttonVariants } from '@/components/ui';
|
import { type buttonVariants } from '@/components/ui';
|
||||||
import { type ComponentProps } from 'react';
|
import { type ComponentProps } from 'react';
|
||||||
import { type VariantProps } from 'class-variance-authority';
|
import { type VariantProps } from 'class-variance-authority';
|
||||||
|
import { getProfile, updateProfile } from '@/lib/hooks';
|
||||||
|
|
||||||
type SignInWithMicrosoftProps = {
|
type SignInWithMicrosoftProps = {
|
||||||
className?: ComponentProps<'div'>['className'];
|
className?: ComponentProps<'div'>['className'];
|
||||||
@ -32,8 +33,24 @@ export const SignInWithMicrosoft = ({
|
|||||||
const result = await signInWithMicrosoft();
|
const result = await signInWithMicrosoft();
|
||||||
|
|
||||||
if (result?.success && result.data) {
|
if (result?.success && result.data) {
|
||||||
// Redirect to Microsoft OAuth page
|
const profileResponse = await getProfile();
|
||||||
window.location.href = result.data;
|
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 {
|
} else {
|
||||||
setStatusMessage(`Error: Could not sign in with Microsoft!`);
|
setStatusMessage(`Error: Could not sign in with Microsoft!`);
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,12 @@ const Footer = () => {
|
|||||||
hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F]
|
hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F]
|
||||||
flex items-center gap-2 transition-all duration-200'
|
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>
|
<span className='text-white'>View Source Code on Gitea</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
BasedAvatar,
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
@ -15,10 +13,9 @@ import {
|
|||||||
import { useAuth, useTVMode } from '@/components/context';
|
import { useAuth, useTVMode } from '@/components/context';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { signOut } from '@/lib/actions';
|
import { signOut } from '@/lib/actions';
|
||||||
import { User } from 'lucide-react';
|
|
||||||
|
|
||||||
const AvatarDropdown = () => {
|
const AvatarDropdown = () => {
|
||||||
const { profile, avatarUrl, refreshUserData } = useAuth();
|
const { profile, refreshUserData } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toggleTVMode, tvMode } = useTVMode();
|
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 (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<Avatar className='cursor-pointer scale-125'>
|
<BasedAvatar
|
||||||
{avatarUrl ? (
|
src={profile?.avatar_url}
|
||||||
<AvatarImage
|
fullName={profile?.full_name}
|
||||||
src={avatarUrl}
|
className='lg:h-12 lg:w-12 my-auto'
|
||||||
alt={getInitials(profile?.full_name)}
|
fallbackClassName='text-xl font-semibold'
|
||||||
width={64}
|
userIconSize={32}
|
||||||
height={64}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AvatarFallback className='text-md'>
|
|
||||||
{profile?.full_name ? (
|
|
||||||
getInitials(profile.full_name)
|
|
||||||
) : (
|
|
||||||
<User size={64} />
|
|
||||||
)}
|
|
||||||
</AvatarFallback>
|
|
||||||
)}
|
|
||||||
</Avatar>
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuLabel className='font-bold'>
|
<DropdownMenuLabel className='font-bold'>
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ThemeToggle, useTVMode } from '@/components/context';
|
import { ThemeToggle, useTVMode } from '@/components/context';
|
||||||
@ -9,44 +8,65 @@ import AvatarDropdown from './AvatarDropdown';
|
|||||||
const Header = () => {
|
const Header = () => {
|
||||||
const { tvMode } = useTVMode();
|
const { tvMode } = useTVMode();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
return tvMode ? (
|
|
||||||
<div className='w-full py-2 pt-6 md:py-5'>
|
// Controls component for both modes
|
||||||
<div className='absolute top-8 right-24'>
|
const Controls = () => (
|
||||||
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4'>
|
<div className='flex flex-row items-center'>
|
||||||
{isAuthenticated && <AvatarDropdown />}
|
<ThemeToggle className='mr-4' />
|
||||||
</div>
|
{isAuthenticated && <AvatarDropdown />}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
);
|
||||||
<header className='w-full py-2 pt-6 md:py-5'>
|
|
||||||
<div className='absolute top-8 right-16'>
|
if (tvMode) {
|
||||||
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4 md:pr-8'>
|
return (
|
||||||
<ThemeToggle className='mr-4' />
|
<div className='absolute top-10 right-37'>
|
||||||
{isAuthenticated && <AvatarDropdown />}
|
<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 justify-center px-4'
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src='/favicon.png'
|
||||||
|
alt='Tech Tracker Logo'
|
||||||
|
width={100}
|
||||||
|
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]
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
href='/'
|
|
||||||
scroll={false}
|
|
||||||
className='flex flex-row items-center text-center
|
|
||||||
justify-center sm:ml-0 p-4 mt-10 sm:mt-0'
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src='/favicon.png'
|
|
||||||
alt='Tech Tracker Logo'
|
|
||||||
width={100}
|
|
||||||
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]
|
|
||||||
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
|
|
||||||
>
|
|
||||||
Tech Tracker
|
|
||||||
</h1>
|
|
||||||
</Link>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
|
@ -1,19 +1,14 @@
|
|||||||
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
import { useFileUpload } from '@/lib/hooks/useFileUpload';
|
||||||
import { useAuth } from '@/components/context';
|
import { useAuth } from '@/components/context';
|
||||||
import {
|
import { BasedAvatar, CardContent } from '@/components/ui';
|
||||||
Avatar,
|
import { Loader2, Pencil, Upload } from 'lucide-react';
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
CardContent,
|
|
||||||
} from '@/components/ui';
|
|
||||||
import { Loader2, Pencil, Upload, User } from 'lucide-react';
|
|
||||||
|
|
||||||
type AvatarUploadProps = {
|
type AvatarUploadProps = {
|
||||||
onAvatarUploaded: (path: string) => Promise<void>;
|
onAvatarUploaded: (path: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
||||||
const { profile, avatarUrl } = useAuth();
|
const { profile } = useAuth();
|
||||||
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
|
const { isUploading, fileInputRef, uploadToStorage } = useFileUpload();
|
||||||
|
|
||||||
const handleAvatarClick = () => {
|
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 (
|
return (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className='flex flex-col items-center'>
|
<div className='flex flex-col items-center'>
|
||||||
@ -56,24 +42,13 @@ export const AvatarUpload = ({ onAvatarUploaded }: AvatarUploadProps) => {
|
|||||||
className='relative group cursor-pointer mb-4'
|
className='relative group cursor-pointer mb-4'
|
||||||
onClick={handleAvatarClick}
|
onClick={handleAvatarClick}
|
||||||
>
|
>
|
||||||
<Avatar className='h-32 w-32'>
|
<BasedAvatar
|
||||||
{avatarUrl ? (
|
src={profile?.avatar_url}
|
||||||
<AvatarImage
|
fullName={profile?.full_name}
|
||||||
src={avatarUrl}
|
className='h-32 w-32'
|
||||||
alt={getInitials(profile?.full_name)}
|
fallbackClassName='text-4xl font-semibold'
|
||||||
width={128}
|
userIconSize={100}
|
||||||
height={128}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AvatarFallback className='text-4xl'>
|
|
||||||
{profile?.full_name ? (
|
|
||||||
getInitials(profile.full_name)
|
|
||||||
) : (
|
|
||||||
<User size={32} />
|
|
||||||
)}
|
|
||||||
</AvatarFallback>
|
|
||||||
)}
|
|
||||||
</Avatar>
|
|
||||||
<div
|
<div
|
||||||
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50
|
className='absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/50
|
||||||
transition-all flex items-center justify-center'
|
transition-all flex items-center justify-center'
|
||||||
|
65
src/components/status/ConnectionStatus.tsx
Normal file
65
src/components/status/ConnectionStatus.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
};
|
@ -106,7 +106,9 @@ export const HistoryDrawer: React.FC<HistoryDrawerProps> = ({
|
|||||||
className='w-8 h-8 md:w-12 md:h-12'
|
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'>
|
<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>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{totalCount > 0 && (
|
{totalCount > 0 && (
|
||||||
|
389
src/components/status/List.tsx
Normal file
389
src/components/status/List.tsx
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type React 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 } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw, Clock, Calendar, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { useStatusData, useStatusSubscription } 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { connectionStatus, connect: reconnect } = useStatusSubscription(() => {
|
||||||
|
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 handleCardSelect = (user: UserWithStatus, e: React.MouseEvent) => {
|
||||||
|
// Prevent selection if clicking on profile elements
|
||||||
|
if ((e.target as HTMLElement).closest('[data-profile-trigger]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 items-center\
|
||||||
|
sm:w-5/6 md:w-3/4 lg:w-2/3 xl:w-1/2 min-w-[450px]',
|
||||||
|
on: 'mt-8',
|
||||||
|
off: 'px-10',
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerClassName = makeConditionalClassName({
|
||||||
|
context: tvMode,
|
||||||
|
defaultClassName: 'w-full',
|
||||||
|
on: 'hidden',
|
||||||
|
off: 'flex mb-3 justify-between items-center',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardContainerClassName = makeConditionalClassName({
|
||||||
|
context: tvMode,
|
||||||
|
defaultClassName: 'w-full space-y-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClassName}>
|
||||||
|
<div className={headerClassName}>
|
||||||
|
<div className='flex items-center gap-4'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleSelectAllChange}
|
||||||
|
className='flex items-center gap-2'
|
||||||
|
>
|
||||||
|
<CheckCircle2
|
||||||
|
className={`w-4 h-4 ${selectAll ? 'text-primary' : ''}`}
|
||||||
|
/>
|
||||||
|
{selectAll ? 'Deselect All' : 'Select All'}
|
||||||
|
</Button>
|
||||||
|
{!tvMode && (
|
||||||
|
<div className='flex items-center gap-2 text-xs'>
|
||||||
|
<span className='text-muted-foreground'>Miss the old table?</span>
|
||||||
|
<Link
|
||||||
|
href='/status/table'
|
||||||
|
className='font-medium hover:underline'
|
||||||
|
>
|
||||||
|
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={`
|
||||||
|
relative transition-all duration-200 cursor-pointer hover:shadow-md
|
||||||
|
${tvMode ? 'p-4' : 'p-3'}
|
||||||
|
${isSelected ? 'ring-2 ring-primary bg-primary/5 shadow-md' : 'hover:bg-muted/30'}
|
||||||
|
${isNewStatus ? 'animate-in slide-in-from-top-2 duration-500 bg-green-50 border-green-200' : ''}
|
||||||
|
`}
|
||||||
|
onClick={(e) => handleCardSelect(userWithStatus, e)}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<div className='absolute top-2 right-2 text-primary'>
|
||||||
|
<CheckCircle2
|
||||||
|
className={`${tvMode ? 'w-6 h-6' : 'w-5 h-5'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CardContent className='p-0'>
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
{/* Profile Section - Clickable for history */}
|
||||||
|
<Drawer>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<div
|
||||||
|
data-profile-trigger
|
||||||
|
className='flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity'
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedHistoryUser(userWithStatus.user)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BasedAvatar
|
||||||
|
src={userWithStatus.user.avatar_url}
|
||||||
|
fullName={userWithStatus.user.full_name}
|
||||||
|
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DrawerTrigger>
|
||||||
|
{selectedHistoryUser === userWithStatus.user && (
|
||||||
|
<HistoryDrawer user={selectedHistoryUser} />
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
<div className='flex-1'>
|
||||||
|
{/* Header with name and timestamp */}
|
||||||
|
<div className='flex items-start justify-between mb-2'>
|
||||||
|
<div>
|
||||||
|
<Drawer>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<h3
|
||||||
|
data-profile-trigger
|
||||||
|
className={`
|
||||||
|
font-semibold cursor-pointer hover:text-primary/80 truncate
|
||||||
|
${tvMode ? 'text-3xl' : 'text-2xl'}
|
||||||
|
`}
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedHistoryUser(userWithStatus.user)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{userWithStatus.user.full_name}
|
||||||
|
</h3>
|
||||||
|
</DrawerTrigger>
|
||||||
|
{selectedHistoryUser === userWithStatus.user && (
|
||||||
|
<HistoryDrawer user={selectedHistoryUser} />
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
<div
|
||||||
|
className={`pl-2 pr-15 pt-2 ${tvMode ? 'text-2xl' : 'text-xl'}`}
|
||||||
|
>
|
||||||
|
<p>{userWithStatus.status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col items-end px-2 gap-2 text-muted-foreground flex-shrink-0'>
|
||||||
|
<div className='flex items-center gap-2 flex-shrink-0 w-full'>
|
||||||
|
<Clock
|
||||||
|
className={`${tvMode ? 'w-6 h-6' : 'w-5 h-5'}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`${tvMode ? 'text-2xl' : 'text-xl'}`}
|
||||||
|
>
|
||||||
|
{formatTime(userWithStatus.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2 flex-shrink-0 w-full'>
|
||||||
|
<Calendar
|
||||||
|
className={`${tvMode ? 'w-6 h-6' : 'w-5 h-5'}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`${tvMode ? 'text-2xl' : 'text-xl'}`}
|
||||||
|
>
|
||||||
|
{formatDate(userWithStatus.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2 flex-shrink-0'>
|
||||||
|
{isUpdatedByOther && (
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<BasedAvatar
|
||||||
|
src={userWithStatus.updated_by?.avatar_url}
|
||||||
|
fullName={userWithStatus.updated_by?.full_name}
|
||||||
|
className={`${tvMode ? 'w-6 h-6' : 'w-5 h-5'}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`${tvMode ? 'text-base' : 'text-sm'}`}
|
||||||
|
>
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<p>Updated by</p>
|
||||||
|
{userWithStatus.updated_by?.full_name}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{usersWithStatuses.length === 0 && (
|
||||||
|
<Card className='p-8 text-center'>
|
||||||
|
<p
|
||||||
|
className={`text-muted-foreground ${tvMode ? 'text-2xl' : 'text-lg'}`}
|
||||||
|
>
|
||||||
|
No status updates have been made in the past day.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!tvMode && (
|
||||||
|
<Card className='p-6 mt-4 w-full'>
|
||||||
|
<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 update...'
|
||||||
|
className='flex-1 text-2xl'
|
||||||
|
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 ${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-xl p-6' : ''}
|
||||||
|
>
|
||||||
|
View All Status History
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<HistoryDrawer />
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
377
src/components/status/Table.tsx
Normal file
377
src/components/status/Table.tsx
Normal 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 { useStatusSubscription, 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 } = useStatusSubscription(() => {
|
||||||
|
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-5xl xl:min-w-[420px]',
|
||||||
|
off: 'lg:text-4xl xl:min-w-[320px]',
|
||||||
|
});
|
||||||
|
const tdClassName = makeConditionalClassName({
|
||||||
|
context: tvMode,
|
||||||
|
defaultClassName: 'py-2 px-2 border',
|
||||||
|
on: 'lg:text-4xl',
|
||||||
|
off: 'lg:text-3xl',
|
||||||
|
});
|
||||||
|
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 text-xs'>
|
||||||
|
<p className='text-muted-foreground'>Tired of the old table? </p>
|
||||||
|
<Link
|
||||||
|
href='/status/list'
|
||||||
|
className='italic font-semibold hover:text-primary/80'
|
||||||
|
>
|
||||||
|
Try 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-xl' : '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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,312 +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';
|
|
||||||
|
|
||||||
type TechTableProps = {
|
|
||||||
initialStatuses: UserWithStatus[];
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TechTable = ({
|
|
||||||
initialStatuses = [],
|
|
||||||
className = 'w-full max-w-7xl mx-auto px-4',
|
|
||||||
}: 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;
|
|
||||||
//if (channelRef.current) {
|
|
||||||
//const supabase = createClient();
|
|
||||||
//supabase.removeChannel(channelRef.current).catch((error) => {
|
|
||||||
//console.error(`Error unsubscribing from status updates: ${error}`);
|
|
||||||
//});
|
|
||||||
//channelRef.current = null;
|
|
||||||
//}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<table
|
|
||||||
className={`w-full text-center border-collapse \
|
|
||||||
${tvMode ? 'text-4xl lg:text-5xl' : 'text-base lg:text-lg'}`}
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr className='bg-muted'>
|
|
||||||
{!tvMode && (
|
|
||||||
<th className='py-3 px-3 border'>
|
|
||||||
<input
|
|
||||||
type='checkbox'
|
|
||||||
className='scale-125 cursor-pointer'
|
|
||||||
checked={selectAll}
|
|
||||||
onChange={handleSelectAllChange}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
)}
|
|
||||||
<th className='py-3 px-4 border font-semibold'>Name</th>
|
|
||||||
<th className='py-3 px-4 border font-semibold'>
|
|
||||||
<Drawer>
|
|
||||||
<DrawerTrigger className='hover:underline'>
|
|
||||||
Status
|
|
||||||
</DrawerTrigger>
|
|
||||||
<HistoryDrawer />
|
|
||||||
</Drawer>
|
|
||||||
</th>
|
|
||||||
<th className='py-3 px-4 border font-semibold'>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='py-2 px-3 border'>
|
|
||||||
<input
|
|
||||||
type='checkbox'
|
|
||||||
className='scale-125 cursor-pointer'
|
|
||||||
checked={selectedIds.includes(userWithStatus.user.id)}
|
|
||||||
onChange={() =>
|
|
||||||
handleCheckboxChange(userWithStatus.user.id)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className='py-3 px-4 border font-medium'>
|
|
||||||
{userWithStatus.user.full_name ?? 'Unknown User'}
|
|
||||||
</td>
|
|
||||||
<td className='py-3 px-4 border'>
|
|
||||||
<Drawer>
|
|
||||||
<DrawerTrigger
|
|
||||||
className='text-left w-full p-2 rounded hover:bg-muted transition-colors'
|
|
||||||
onClick={() => setSelectedHistoryUser(userWithStatus.user)}
|
|
||||||
>
|
|
||||||
{userWithStatus.status}
|
|
||||||
</DrawerTrigger>
|
|
||||||
{selectedHistoryUser === userWithStatus.user && (
|
|
||||||
<HistoryDrawer user={selectedHistoryUser} />
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
</td>
|
|
||||||
<td className='py-3 px-4 border text-muted-foreground'>
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,2 +1,4 @@
|
|||||||
|
export * from './ConnectionStatus';
|
||||||
export * from './HistoryDrawer';
|
export * from './HistoryDrawer';
|
||||||
export * from './TechTable';
|
export * from './List';
|
||||||
|
export * from './Table';
|
||||||
|
@ -2,9 +2,60 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||||
|
import { User } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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({
|
function Avatar({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@ -37,7 +88,9 @@ function AvatarImage({
|
|||||||
function AvatarFallback({
|
function AvatarFallback({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
}: React.ComponentProps<
|
||||||
|
typeof AvatarPrimitive.Fallback & { fullName: string }
|
||||||
|
>) {
|
||||||
return (
|
return (
|
||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
data-slot='avatar-fallback'
|
data-slot='avatar-fallback'
|
||||||
@ -50,4 +103,4 @@ function AvatarFallback({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Avatar, AvatarImage, AvatarFallback };
|
export { Avatar, BasedAvatar, AvatarImage, AvatarFallback };
|
||||||
|
@ -32,7 +32,7 @@ export const Loading: React.FC<Loading_Props> = ({
|
|||||||
}, [intervalMs, alpha]);
|
}, [intervalMs, alpha]);
|
||||||
|
|
||||||
return (
|
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} />
|
<Progress value={progress} className={className} {...props} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -3,8 +3,7 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import { createServerClient } from '@/utils/supabase';
|
import { createServerClient } from '@/utils/supabase';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import type { User } from '@/utils/supabase';
|
import type { User, Result } from '@/utils/supabase';
|
||||||
import type { Result } from '.';
|
|
||||||
|
|
||||||
export const signUp = async (
|
export const signUp = async (
|
||||||
formData: FormData,
|
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 supabase = await createServerClient();
|
||||||
const origin = (await headers()).get('origin');
|
const origin = (await headers()).get('origin');
|
||||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||||
provider: 'azure',
|
provider: 'azure',
|
||||||
options: {
|
options: {
|
||||||
scopes: 'openid, profile email offline_access',
|
scopes: 'openid profile email offline_access',
|
||||||
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (error) return { success: false, error: error.message };
|
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 supabase = await createServerClient();
|
||||||
const origin = process.env.BASE_URL!;
|
const origin = process.env.BASE_URL!;
|
||||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||||
provider: 'apple',
|
provider: 'apple',
|
||||||
options: {
|
options: {
|
||||||
|
scopes: 'openid profile email offline_access',
|
||||||
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (error) return { success: false, error: error.message };
|
if (error) return { success: false, error: error.message };
|
||||||
return { success: true, data: data.url };
|
return { success: true, data };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const forgotPassword = async (
|
export const forgotPassword = async (
|
||||||
|
@ -1,22 +1,66 @@
|
|||||||
'use server';
|
'use client';
|
||||||
|
|
||||||
import 'server-only';
|
|
||||||
import { createServerClient, type Profile } from '@/utils/supabase';
|
import { createServerClient, type Profile } from '@/utils/supabase';
|
||||||
import { getUser } from '@/lib/actions';
|
import { getSignedUrl, getUser } from '@/lib/actions';
|
||||||
import type { Result } from '.';
|
import type { Result } from '.';
|
||||||
|
|
||||||
export const getProfile = async (): Promise<Result<Profile>> => {
|
export const getProfile = async (
|
||||||
|
userId: string | null = null,
|
||||||
|
): Promise<Result<Profile>> => {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
if (userId == null) {
|
||||||
if (!user.success || user.data === undefined)
|
const user = await getUser();
|
||||||
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 supabase = await createServerClient();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', user.data.id)
|
.eq('id', userId)
|
||||||
.single();
|
.single();
|
||||||
if (error) throw error;
|
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 };
|
return { success: true, data: data as Profile };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@ -33,18 +77,21 @@ type updateProfileProps = {
|
|||||||
full_name?: string;
|
full_name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
|
provider?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProfile = async ({
|
export const updateProfile = async ({
|
||||||
full_name,
|
full_name,
|
||||||
email,
|
email,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
provider,
|
||||||
}: updateProfileProps): Promise<Result<Profile>> => {
|
}: updateProfileProps): Promise<Result<Profile>> => {
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
full_name === undefined &&
|
full_name === undefined &&
|
||||||
email === undefined &&
|
email === undefined &&
|
||||||
avatar_url === undefined
|
avatar_url === undefined &&
|
||||||
|
provider === undefined
|
||||||
)
|
)
|
||||||
throw new Error('No profile data provided');
|
throw new Error('No profile data provided');
|
||||||
|
|
||||||
@ -59,11 +106,21 @@ export const updateProfile = async ({
|
|||||||
...(full_name !== undefined && { full_name }),
|
...(full_name !== undefined && { full_name }),
|
||||||
...(email !== undefined && { email }),
|
...(email !== undefined && { email }),
|
||||||
...(avatar_url !== undefined && { avatar_url }),
|
...(avatar_url !== undefined && { avatar_url }),
|
||||||
|
...(provider !== undefined && { provider }),
|
||||||
})
|
})
|
||||||
.eq('id', userResponse.data.id)
|
.eq('id', userResponse.data.id)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (error) throw error;
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: data as Profile,
|
data: data as Profile,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
import { createServerClient } from '@/utils/supabase';
|
import { createServerClient } from '@/utils/supabase';
|
||||||
import type { Profile, Result } 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 = {
|
export type UserWithStatus = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -24,6 +24,16 @@ type PaginatedHistory = {
|
|||||||
export const getRecentUsersWithStatuses = async (): Promise<
|
export const getRecentUsersWithStatuses = async (): Promise<
|
||||||
Result<UserWithStatus[]>
|
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 {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
||||||
@ -55,7 +65,20 @@ export const getRecentUsersWithStatuses = async (): Promise<
|
|||||||
return true;
|
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) {
|
} catch (error) {
|
||||||
return { success: false, error: `Error: ${error as Error}` };
|
return { success: false, error: `Error: ${error as Error}` };
|
||||||
}
|
}
|
||||||
@ -91,48 +114,40 @@ export const broadcastStatusUpdates = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateStatuses = async (
|
export const updateStatuses = async (
|
||||||
userIds: string[],
|
usersWithStatuses: UserWithStatus[],
|
||||||
status: string,
|
status: string,
|
||||||
): Promise<Result<void>> => {
|
): Promise<Result<void>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const userResponse = await getUser();
|
const profileResponse = await getProfileWithAvatar();
|
||||||
if (!userResponse.success) throw new Error('Not authenticated!');
|
if (!profileResponse.success) throw new Error('Not authenticated!');
|
||||||
const profileResponse = await getProfile();
|
const user = profileResponse.data;
|
||||||
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 { data: insertedStatuses, error: insertedStatusesError } =
|
const { data: insertedStatuses, error: insertedStatusesError } =
|
||||||
await supabase.from('statuses').insert(inserts).select();
|
await supabase
|
||||||
if (insertedStatusesError) throw insertedStatusesError as Error;
|
.from('statuses')
|
||||||
|
.insert(
|
||||||
|
usersWithStatuses.map((userWithStatus) => ({
|
||||||
|
user_id: userWithStatus.user.id,
|
||||||
|
status,
|
||||||
|
updated_by_id: user.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.select();
|
||||||
|
|
||||||
if (insertedStatuses) {
|
if (insertedStatusesError) throw new Error("Couldn't insert statuses!");
|
||||||
const broadcastArray = new Array<UserWithStatus>(insertedStatuses.length);
|
else if (insertedStatuses) {
|
||||||
for (const insertedStatus of insertedStatuses) {
|
const createdAtFallback = new Date(Date.now()).toISOString();
|
||||||
const { data: profile, error: profileError } = await supabase
|
await broadcastStatusUpdates(
|
||||||
.from('profiles')
|
usersWithStatuses.map((s, i) => {
|
||||||
.select('*')
|
return {
|
||||||
.eq('id', insertedStatus.user_id)
|
user: s.user,
|
||||||
.single();
|
status: status,
|
||||||
if (profileError) throw profileError as Error;
|
created_at: insertedStatuses[i]?.created_at ?? createdAtFallback,
|
||||||
|
updated_by: user,
|
||||||
if (profile) {
|
};
|
||||||
broadcastArray.push({
|
}),
|
||||||
user: profile,
|
);
|
||||||
status: insertedStatus.status,
|
|
||||||
created_at: insertedStatus.created_at,
|
|
||||||
updated_by: userProfile,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await broadcastStatusUpdates(broadcastArray);
|
|
||||||
}
|
}
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -148,33 +163,31 @@ export const updateUserStatus = async (
|
|||||||
): Promise<Result<void>> => {
|
): Promise<Result<void>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = await createServerClient();
|
const supabase = await createServerClient();
|
||||||
const userResponse = await getUser();
|
const profileResponse = await getProfileWithAvatar();
|
||||||
if (!userResponse.success)
|
|
||||||
throw new Error(`Not authenticated! ${userResponse.error}`);
|
|
||||||
const profileResponse = await getProfile();
|
|
||||||
if (!profileResponse.success)
|
if (!profileResponse.success)
|
||||||
throw new Error(`Could not get profile! ${profileResponse.error}`);
|
throw new Error(`Not authenticated! ${profileResponse.error}`);
|
||||||
const user = userResponse.data;
|
|
||||||
const userProfile = profileResponse.data;
|
const userProfile = profileResponse.data;
|
||||||
|
|
||||||
const { data: insertedStatus, error: insertedStatusError } = await supabase
|
const { data: insertedStatus, error: insertedStatusError } = await supabase
|
||||||
.from('statuses')
|
.from('statuses')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: userProfile.id,
|
||||||
status,
|
status,
|
||||||
updated_by_id: user.id,
|
updated_by_id: userProfile.id,
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (insertedStatusError) throw insertedStatusError as Error;
|
if (insertedStatusError) throw insertedStatusError as Error;
|
||||||
|
|
||||||
const userStatus: UserWithStatus = {
|
await broadcastStatusUpdates([
|
||||||
user: userProfile,
|
{
|
||||||
status: insertedStatus.status,
|
user: userProfile,
|
||||||
created_at: insertedStatus.created_at,
|
status: insertedStatus.status,
|
||||||
};
|
created_at: insertedStatus.created_at,
|
||||||
|
updated_by: userProfile,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
await broadcastStatusUpdates([userStatus]);
|
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@ -220,14 +233,6 @@ export const getUserHistory = async (
|
|||||||
};
|
};
|
||||||
if (statusesError) throw statusesError as Error;
|
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 totalCount = count ?? 0;
|
||||||
const totalPages = Math.ceil(totalCount / perPage);
|
const totalPages = Math.ceil(totalCount / perPage);
|
||||||
|
|
||||||
|
@ -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 supabase = createClient();
|
||||||
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
||||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
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 };
|
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 supabase = createClient();
|
||||||
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
const origin = process.env.NEXT_PUBLIC_SITE_URL!;
|
||||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||||
provider: 'apple',
|
provider: 'apple',
|
||||||
options: {
|
options: {
|
||||||
|
scopes: 'openid profile email offline_access',
|
||||||
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
redirectTo: `${origin}/auth/callback?redirect_to=/auth/success`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (error) return { success: false, error: error.message };
|
if (error) return { success: false, error: error.message };
|
||||||
return { success: true, data: data.url };
|
return { success: true, data };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const forgotPassword = async (
|
export const forgotPassword = async (
|
||||||
|
@ -3,6 +3,8 @@ export * from './public';
|
|||||||
export * from './status';
|
export * from './status';
|
||||||
export * from './storage';
|
export * from './storage';
|
||||||
export * from './useFileUpload';
|
export * from './useFileUpload';
|
||||||
|
export * from './useStatusSubscription';
|
||||||
|
export * from './useStatusData';
|
||||||
|
|
||||||
export type Result<T> =
|
export type Result<T> =
|
||||||
| { success: true; data: T }
|
| { success: true; data: T }
|
||||||
|
@ -1,21 +1,66 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createClient, type Profile } from '@/utils/supabase';
|
import { createClient, type Profile } from '@/utils/supabase';
|
||||||
import { getUser } from '@/lib/hooks';
|
import { getSignedUrl, getUser } from '@/lib/hooks';
|
||||||
import type { Result } from '.';
|
import type { Result } from '.';
|
||||||
|
|
||||||
export const getProfile = async (): Promise<Result<Profile>> => {
|
export const getProfile = async (
|
||||||
|
userId: string | null = null,
|
||||||
|
): Promise<Result<Profile>> => {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
if (userId == null) {
|
||||||
if (!user.success || user.data === undefined)
|
const user = await getUser();
|
||||||
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 supabase = createClient();
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('id', user.data.id)
|
.eq('id', userId)
|
||||||
.single();
|
.single();
|
||||||
if (error) throw error;
|
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 };
|
return { success: true, data: data as Profile };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@ -32,18 +77,21 @@ type updateProfileProps = {
|
|||||||
full_name?: string;
|
full_name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
|
provider?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProfile = async ({
|
export const updateProfile = async ({
|
||||||
full_name,
|
full_name,
|
||||||
email,
|
email,
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
provider,
|
||||||
}: updateProfileProps): Promise<Result<Profile>> => {
|
}: updateProfileProps): Promise<Result<Profile>> => {
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
full_name === undefined &&
|
full_name === undefined &&
|
||||||
email === undefined &&
|
email === undefined &&
|
||||||
avatar_url === undefined
|
avatar_url === undefined &&
|
||||||
|
provider === undefined
|
||||||
)
|
)
|
||||||
throw new Error('No profile data provided');
|
throw new Error('No profile data provided');
|
||||||
|
|
||||||
@ -58,11 +106,21 @@ export const updateProfile = async ({
|
|||||||
...(full_name !== undefined && { full_name }),
|
...(full_name !== undefined && { full_name }),
|
||||||
...(email !== undefined && { email }),
|
...(email !== undefined && { email }),
|
||||||
...(avatar_url !== undefined && { avatar_url }),
|
...(avatar_url !== undefined && { avatar_url }),
|
||||||
|
...(provider !== undefined && { provider }),
|
||||||
})
|
})
|
||||||
.eq('id', userResponse.data.id)
|
.eq('id', userResponse.data.id)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (error) throw error;
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: data as Profile,
|
data: data as Profile,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { createClient } from '@/utils/supabase';
|
import { createClient } from '@/utils/supabase';
|
||||||
import type { Profile, Result } 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 = {
|
export type UserWithStatus = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -24,18 +24,30 @@ type PaginatedHistory = {
|
|||||||
export const getRecentUsersWithStatuses = async (): Promise<
|
export const getRecentUsersWithStatuses = async (): Promise<
|
||||||
Result<UserWithStatus[]>
|
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 {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
const { data, error } = (await supabase
|
const { data, error } = (await supabase
|
||||||
.from('statuses')
|
.from('statuses')
|
||||||
.select(`
|
.select(
|
||||||
|
`
|
||||||
user:profiles!user_id(*),
|
user:profiles!user_id(*),
|
||||||
status,
|
status,
|
||||||
created_at,
|
created_at,
|
||||||
updated_by:profiles!updated_by_id(*)
|
updated_by:profiles!updated_by_id(*)
|
||||||
`)
|
`,
|
||||||
|
)
|
||||||
.gte('created_at', oneDayAgo.toISOString())
|
.gte('created_at', oneDayAgo.toISOString())
|
||||||
.order('created_at', { ascending: false })) as {
|
.order('created_at', { ascending: false })) as {
|
||||||
data: UserWithStatus[];
|
data: UserWithStatus[];
|
||||||
@ -53,7 +65,20 @@ export const getRecentUsersWithStatuses = async (): Promise<
|
|||||||
return true;
|
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) {
|
} catch (error) {
|
||||||
return { success: false, error: `Error: ${error as Error}` };
|
return { success: false, error: `Error: ${error as Error}` };
|
||||||
}
|
}
|
||||||
@ -89,92 +114,81 @@ export const broadcastStatusUpdates = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateStatuses = async (
|
export const updateStatuses = async (
|
||||||
userIds: string[],
|
usersWithStatuses: UserWithStatus[],
|
||||||
status: string,
|
status: string,
|
||||||
): Promise<Result<void>> => {
|
): Promise<Result<UserWithStatus[]>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const userResponse = await getUser();
|
const profileResponse = await getProfileWithAvatar();
|
||||||
if (!userResponse.success) throw new Error('Not authenticated!');
|
if (!profileResponse.success) throw new Error('Not authenticated!');
|
||||||
const profileResponse = await getProfile();
|
const user = profileResponse.data;
|
||||||
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 { data: insertedStatuses, error: insertedStatusesError } =
|
const { data: insertedStatuses, error: insertedStatusesError } =
|
||||||
await supabase.from('statuses').insert(inserts).select();
|
await supabase
|
||||||
if (insertedStatusesError) throw insertedStatusesError as Error;
|
.from('statuses')
|
||||||
|
.insert(
|
||||||
|
usersWithStatuses.map((userWithStatus) => ({
|
||||||
|
user_id: userWithStatus.user.id,
|
||||||
|
status,
|
||||||
|
updated_by_id: user.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.select();
|
||||||
|
|
||||||
if (insertedStatuses) {
|
if (insertedStatusesError) throw new Error('Error inserting statuses!');
|
||||||
const broadcastArray = new Array<UserWithStatus>(insertedStatuses.length);
|
else if (insertedStatuses) {
|
||||||
for (const insertedStatus of insertedStatuses) {
|
const createdAtFallback = new Date(Date.now()).toISOString();
|
||||||
const { data: profile, error: profileError } = await supabase
|
const statusUpdates = usersWithStatuses.map((s, i) => {
|
||||||
.from('profiles')
|
return {
|
||||||
.select('*')
|
user: s.user,
|
||||||
.eq('id', insertedStatus.user_id)
|
status: status,
|
||||||
.single();
|
created_at: insertedStatuses[i]?.created_at ?? createdAtFallback,
|
||||||
if (profileError) throw profileError as Error;
|
updated_by: user,
|
||||||
|
};
|
||||||
if (profile) {
|
});
|
||||||
broadcastArray.push({
|
await broadcastStatusUpdates(statusUpdates);
|
||||||
user: profile,
|
return { success: true, data: statusUpdates };
|
||||||
status: insertedStatus.status,
|
} else {
|
||||||
created_at: insertedStatus.created_at,
|
return { success: false, error: 'No inserted statuses returned!' };
|
||||||
updated_by: userProfile,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await broadcastStatusUpdates(broadcastArray);
|
|
||||||
}
|
}
|
||||||
return { success: true, data: undefined };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: `Error updating statuses: ${error as Error}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateUserStatus = async (
|
export const updateUserStatus = async (
|
||||||
status: string,
|
status: string,
|
||||||
): Promise<Result<void>> => {
|
): Promise<Result<UserWithStatus[]>> => {
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const userResponse = await getUser();
|
const profileResponse = await getProfileWithAvatar();
|
||||||
if (!userResponse.success)
|
|
||||||
throw new Error(`Not authenticated! ${userResponse.error}`);
|
|
||||||
const profileResponse = await getProfile();
|
|
||||||
if (!profileResponse.success)
|
if (!profileResponse.success)
|
||||||
throw new Error(`Could not get profile! ${profileResponse.error}`);
|
throw new Error(`Not authenticated! ${profileResponse.error}`);
|
||||||
const user = userResponse.data;
|
|
||||||
const userProfile = profileResponse.data;
|
const userProfile = profileResponse.data;
|
||||||
|
|
||||||
const { data: insertedStatus, error: insertedStatusError } = await supabase
|
const { data: insertedStatus, error: insertedStatusError } = await supabase
|
||||||
.from('statuses')
|
.from('statuses')
|
||||||
.insert({
|
.insert({
|
||||||
user_id: user.id,
|
user_id: userProfile.id,
|
||||||
status,
|
status,
|
||||||
updated_by_id: user.id,
|
updated_by_id: userProfile.id,
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (insertedStatusError) throw insertedStatusError as Error;
|
if (insertedStatusError) throw insertedStatusError as Error;
|
||||||
|
|
||||||
const userStatus: UserWithStatus = {
|
const statusUpdate = {
|
||||||
user: userProfile,
|
user: userProfile,
|
||||||
status: insertedStatus.status,
|
status: insertedStatus.status,
|
||||||
created_at: insertedStatus.created_at,
|
created_at: insertedStatus.created_at,
|
||||||
updated_by: userProfile,
|
updated_by: userProfile,
|
||||||
};
|
};
|
||||||
|
await broadcastStatusUpdates([statusUpdate]);
|
||||||
|
|
||||||
await broadcastStatusUpdates([userStatus]);
|
return { success: true, data: [statusUpdate] };
|
||||||
return { success: true, data: undefined };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@ -219,14 +233,6 @@ export const getUserHistory = async (
|
|||||||
};
|
};
|
||||||
if (statusesError) throw statusesError as Error;
|
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 totalCount = count ?? 0;
|
||||||
const totalPages = Math.ceil(totalCount / perPage);
|
const totalPages = Math.ceil(totalCount / perPage);
|
||||||
|
|
||||||
|
132
src/lib/hooks/useStatusData.ts
Normal file
132
src/lib/hooks/useStatusData.ts
Normal 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, // 30 seconds
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
259
src/lib/hooks/useStatusSubscription.ts
Normal file
259
src/lib/hooks/useStatusSubscription.ts
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect, useCallback, useRef } 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>();
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
let reconnectTimeout: NodeJS.Timeout | undefined;
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const notifySubscribers = (status: ConnectionStatus) => {
|
||||||
|
console.log(
|
||||||
|
'📢 notifySubscribers: Notifying',
|
||||||
|
subscribers.size,
|
||||||
|
'subscribers of status change to:',
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
sharedConnectionStatus = status;
|
||||||
|
subscribers.forEach((callback, index) => {
|
||||||
|
console.log('📢 notifySubscribers: Calling subscriber', index + 1);
|
||||||
|
callback(status);
|
||||||
|
});
|
||||||
|
console.log('📢 notifySubscribers: All subscribers notified');
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyStatusUpdate = () => {
|
||||||
|
console.log(
|
||||||
|
'🔄 notifyStatusUpdate: Notifying',
|
||||||
|
statusUpdateCallbacks.size,
|
||||||
|
'status update callbacks',
|
||||||
|
);
|
||||||
|
statusUpdateCallbacks.forEach((callback, index) => {
|
||||||
|
console.log('🔄 notifyStatusUpdate: Calling callback', index + 1);
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
console.log('🔄 notifyStatusUpdate: All callbacks executed');
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
console.log('🧹 cleanup: Starting cleanup process');
|
||||||
|
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
console.log('🧹 cleanup: Clearing reconnect timeout');
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sharedChannel) {
|
||||||
|
console.log('🧹 cleanup: Removing shared channel');
|
||||||
|
supabase.removeChannel(sharedChannel).catch((error) => {
|
||||||
|
console.error('❌ cleanup: Error removing shared channel:', error);
|
||||||
|
});
|
||||||
|
sharedChannel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ cleanup: Cleanup completed');
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
console.log('🔌 connect: Function called');
|
||||||
|
console.log('🔌 connect: sharedChannel exists:', !!sharedChannel);
|
||||||
|
console.log('🔌 connect: subscribers count:', subscribers.size);
|
||||||
|
|
||||||
|
if (sharedChannel) {
|
||||||
|
console.log('❌ connect: Already connected or connecting, returning early');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔌 connect: Starting connection process');
|
||||||
|
cleanup();
|
||||||
|
notifySubscribers('connecting');
|
||||||
|
|
||||||
|
console.log('🔌 connect: Creating new channel');
|
||||||
|
const channel = supabase
|
||||||
|
.channel('status_updates', {
|
||||||
|
config: { broadcast: { self: true } },
|
||||||
|
})
|
||||||
|
.on('broadcast', { event: 'status_updated' }, (payload) => {
|
||||||
|
console.log('📡 connect: Broadcast event received:', payload);
|
||||||
|
notifyStatusUpdate();
|
||||||
|
})
|
||||||
|
.subscribe((status) => {
|
||||||
|
console.log('📡 connect: Subscription status changed to:', status);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||||
|
if (status === 'SUBSCRIBED') {
|
||||||
|
console.log('✅ connect: Successfully subscribed to realtime');
|
||||||
|
notifySubscribers('connected');
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
console.log('✅ connect: Reset reconnect attempts to 0');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
||||||
|
} else if (status === 'CHANNEL_ERROR' || status === 'CLOSED') {
|
||||||
|
console.log('❌ connect: Channel error or closed, status:', status);
|
||||||
|
notifySubscribers('disconnected');
|
||||||
|
|
||||||
|
if (reconnectAttempts < 5) {
|
||||||
|
reconnectAttempts++;
|
||||||
|
const delay = 2000 * reconnectAttempts;
|
||||||
|
console.log(
|
||||||
|
'🔄 connect: Scheduling reconnection attempt',
|
||||||
|
reconnectAttempts,
|
||||||
|
'in',
|
||||||
|
delay,
|
||||||
|
'ms',
|
||||||
|
);
|
||||||
|
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
console.log('🔄 connect: Reconnection timeout executed');
|
||||||
|
if (subscribers.size > 0) {
|
||||||
|
console.log('🔄 connect: Calling connect() for reconnection');
|
||||||
|
connect();
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'❌ connect: No active subscribers, skipping reconnection',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ connect: Max reconnection attempts (5) reached');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sharedChannel = channel;
|
||||||
|
console.log('🔌 connect: Channel stored in sharedChannel variable');
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
console.log('🔌 disconnect: Function called');
|
||||||
|
cleanup();
|
||||||
|
notifySubscribers('disconnected');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useStatusSubscription = (onStatusUpdate?: () => void) => {
|
||||||
|
console.log('🚀 useSharedStatusSubscription: Hook called');
|
||||||
|
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(
|
||||||
|
sharedConnectionStatus,
|
||||||
|
);
|
||||||
|
const onStatusUpdateRef = useRef(onStatusUpdate);
|
||||||
|
const hasInitialized = useRef(false);
|
||||||
|
|
||||||
|
// Keep the ref updated
|
||||||
|
onStatusUpdateRef.current = onStatusUpdate;
|
||||||
|
|
||||||
|
// Create a stable callback
|
||||||
|
const stableOnStatusUpdate = useCallback(() => {
|
||||||
|
onStatusUpdateRef.current?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('🔧 useSharedStatusSubscription useEffect: Running');
|
||||||
|
console.log(
|
||||||
|
'🔧 useSharedStatusSubscription useEffect: hasInitialized:',
|
||||||
|
hasInitialized.current,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'🔧 useSharedStatusSubscription useEffect: Current subscribers count:',
|
||||||
|
subscribers.size,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prevent duplicate initialization
|
||||||
|
if (hasInitialized.current) {
|
||||||
|
console.log(
|
||||||
|
'🔧 useSharedStatusSubscription useEffect: Already initialized, skipping',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasInitialized.current = true;
|
||||||
|
|
||||||
|
// Subscribe to status changes
|
||||||
|
subscribers.add(setConnectionStatus);
|
||||||
|
console.log(
|
||||||
|
'🔧 useSharedStatusSubscription useEffect: Added setConnectionStatus to subscribers',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to status updates
|
||||||
|
if (onStatusUpdate) {
|
||||||
|
statusUpdateCallbacks.add(stableOnStatusUpdate);
|
||||||
|
console.log(
|
||||||
|
'🔧 useSharedStatusSubscription useEffect: Added stable onStatusUpdate callback',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect if this is the first subscriber
|
||||||
|
if (subscribers.size === 1) {
|
||||||
|
console.log(
|
||||||
|
'🔧 useSharedStatusSubscription useEffect: First subscriber, setting up connection',
|
||||||
|
);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
console.log(
|
||||||
|
'🔧 useSharedStatusSubscription useEffect: Connection timeout executed, calling connect()',
|
||||||
|
);
|
||||||
|
connect();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log(
|
||||||
|
'🔧 useSharedStatusSubscription useEffect: Cleanup - clearing connection timeout',
|
||||||
|
);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
hasInitialized.current = false;
|
||||||
|
subscribers.delete(setConnectionStatus);
|
||||||
|
statusUpdateCallbacks.delete(stableOnStatusUpdate);
|
||||||
|
|
||||||
|
if (subscribers.size === 0) {
|
||||||
|
disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log(
|
||||||
|
'🔧 useSharedStatusSubscription useEffect: Cleanup function running',
|
||||||
|
);
|
||||||
|
hasInitialized.current = false;
|
||||||
|
subscribers.delete(setConnectionStatus);
|
||||||
|
statusUpdateCallbacks.delete(stableOnStatusUpdate);
|
||||||
|
|
||||||
|
if (subscribers.size === 0) {
|
||||||
|
console.log(
|
||||||
|
'🔧 useSharedStatusSubscription useEffect: No more subscribers, calling disconnect()',
|
||||||
|
);
|
||||||
|
disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []); // Empty dependency array!
|
||||||
|
|
||||||
|
const reconnect = useCallback(() => {
|
||||||
|
console.log('🔄 reconnect: Function called');
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
console.log(
|
||||||
|
'🔄 reconnect: Reset reconnectAttempts to 0, calling connect()',
|
||||||
|
);
|
||||||
|
connect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'🏁 useSharedStatusSubscription: connectionStatus:',
|
||||||
|
connectionStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionStatus,
|
||||||
|
connect: reconnect,
|
||||||
|
disconnect,
|
||||||
|
};
|
||||||
|
};
|
@ -4,3 +4,33 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const makeConditionalClassName = ({
|
||||||
|
context,
|
||||||
|
defaultClassName,
|
||||||
|
on = '',
|
||||||
|
off = '',
|
||||||
|
}: {
|
||||||
|
context: boolean;
|
||||||
|
defaultClassName: string;
|
||||||
|
on?: string;
|
||||||
|
off?: string;
|
||||||
|
}) => {
|
||||||
|
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}`;
|
||||||
|
};
|
||||||
|
@ -4,6 +4,8 @@ import { updateSession } from '@/utils/supabase/middleware';
|
|||||||
// In-memory store for tracking IPs (use Redis in production)
|
// In-memory store for tracking IPs (use Redis in production)
|
||||||
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
const ipAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
||||||
const bannedIPs = new Set<string>();
|
const bannedIPs = new Set<string>();
|
||||||
|
// Ban Arctic Wolf Explicitly
|
||||||
|
bannedIPs.add('::ffff:10.0.1.49');
|
||||||
|
|
||||||
// Suspicious patterns that indicate malicious activity
|
// Suspicious patterns that indicate malicious activity
|
||||||
const MALICIOUS_PATTERNS = [
|
const MALICIOUS_PATTERNS = [
|
||||||
@ -93,7 +95,7 @@ export const middleware = async (request: NextRequest) => {
|
|||||||
|
|
||||||
// Check if IP is already banned
|
// Check if IP is already banned
|
||||||
if (bannedIPs.has(ip)) {
|
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 });
|
return new NextResponse('Access denied.', { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,13 +104,15 @@ export const middleware = async (request: NextRequest) => {
|
|||||||
const isSuspiciousMethod = isMethodSuspicious(method);
|
const isSuspiciousMethod = isMethodSuspicious(method);
|
||||||
|
|
||||||
if (isSuspiciousPath || isSuspiciousMethod) {
|
if (isSuspiciousPath || isSuspiciousMethod) {
|
||||||
console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`);
|
//console.log(`⚠️ Suspicious activity from ${ip}: ${method} ${pathname}`);
|
||||||
|
|
||||||
const shouldBan = updateIPAttempts(ip);
|
const shouldBan = updateIPAttempts(ip);
|
||||||
|
|
||||||
if (shouldBan) {
|
if (shouldBan) {
|
||||||
console.log(`🔨 IP ${ip} has been banned for suspicious activity`);
|
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
|
// Return 404 to not reveal the blocking mechanism
|
||||||
|
@ -110,6 +110,10 @@ AZURE_REDIRECT_URI=
|
|||||||
AZURE_TENANT_ID=
|
AZURE_TENANT_ID=
|
||||||
AZURE_TENANT_URL=
|
AZURE_TENANT_URL=
|
||||||
|
|
||||||
|
# Gib's Auth (Trying to set up Authentik)
|
||||||
|
#SAML_ENABLED=false
|
||||||
|
#SAML_PRIVATE_KEY=
|
||||||
|
|
||||||
|
|
||||||
############
|
############
|
||||||
# Studio - Configuration for the Dashboard
|
# Studio - Configuration for the Dashboard
|
||||||
|
@ -5,22 +5,22 @@
|
|||||||
# Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans
|
# Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans
|
||||||
# Reset everything: ./reset.sh
|
# Reset everything: ./reset.sh
|
||||||
|
|
||||||
name: supabase
|
name: techtracker
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
supabase-network:
|
techtracker:
|
||||||
name: supabase-network
|
name: techtracker
|
||||||
driver: bridge
|
driver: bridge
|
||||||
ipam:
|
ipam:
|
||||||
config:
|
config:
|
||||||
- subnet: 172.20.0.0/16
|
- subnet: 172.19.0.0/16
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
studio:
|
studio:
|
||||||
container_name: supabase-studio
|
container_name: supabase-studio
|
||||||
image: supabase/studio:2025.05.19-sha-3487831
|
image: supabase/studio:2025.05.19-sha-3487831
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
@ -61,7 +61,7 @@ services:
|
|||||||
kong:
|
kong:
|
||||||
container_name: supabase-kong
|
container_name: supabase-kong
|
||||||
image: kong:2.8.1
|
image: kong:2.8.1
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- ${KONG_HTTP_PORT}:8000/tcp
|
- ${KONG_HTTP_PORT}:8000/tcp
|
||||||
@ -90,7 +90,7 @@ services:
|
|||||||
auth:
|
auth:
|
||||||
container_name: supabase-auth
|
container_name: supabase-auth
|
||||||
image: supabase/gotrue:v2.172.1
|
image: supabase/gotrue:v2.172.1
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
@ -173,7 +173,7 @@ services:
|
|||||||
GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID}
|
GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID}
|
||||||
GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET}
|
GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET}
|
||||||
GOTRUE_EXTERNAL_AZURE_TENANT_ID: ${AZURE_TENANT_ID}
|
GOTRUE_EXTERNAL_AZURE_TENANT_ID: ${AZURE_TENANT_ID}
|
||||||
GOTRUE_EXTERNAL_AZURE_TENANT_URL: ${AZURE_TENANT_URL}
|
GOTRUE_EXTERNAL_AZURE_URL: ${AZURE_TENANT_URL}
|
||||||
GOTRUE_EXTERNAL_AZURE_REDIRECT_URI: ${AZURE_REDIRECT_URI}
|
GOTRUE_EXTERNAL_AZURE_REDIRECT_URI: ${AZURE_REDIRECT_URI}
|
||||||
|
|
||||||
# Uncomment to enable custom access token hook. Please see: https://supabase.com/docs/guides/auth/auth-hooks for full list of hooks and additional details about custom_access_token_hook
|
# Uncomment to enable custom access token hook. Please see: https://supabase.com/docs/guides/auth/auth-hooks for full list of hooks and additional details about custom_access_token_hook
|
||||||
@ -199,7 +199,7 @@ services:
|
|||||||
rest:
|
rest:
|
||||||
container_name: supabase-rest
|
container_name: supabase-rest
|
||||||
image: postgrest/postgrest:v12.2.12
|
image: postgrest/postgrest:v12.2.12
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@ -224,7 +224,7 @@ services:
|
|||||||
# This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain
|
# This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain
|
||||||
container_name: realtime-dev.supabase-realtime
|
container_name: realtime-dev.supabase-realtime
|
||||||
image: supabase/realtime:v2.34.47
|
image: supabase/realtime:v2.34.47
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@ -270,7 +270,7 @@ services:
|
|||||||
storage:
|
storage:
|
||||||
container_name: supabase-storage
|
container_name: supabase-storage
|
||||||
image: supabase/storage-api:v1.22.17
|
image: supabase/storage-api:v1.22.17
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./volumes/storage:/var/lib/storage:z
|
- ./volumes/storage:/var/lib/storage:z
|
||||||
@ -314,7 +314,7 @@ services:
|
|||||||
imgproxy:
|
imgproxy:
|
||||||
container_name: supabase-imgproxy
|
container_name: supabase-imgproxy
|
||||||
image: darthsim/imgproxy:v3.8.0
|
image: darthsim/imgproxy:v3.8.0
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./volumes/storage:/var/lib/storage:z
|
- ./volumes/storage:/var/lib/storage:z
|
||||||
@ -337,7 +337,7 @@ services:
|
|||||||
meta:
|
meta:
|
||||||
container_name: supabase-meta
|
container_name: supabase-meta
|
||||||
image: supabase/postgres-meta:v0.89.0
|
image: supabase/postgres-meta:v0.89.0
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@ -356,7 +356,7 @@ services:
|
|||||||
functions:
|
functions:
|
||||||
container_name: supabase-edge-functions
|
container_name: supabase-edge-functions
|
||||||
image: supabase/edge-runtime:v1.67.4
|
image: supabase/edge-runtime:v1.67.4
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./volumes/functions:/home/deno/functions:Z
|
- ./volumes/functions:/home/deno/functions:Z
|
||||||
@ -381,7 +381,7 @@ services:
|
|||||||
analytics:
|
analytics:
|
||||||
container_name: supabase-analytics
|
container_name: supabase-analytics
|
||||||
image: supabase/logflare:1.12.0
|
image: supabase/logflare:1.12.0
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 4000:4000
|
- 4000:4000
|
||||||
@ -430,7 +430,7 @@ services:
|
|||||||
db:
|
db:
|
||||||
container_name: supabase-db
|
container_name: supabase-db
|
||||||
image: supabase/postgres:15.8.1.060
|
image: supabase/postgres:15.8.1.060
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
ports:
|
ports:
|
||||||
- ${POSTGRES_PORT}:${POSTGRES_PORT}
|
- ${POSTGRES_PORT}:${POSTGRES_PORT}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@ -450,6 +450,8 @@ services:
|
|||||||
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
|
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
|
||||||
# Changes required for Pooler support
|
# Changes required for Pooler support
|
||||||
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
|
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
|
||||||
|
# Initial SQL that should run
|
||||||
|
- ../db/schema.sql:/docker-entrypoint-initdb.d/seed.sql
|
||||||
# Use named volume to persist pgsodium decryption key between restarts
|
# Use named volume to persist pgsodium decryption key between restarts
|
||||||
- db-config:/etc/postgresql-custom
|
- db-config:/etc/postgresql-custom
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -490,7 +492,7 @@ services:
|
|||||||
vector:
|
vector:
|
||||||
container_name: supabase-vector
|
container_name: supabase-vector
|
||||||
image: timberio/vector:0.28.1-alpine
|
image: timberio/vector:0.28.1-alpine
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
|
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
|
||||||
@ -522,7 +524,7 @@ services:
|
|||||||
supavisor:
|
supavisor:
|
||||||
container_name: supabase-pooler
|
container_name: supabase-pooler
|
||||||
image: supabase/supavisor:2.5.1
|
image: supabase/supavisor:2.5.1
|
||||||
networks: [supabase-network]
|
networks: [techtracker]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
#- ${POSTGRES_PORT}:5432
|
#- ${POSTGRES_PORT}:5432
|
||||||
@ -574,3 +576,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-config:
|
db-config:
|
||||||
|
name: techtracker-config
|
||||||
|
Reference in New Issue
Block a user