UI+ Routes

Made even more changes the UI and added new Routes for searching a user, requesting consent for messaging and others.
Now just need to make the SSE work.
This commit is contained in:
Nyxian 2024-12-12 08:56:11 -03:00
parent 25b379aadd
commit 79bdca973c
25 changed files with 1884 additions and 148 deletions

537
package-lock.json generated
View file

@ -8,7 +8,10 @@
"name": "sipher",
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.2.1",
@ -682,6 +685,63 @@
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.1.tgz",
"integrity": "sha512-bg/l7l5QzUjgsh8kjwDFommzAshnUsuVMV5NM56QVCm+7ZckYdd9P/ExR8xG/Oup0OajVxNLaHJ1tb8mXk+nzQ==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-collapsible": "1.1.1",
"@radix-ui/react-collection": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz",
"integrity": "sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dialog": "1.1.2",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz",
@ -730,6 +790,35 @@
}
}
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.1.tgz",
"integrity": "sha512-1///SnrfQHJEofLokyczERxQbWfCGQlQ2XsCZMucVs6it+lq9iw4vXy+uDn1edlb58cOZOWSldnfPAYcT4O/Yg==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz",
@ -797,6 +886,149 @@
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz",
"integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.1",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.2",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.6.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz",
"integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.6",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll/node_modules/react-remove-scroll-bar": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz",
"integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==",
"dependencies": {
"react-style-singleton": "^2.2.1",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll/node_modules/react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
"integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==",
"dependencies": {
"get-nonce": "^1.0.0",
"invariant": "^2.2.4",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll/node_modules/use-callback-ref": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz",
"integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll/node_modules/use-sidecar": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
"integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@ -837,6 +1069,72 @@
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz",
"integrity": "sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-menu": "2.1.2",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
"integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz",
"integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-icons": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
@ -885,6 +1183,153 @@
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz",
"integrity": "sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-collection": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.1",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-popper": "1.2.0",
"@radix-ui/react-portal": "1.1.2",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-roving-focus": "1.1.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.6.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/react-remove-scroll": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz",
"integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.6",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/react-remove-scroll/node_modules/react-remove-scroll-bar": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz",
"integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==",
"dependencies": {
"react-style-singleton": "^2.2.1",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/react-remove-scroll/node_modules/react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
"integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==",
"dependencies": {
"get-nonce": "^1.0.0",
"invariant": "^2.2.4",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/react-remove-scroll/node_modules/use-callback-ref": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz",
"integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/react-remove-scroll/node_modules/use-sidecar": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
"integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",
@ -1000,6 +1445,50 @@
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz",
"integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-collection": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz",
"integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.1.tgz",
@ -1456,6 +1945,17 @@
"node": ">=16.17.0"
}
},
"node_modules/aria-hidden": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz",
"integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -1681,6 +2181,11 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -1808,6 +2313,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"engines": {
"node": ">=6"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@ -1849,6 +2362,14 @@
"node": ">= 0.4"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
@ -1942,6 +2463,11 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@ -1958,6 +2484,17 @@
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",

View file

@ -9,7 +9,10 @@
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.2.1",

View file

@ -2,7 +2,7 @@ import {createClient} from "@/lib/supabase/server";
import {NextResponse} from "next/server";
// Helper function to get user data by UUID
async function getUserByUUID(supabase: any, uuid: string) {
export async function getUserByUUID(supabase: any, uuid: string) {
const {data: userData, error: userError} = await supabase
.from('users')
.select('*')

View file

@ -0,0 +1,74 @@
// app/api/realtime/route.ts
import {createClient} from '@/lib/supabase/server'
export async function GET(request: Request) {
const supabase = await createClient()
console.log("Updated")
// Get the current authenticated user
const {data: {user}, error: userError} = await supabase.auth.getUser()
// If any of these, return a default error.
if (userError || !user) {
return new Response('Unauthorized', {status: 401})
}
// Start the stream of data
const stream = new TransformStream()
const writer = stream.writable.getWriter()
const encoder = new TextEncoder()
// Create the channel
let channel: ReturnType<typeof supabase.channel> | null = null
try {
// RealTime supabase!
channel = supabase
.channel('user-requests')
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'users',
filter: `uuid=eq.${user.id}`,
},
async (payload) => {
if (payload.new.requests !== payload.old.requests) {
try {
const data = encoder.encode(`data: ${JSON.stringify({
type: 'requests_update',
data: payload.new.requests
})}\n\n`)
await writer.write(data)
} catch (error) {
console.error('Error writing to stream:', error)
}
}
}
)
.subscribe()
const initialData = encoder.encode(`data: ${JSON.stringify({
type: 'connected',
message: 'SSE connection established'
})}\n\n`)
await writer.write(initialData)
request.signal.addEventListener('abort', () => {
channel?.unsubscribe()
writer.close()
})
} catch (error) {
console.error('Error in SSE setup:', error)
return new Response('Error setting up SSE', {status: 500})
}
return new Response(stream.readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}

View file

@ -0,0 +1,47 @@
import {createClient} from "@/lib/supabase/server";
import {NextResponse} from "next/server";
export async function GET(request: Request) {
try {
const supabase = await createClient();
const {searchParams} = new URL(request.url);
const uuid = searchParams.get('uuid');
console.log('Searching for UUID:', uuid);
if (!uuid) {
return NextResponse.json({error: "Missing UUID from request"}, {status: 400})
} else if (uuid.length > 10) {
return NextResponse.json({error: "UUID is not valid."}, {status: 400});
}
const {data: {user}, error: userError} = await supabase.auth.getUser()
if (userError) {
return NextResponse.json(
{error: userError},
{status: userError?.status}
)
} else if (!user) {
return NextResponse.json(
{error: "User not found"},
{status: 401}
)
}
const rpcResult = await supabase.rpc('search_users', {
search_term: uuid
});
const {data, error} = rpcResult;
if (error) {
return NextResponse.json({error: error}, {status: 500});
} else if (data.length === 0) {
return NextResponse.json({user: []}, {status: 200});
}
return NextResponse.json({exists: !!(data[0].suuid && data[0].username)}, {status: 200});
} catch (error) {
return NextResponse.json({error: error}, {status: 500});
}
}

View file

@ -0,0 +1,66 @@
import {createClient} from "@/lib/supabase/server";
import {NextResponse} from "next/server";
import {SupabaseClient} from "@supabase/supabase-js";
import {getUserByUUID} from "@/app/api/auth/get_user/route";
async function updateUserRequests(searchTerm: string, requestSuuid: string, supabase: SupabaseClient<any, "public", any>) {
try {
const {data, error} = await supabase.rpc('update_user_requests', {
search_term: searchTerm,
new_request: requestSuuid
});
if (error) {
throw error;
}
return {success: true, data};
} catch (error) {
console.error('Error updating user requests:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}
export async function POST(request: Request) {
try {
const supabase = await createClient();
const {searchTerm} = await request.json();
if (!searchTerm) {
return NextResponse.json(
{error: "Missing required fields"},
{status: 400}
);
}
const {data: {user}, error: authError} = await supabase.auth.getUser();
if (authError) throw authError;
if (!user) {
return NextResponse.json({user: null}, {status: 401});
}
const userSuuid = (await getUserByUUID(supabase, user.id)).suuid;
const result = await updateUserRequests(searchTerm, userSuuid, supabase);
if (!result.success) {
return NextResponse.json(
{error: result.error},
{status: 500}
);
}
return NextResponse.json({success: true});
} catch (error) {
return NextResponse.json(
{error: "Failed to update requests"},
{status: 500}
);
}
}

View file

@ -9,6 +9,7 @@ import {SharedStateProvider} from "@/hooks/shared-states";
import ThemeProvider from "@/components/ui/theme-provider";
import {headers} from "next/headers";
import {Toaster} from "@/components/ui/toaster";
import {RealtimeRequests} from "@/components/main/realtime/request";
const publicSans = Public_Sans({
subsets: ['latin'],
@ -57,6 +58,7 @@ export default async function RootLayout(
<div className={`max-h-[1080px] p-6 bg-secondary`}>
<div className="flex bg-background">
<Sidebar>
<RealtimeRequests/>
{children}
</Sidebar>
</div>

View file

@ -3,18 +3,89 @@ import {useTheme} from "next-themes";
import Image from "next/image";
import {Feather, Search} from "lucide-react";
import {useEffect, useState} from "react";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
import {Separator} from "@/components/ui/separator";
import Link from "next/link";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from "@/components/ui/alert-dialog";
export default function SiPher() {
const {theme, systemTheme} = useTheme();
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [mounted, setMounted] = useState(false);
/** Consent Form states */
const [showConsentForm, setShowConsentForm] = useState(false);
const [formError, setFormError] = useState("");
/** Input states */
const [inputDisabled, setInputDisabled] = useState(false);
const [inputValue, setInputValue] = useState("");
/** Search expandability state */
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
/**
* @param search_term Either the SUUID or username (If not indexable, will return false.)
*/
const fetchUser = async (search_term: string) => {
// Search term cannot be empty
if (search_term.length <= 0) {
return false;
}
// Sends the requisition to the API by using native fetch.
const req = await fetch(`/api/user/search/user?uuid=${search_term}`);
// Checks if the response is ok (200) or not, if not, returns false.
if (!req.ok) {
return false
}
const user = await req.json() as { exists: boolean };
// If the user does not exist, just return it
if (!user.exists) return user.exists;
setShowConsentForm(true); // Shows the confirmation to ask the other user to consent to the communication;
setInputDisabled(true); // Makes the input disabled until either the user cancels the consent form or accepts it;
return user.exists; // If everything went right and the user was found, return true
}
const sendRequest = async (user: string) => {
if (user.length <= 0) {
return false;
}
const req = await fetch(`/api/user/send/request`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
searchTerm: user, // SUUID or username
})
});
if (!req.ok) return false;
const {sent} = await req.json() as { sent: boolean };
// If the user does not exist, just return it
if (!sent) return sent;
return sent;
}
const getTheme = () => {
if (!mounted) return "light";
if (theme === "system") {
@ -26,81 +97,149 @@ export default function SiPher() {
const currentTheme = getTheme();
return (
<div
className={`relative flex-1 ${currentTheme === "dark" ? "dark" : ""} w-full max-h-[600px] bg-gradient-to-b from-background to-background/95`}>
{/* Animated background elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div
className="absolute inset-0 bg-[radial-gradient(circle_500px_at_50%_50%,rgba(120,120,120,0.05),transparent)]"/>
</div>
<div className="relative flex flex-col justify-center items-center h-screen px-4 select-none space-y-8">
{/* Logo section with subtle hover effect */}
<div className="relative group">
<div
className="absolute inset-0 bg-primary/5 rounded-full blur-xl group-hover:bg-primary/10 transition-all duration-500"/>
<Image
priority
src={`/logos/logo.png`}
alt="SiPher"
width={128}
height={128}
draggable={false}
className="relative transform transition-transform duration-500 group-hover:scale-105"
/>
</div>
{/* Main text content with improved typography and spacing */}
<div className="max-w-2xl space-y-6 text-center">
<p className="text-lg md:text-xl font-medium leading-relaxed text-primary">
Where shadows dance and secrets nest, Silent Whisper serves as the dark sanctuary for those
who value discretion above all. Born from ancient corvid traditions, this messenger's haven ensures your
whispers remain unheard by all but their intended recipients.
</p>
<p className="text-sm md:text-base font-medium text-muted-foreground leading-relaxed">
Like the sacred ravens of old, your messages fly through the darkness, their contents sealed by shadows and
protected by forgotten wards. Each member of our dark fellowship is known only by their chosen name, their
true identity shrouded in mystery.
</p>
</div>
{/* Enhanced search component */}
<div className="relative mt-8">
<div
className={`flex items-center rounded-full transition-all duration-300 ${
isSearchExpanded
? "bg-secondary/30 backdrop-blur-sm border border-primary/20 shadow-lg"
: ""
}`}
style={{
width: isSearchExpanded ? "240px" : "40px",
}}
>
<button
className={`flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-full
${currentTheme === "dark" ? "hover:bg-secondary/60" : "hover:bg-primary/10"}
transition-colors duration-200`}
onClick={() => setIsSearchExpanded(!isSearchExpanded)}
>
<Search className="w-5 h-5"/>
</button>
<input
type="text"
placeholder="Find fellow shadows..."
className={`w-full bg-transparent focus:outline-none text-primary placeholder-primary/50
transition-all duration-300 ${isSearchExpanded ? "px-4" : "w-0 px-0"}`}
<>
<AlertDialog open={showConsentForm} onOpenChange={(open) => {
if (!open) setFormError("");
}}>
<AlertDialogTrigger/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Consent Form</AlertDialogTitle>
<AlertDialogDescription className={"flex flex-col space-y-1"}>
<span>
Are you sure you want to contact <span className={"font-bold"}>{inputValue}</span>?
</span>
<span>
By continuing, <span className={"font-bold"}>{inputValue}</span> will receive a notification to accept
it. If accepted, that user will appear on your sidebar, if rejected, you will never know about it.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setShowConsentForm(false);
setInputDisabled(false);
}}
>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
sendRequest(inputValue).then((result) => {
if (!result) setFormError("Could not send notification for whatever reason. Sorry.");
});
setInputDisabled(false);
}}
>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div
className={`relative flex-1 ${currentTheme === "dark" ? "dark" : ""} w-full max-h-[600px] bg-gradient-to-b from-background to-background/95`}>
<div className="relative flex flex-col justify-center items-center h-screen px-4 select-none space-y-8">
<div className="relative group">
<div
className="absolute inset-0 bg-primary/5 rounded-full blur-xl group-hover:bg-primary/10 transition-all duration-500"/>
<Image
priority
src={`/logos/logo.png`}
alt="SiPher"
width={128}
height={128}
draggable={false}
className="relative transform transition-transform duration-500 group-hover:scale-105"
/>
</div>
{/* Decorative feather icon */}
<Feather
className={`absolute -right-6 top-1/2 -translate-y-1/2 w-4 h-4 text-primary/30 transform rotate-45
<div className="max-w-2xl space-y-6 text-center">
<p className="text-lg md:text-xl font-medium leading-relaxed text-primary">
Where shadows dance and secrets nest, Silent Whisper serves as the dark sanctuary for those
who value discretion above all. Born from ancient corvid traditions, this messenger's haven ensures your
whispers remain unheard by all but their intended recipients.
</p>
<p className="text-sm md:text-base font-medium text-muted-foreground leading-relaxed">
Like the sacred ravens of old, your messages fly through the darkness, their contents sealed by shadows
and
protected by forgotten wards. Each member of our dark fellowship is known only by their chosen name, their
true identity shrouded in mystery.
</p>
</div>
<div className="relative mt-8">
<div
className={`flex items-center rounded-full transition-all duration-300 ${
isSearchExpanded
? "bg-secondary/30 backdrop-blur-sm border border-primary/20 shadow-lg"
: ""
}`}
style={{
width: isSearchExpanded ? "240px" : "40px",
}}
>
<button
className={`flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-full
${currentTheme === "dark" ? "hover:bg-secondary/60" : "hover:bg-primary/10"}
transition-colors duration-200`}
onClick={() => setIsSearchExpanded(!isSearchExpanded)}
>
<Search className="w-5 h-5"/>
</button>
<input
type="text"
placeholder="Find fellow shadows..."
className={`w-full bg-transparent focus:outline-none text-primary placeholder-primary/50
transition-all duration-300 ${isSearchExpanded ? "px-4" : "w-0 px-0"}`}
disabled={inputDisabled}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
fetchUser(inputValue).then((res) => {
console.log(res);
})
}
}}
/>
</div>
<Feather
className={`absolute -right-6 top-1/2 -translate-y-1/2 w-4 h-4 text-primary/30 transform rotate-45
transition-opacity duration-300 ${isSearchExpanded ? "opacity-100" : "opacity-0"}`}
/>
/>
</div>
<Separator/>
<div className={"flex flex-col w-[400px]"}>
<p className="text-lg md:text-xl font-medium leading-relaxed text-primary">
F.A.Q
</p>
<Accordion type={"single"} collapsible className={"w-full-30%"}>
<AccordionItem value={"works"}>
<AccordionTrigger>How does this works?</AccordionTrigger>
<AccordionContent asChild>
<Link href="/about" className={"text-primary text-lg p-1"}>
Please, click here
</Link>
</AccordionContent>
</AccordionItem>
<AccordionItem value={"exists"}>
<AccordionTrigger>Why does this exists?</AccordionTrigger>
<AccordionContent>
I made this as a CS50X final project, hence why it is not intended for real usage. (Do not use it in a
situation where you need real privacy.)
</AccordionContent>
</AccordionItem>
<AccordionItem value={"os"}>
<AccordionTrigger>Is this open-source?</AccordionTrigger>
<AccordionContent>
No, not yet (As of 11/12/2024)
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,61 @@
// components/RealtimeRequests.tsx
'use client'
import {useEffect} from 'react'
import {useToast} from "@/hooks/use-toast"
import {useUser} from "@/contexts/user"
export function RealtimeRequests() {
const {toast} = useToast()
const {user, updateUser} = useUser()
useEffect(() => {
if (!user) return
const eventSource = new EventSource('/api/user/actions/realtime/requests')
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
switch (data.type) {
case 'connected':
console.log('SSE connected:', data.message)
break
case 'requests_update':
// Update the user context with new requests
updateUser({...user, requests: data.data})
// Show a toast notification
toast({
title: "New Request",
description: "You have a new request pending",
duration: 5000,
})
break
default:
console.log('Unknown message type:', data.type)
}
} catch (error) {
console.error('Error parsing SSE message:', error)
}
}
eventSource.onerror = (error) => {
console.error('SSE error:', error)
eventSource.close()
// Optionally show an error toast
toast({
title: "Connection Error",
description: "Failed to connect to realtime updates",
variant: "destructive",
duration: 5000,
})
}
return () => {
eventSource.close()
}
}, [user, updateUser, toast])
return null
}

View file

@ -3,7 +3,7 @@ import React, {useCallback, useEffect, useState} from "react"
import {usePathname} from "next/navigation"
import Link from "next/link"
import {AnimatePresence, motion} from "framer-motion"
import {LogOut, X} from "lucide-react"
import {LogOut, Mail, MailPlus, X} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Avatar, AvatarFallback} from "@/components/ui/avatar"
import {Separator} from "@/components/ui/separator"
@ -16,6 +16,7 @@ import {useRefs, useUIState} from "@/hooks/shared-states";
import {useToast} from "@/hooks/use-toast";
import {useTheme} from "next-themes";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
type SidebarProps = {
children?: React.ReactNode
@ -38,6 +39,8 @@ function Sidebar(
const {isDrawerOpen, setIsDrawerOpen} = useUIState()
const {drawerRef} = useRefs();
const [pendingRequest, setPendingRequest] = useState<number>(0);
const user = useUser().user!;
const {
@ -45,6 +48,10 @@ function Sidebar(
suuid
} = user
useEffect(() => {
setPendingRequest(user.requests?.length || 0);
}, [user])
useEffect(() => {
const getThreads = async () => {
try {
@ -126,6 +133,31 @@ function Sidebar(
<ScrollArea className="flex-grow max-h-[590px] px-4 py-4">
<nav>
<ul className="space-y-1">
<DropdownMenu>
<DropdownMenuTrigger>
<div className={"flex flex-row items-center w-full justify-start text-[17px]"}>
{
pendingRequest > 0 ? (
<MailPlus className="w-8 h-8 mr-3 p-1"/>
) : (
<Mail className="w-8 h-8 mr-3 p-1"/>
)
}
Requests
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="px-4 py-1 w-56" side={"right"}>
{
pendingRequest > 0 && user.requests.map((request, item) => {
return (
<p>{request}</p>
)
}) || (
<p>Nothing new here</p>
)
}
</DropdownMenuContent>
</DropdownMenu>
{threads.map((thread) => (
<li key={thread.id}>
<Link href={thread.id} passHref>

View file

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View file

@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View file

@ -1,89 +1,102 @@
// contexts/user.tsx
'use client';
import {createContext, useContext} from 'react';
import {createContext, useContext, useState} from 'react';
import {useRouter} from 'next/navigation';
interface UserContextType {
user: NonNullable<SiPher.User>;
getUser: (context: string) => Promise<NonNullable<SiPher.User>>;
user: NonNullable<SiPher.User>;
getUser: (context: string, userId?: string) => Promise<NonNullable<SiPher.User>>;
updateUser: (newUserData: NonNullable<SiPher.User>) => void;
}
const UserContext = createContext<UserContextType | null>(null);
export function useUser() {
const context = useContext(UserContext);
const router = useRouter();
if (!context) {
throw new Error('useUser must be used within a UserProvider');
}
return {
user: context.user,
getUser: async (context: string, userId?: string) => {
if (process.env.NODE_ENV !== 'production') {
console.log(`useUser().getUser(): Being called by ${context}`)
}
try {
const response = await fetch(`/api/auth/get_user?${
userId && `uuid=${
encodeURIComponent(userId)
}`
}`);
if (!response.ok) {
const error = await response.json();
if (error.message?.includes("Auth session missing!")) {
throw new Error('No authenticated user');
}
throw new Error(error.message || 'Authentication failed');
}
const {user} = await response.json();
return user as NonNullable<SiPher.User>;
} catch (error) {
console.error('Failed to get user:', error);
router.push('/auth/login');
throw error;
}
},
checkAuth: async (context: string) => {
if (process.env.NODE_ENV !== 'production') {
console.log(`useUser().checkAuth(): Being called by ${context}`)
}
try {
const response = await fetch('/api/auth/get_user');
return response.ok;
} catch {
return false;
}
}
};
const context = useContext(UserContext);
const router = useRouter();
if (!context) {
throw new Error('useUser must be used within a UserProvider');
}
return {
user: context.user,
updateUser: context.updateUser,
getUser: async (context: string, userId?: string) => {
if (process.env.NODE_ENV !== 'production') {
console.log(`useUser().getUser(): Being called by ${context}`)
}
try {
const response = await fetch(`/api/auth/get_user?${
userId && `uuid=${
encodeURIComponent(userId)
}`
}`);
if (!response.ok) {
const error = await response.json();
if (error.message?.includes("Auth session missing!")) {
throw new Error('No authenticated user');
}
throw new Error(error.message || 'Authentication failed');
}
const {user} = await response.json();
return user as NonNullable<SiPher.User>;
} catch (error) {
console.error('Failed to get user:', error);
router.push('/auth/login');
throw error;
}
},
checkAuth: async (context: string) => {
if (process.env.NODE_ENV !== 'production') {
console.log(`useUser().checkAuth(): Being called by ${context}`)
}
try {
const response = await fetch('/api/auth/get_user');
return response.ok;
} catch {
return false;
}
}
};
}
export function UserProvider(
{
children,
initialUser
}: {
children: React.ReactNode;
initialUser: NonNullable<SiPher.User>;
}
{
children,
initialUser
}: {
children: React.ReactNode;
initialUser: NonNullable<SiPher.User>;
}
) {
return (
<UserContext.Provider value={{
user: initialUser,
getUser: async () => {
const response = await fetch('/api/auth/get_user');
if (!response.ok) {
throw new Error('Failed to get user');
}
const {user} = await response.json();
return user as NonNullable<SiPher.User>;
}
}}>
{children}
</UserContext.Provider>
);
const [user, setUser] = useState<NonNullable<SiPher.User>>(initialUser);
const updateUser = (newUserData: NonNullable<SiPher.User>) => {
setUser(newUserData);
};
return (
<UserContext.Provider value={{
user,
updateUser,
getUser: async (context: string, userId?: string) => {
const response = await fetch(`/api/auth/get_user?${
userId && `uuid=${
encodeURIComponent(userId)
}`
}`);
if (!response.ok) {
throw new Error('Failed to get user');
}
const {user} = await response.json();
return user as NonNullable<SiPher.User>;
}
}}>
{children}
</UserContext.Provider>
);
}

View file

@ -1,3 +1,4 @@
"use server"
import {CookieOptions, createServerClient} from '@supabase/ssr';
import {cookies} from 'next/headers';

2
src/types/user.d.ts vendored
View file

@ -24,6 +24,8 @@ declare global {
created_at: string,
/** Messages field */
messages: Messages[]
/** Consent Requests */
requests: string[] // Only accessible to the current user logged in. Will contain an array of SUUIDs
}
}
}

View file

@ -0,0 +1,37 @@
-- RLS Policies
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE message_threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE thread_participants ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Users policies
CREATE POLICY "Users can view their own profile"
ON users FOR SELECT
USING (auth.uid() = uuid);
CREATE POLICY "Users can view indexable profiles"
ON users FOR SELECT
USING (indexable = true);
CREATE POLICY "Users can update their own profile"
ON users FOR UPDATE
USING (auth.uid() = uuid);
-- Message threads policies
CREATE POLICY "Users can view their threads"
ON message_threads FOR SELECT
USING (is_thread_participant(id));
-- Thread participants policies
CREATE POLICY "Users can view their thread participants"
ON thread_participants FOR SELECT
USING (is_thread_participant(thread_id));
-- Messages policies
CREATE POLICY "Users can view their messages"
ON messages FOR SELECT
USING (is_thread_participant(thread_id));
CREATE POLICY "Users can send messages"
ON messages FOR INSERT
WITH CHECK (is_thread_participant(thread_id));

View file

@ -0,0 +1,59 @@
-- Drop the existing policy if it exists
DROP POLICY IF EXISTS "Allow SUUID searches" ON public.users;
-- Create a new policy to explicitly allow SUUID searches
CREATE POLICY "Allow SUUID searches - Exact Match" ON public.users
FOR SELECT
USING (
suuid = current_setting('request.jwt.claims')::json ->> 'search_term'
OR indexable = true
);
-- Create an alternative approach: more permissive policy for SUUID searches
CREATE POLICY "Allow SUUID searches - Permissive" ON public.users
FOR SELECT
USING (
suuid = ANY (
ARRAY (
SELECT
unnest(
regexp_split_to_array(
current_setting('request.jwt.claims')::json ->> 'search_term',
','
)
)
)
)
OR indexable = true
);
-- Create or replace the search_users function
CREATE OR REPLACE FUNCTION public.search_users (search_term TEXT)
RETURNS TABLE (
uuid UUID,
suuid TEXT,
username TEXT,
indexable BOOLEAN
) AS $$
BEGIN
-- Set the search term in the current transaction
SET LOCAL "request.jwt.claim.search_term" = search_term;
RETURN QUERY
SELECT
u.uuid,
u.suuid::TEXT,
CASE
WHEN u.suuid = search_term OR u.indexable THEN u.username
ELSE NULL
END,
u.indexable
FROM public.users u
WHERE
u.suuid = search_term
OR (
u.indexable = true AND
u.username ILIKE '%' || search_term || '%'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View file

@ -0,0 +1,5 @@
-- Indexes
CREATE INDEX idx_users_suuid ON users(suuid);
CREATE INDEX idx_users_indexable ON users(indexable) WHERE indexable = true;
CREATE INDEX idx_thread_participants_user ON thread_participants(user_uuid);
CREATE INDEX idx_messages_thread ON messages(thread_id);

View file

@ -0,0 +1,116 @@
-- For generate_short_uuid
CREATE
OR REPLACE FUNCTION public.generate_short_uuid () RETURNS TEXT AS $$
DECLARE
chars TEXT := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
result TEXT := '';
i INTEGER := 0;
max_attempts INTEGER := 10;
current_attempt INTEGER := 0;
is_unique BOOLEAN := false;
BEGIN
WHILE NOT is_unique AND current_attempt < max_attempts LOOP
result := '';
FOR i IN 1..8 LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::integer, 1);
END LOOP;
SELECT COUNT(*) = 0 INTO is_unique
FROM public.users
WHERE suuid = result;
current_attempt := current_attempt + 1;
END LOOP;
IF NOT is_unique THEN
RAISE EXCEPTION 'Could not generate unique short UUID after % attempts', max_attempts;
END IF;
RETURN result;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION public.search_users(search_term TEXT)
RETURNS TABLE (
uuid UUID,
suuid TEXT,
username TEXT,
indexable BOOLEAN
) AS $$
BEGIN
RETURN QUERY
SELECT
u.uuid,
u.suuid::TEXT,
-- Simplified CASE logic: show username if SUUID match OR (username match AND indexable)
CASE
WHEN u.suuid = search_term OR u.indexable THEN u.username
ELSE NULL
END,
u.indexable
FROM public.users u
WHERE
u.suuid = search_term -- Case 1: SUUID match (always show)
OR (
u.indexable = true AND -- Case 2: Username match + indexable
u.username ILIKE '%' || search_term || '%'
);
END;
$$ LANGUAGE plpgsql;
-- For is_thread_participant
CREATE
OR REPLACE FUNCTION public.is_thread_participant (thread_uuid UUID) RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM public.thread_participants
WHERE thread_id = thread_uuid
AND user_uuid = auth.uid()
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- For get_user_threads
CREATE
OR REPLACE FUNCTION public.get_user_threads (user_id UUID) RETURNS TABLE (
thread_id UUID,
participants TEXT[],
messages JSON[]
) AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM public.thread_participants
WHERE user_uuid = user_id
) THEN
-- Return empty result if user has no threads
RETURN;
END IF;
RETURN QUERY
SELECT
mt.id,
array_agg(DISTINCT u.username),
COALESCE(array_agg(
CASE WHEN m.id IS NOT NULL THEN
json_build_object(
'id', m.id,
'content', m.content,
'created_at', m.created_at
)
ELSE NULL END
) FILTER (WHERE m.id IS NOT NULL), ARRAY[]::JSON[])
FROM public.message_threads mt
JOIN public.thread_participants tp ON mt.id = tp.thread_id
JOIN public.users u ON tp.user_uuid = u.uuid
LEFT JOIN public.messages m ON mt.id = m.thread_id
WHERE mt.id IN (
SELECT thread_id
FROM public.thread_participants
WHERE user_uuid = user_id
)
GROUP BY mt.id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View file

@ -0,0 +1,46 @@
-- Drop everything related to users
DROP TABLE IF EXISTS public.users CASCADE;
-- Create new users table
CREATE TABLE public.users (
uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
suuid CHAR(8) UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL CHECK (length(username) >= 3 AND username ~ '^[a-zA-Z0-9_-]+$'),
indexable BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
);
-- Create trigger function for SUUID generation
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
NEW.suuid := public.generate_short_uuid();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create the trigger
CREATE TRIGGER on_user_created
BEFORE INSERT ON public.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user();
-- Add policies
CREATE POLICY "Users can view their own profile" ON public.users
FOR SELECT USING (auth.uid() = uuid);
CREATE POLICY "Users can view indexable profiles" ON public.users
FOR SELECT USING (indexable = true);
CREATE POLICY "Allow user registration" ON public.users
FOR INSERT WITH CHECK (true);
CREATE POLICY "Users can update their own profile" ON public.users
FOR UPDATE USING (auth.uid() = uuid);
-- Enable RLS
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Create index for better performance
CREATE INDEX idx_users_suuid ON public.users(suuid);
CREATE INDEX idx_users_indexable ON public.users(indexable) WHERE indexable = true;

View file

@ -0,0 +1,47 @@
-- Drop everything related to users
DROP TABLE IF EXISTS public.users CASCADE;
-- Create new users table
CREATE TABLE public.users (
uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
suuid CHAR(8) UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL CHECK (length(username) >= 3 AND username ~ '^[a-zA-Z0-9_-]+$'),
password TEXT NOT NULL CHECK (length(password) >= 8),
indexable BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
);
-- Create trigger function for SUUID generation
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
NEW.suuid := public.generate_short_uuid();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create the trigger
CREATE TRIGGER on_user_created
BEFORE INSERT ON public.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user();
-- Add policies
CREATE POLICY "Users can view their own profile" ON public.users
FOR SELECT USING (auth.uid() = uuid);
CREATE POLICY "Users can view indexable profiles" ON public.users
FOR SELECT USING (indexable = true);
CREATE POLICY "Allow user registration" ON public.users
FOR INSERT WITH CHECK (true);
CREATE POLICY "Users can update their own profile" ON public.users
FOR UPDATE USING (auth.uid() = uuid);
-- Enable RLS
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Create index for better performance
CREATE INDEX idx_users_suuid ON public.users(suuid);
CREATE INDEX idx_users_indexable ON public.users(indexable) WHERE indexable = true;

View file

@ -0,0 +1 @@
SELECT * FROM public.search_users('chCzlx84');

27
supabase/users_table.sql Normal file
View file

@ -0,0 +1,27 @@
-- Base Tables
CREATE TABLE users (
uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
suuid CHAR(8) UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL CHECK (length(username) >= 3 AND username ~ '^[a-zA-Z0-9_-]+$'),
password TEXT NOT NULL CHECK (length(password) >= 8),
indexable BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
);
CREATE TABLE message_threads (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
);
CREATE TABLE thread_participants (
thread_id UUID REFERENCES message_threads(id) ON DELETE CASCADE,
user_uuid UUID REFERENCES users(uuid) ON DELETE CASCADE,
PRIMARY KEY (thread_id, user_uuid)
);
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
thread_id UUID REFERENCES message_threads(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
);

View file

@ -55,6 +55,28 @@ export default {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},