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", "name": "sipher",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "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-avatar": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.2.1", "@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", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" "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": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", "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": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", "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": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", "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": { "node_modules/@radix-ui/react-icons": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", "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": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", "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": { "node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.1.tgz", "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": ">=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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -1681,6 +2181,11 @@
"node": ">=8" "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": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -1808,6 +2313,14 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/glob": {
"version": "10.4.5", "version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@ -1849,6 +2362,14 @@
"node": ">= 0.4" "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": { "node_modules/is-arrayish": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
@ -1942,6 +2463,11 @@
"jiti": "bin/jiti.js" "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": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "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", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" "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": { "node_modules/lru-cache": {
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",

View file

@ -9,7 +9,10 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "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-avatar": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.2.1", "@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"; import {NextResponse} from "next/server";
// Helper function to get user data by UUID // 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 const {data: userData, error: userError} = await supabase
.from('users') .from('users')
.select('*') .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 ThemeProvider from "@/components/ui/theme-provider";
import {headers} from "next/headers"; import {headers} from "next/headers";
import {Toaster} from "@/components/ui/toaster"; import {Toaster} from "@/components/ui/toaster";
import {RealtimeRequests} from "@/components/main/realtime/request";
const publicSans = Public_Sans({ const publicSans = Public_Sans({
subsets: ['latin'], subsets: ['latin'],
@ -57,6 +58,7 @@ export default async function RootLayout(
<div className={`max-h-[1080px] p-6 bg-secondary`}> <div className={`max-h-[1080px] p-6 bg-secondary`}>
<div className="flex bg-background"> <div className="flex bg-background">
<Sidebar> <Sidebar>
<RealtimeRequests/>
{children} {children}
</Sidebar> </Sidebar>
</div> </div>

View file

@ -3,18 +3,89 @@ import {useTheme} from "next-themes";
import Image from "next/image"; import Image from "next/image";
import {Feather, Search} from "lucide-react"; import {Feather, Search} from "lucide-react";
import {useEffect, useState} from "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() { export default function SiPher() {
const {theme, systemTheme} = useTheme(); const {theme, systemTheme} = useTheme();
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [mounted, setMounted] = 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(() => { useEffect(() => {
setMounted(true); 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 = () => { const getTheme = () => {
if (!mounted) return "light"; if (!mounted) return "light";
if (theme === "system") { if (theme === "system") {
@ -26,81 +97,149 @@ export default function SiPher() {
const currentTheme = getTheme(); const currentTheme = getTheme();
return ( return (
<div <>
className={`relative flex-1 ${currentTheme === "dark" ? "dark" : ""} w-full max-h-[600px] bg-gradient-to-b from-background to-background/95`}> <AlertDialog open={showConsentForm} onOpenChange={(open) => {
{/* Animated background elements */} if (!open) setFormError("");
<div className="absolute inset-0 overflow-hidden pointer-events-none"> }}>
<div <AlertDialogTrigger/>
className="absolute inset-0 bg-[radial-gradient(circle_500px_at_50%_50%,rgba(120,120,120,0.05),transparent)]"/> <AlertDialogContent>
</div> <AlertDialogHeader>
<AlertDialogTitle>Consent Form</AlertDialogTitle>
<div className="relative flex flex-col justify-center items-center h-screen px-4 select-none space-y-8"> <AlertDialogDescription className={"flex flex-col space-y-1"}>
{/* Logo section with subtle hover effect */} <span>
<div className="relative group"> Are you sure you want to contact <span className={"font-bold"}>{inputValue}</span>?
<div </span>
className="absolute inset-0 bg-primary/5 rounded-full blur-xl group-hover:bg-primary/10 transition-all duration-500"/> <span>
<Image By continuing, <span className={"font-bold"}>{inputValue}</span> will receive a notification to accept
priority it. If accepted, that user will appear on your sidebar, if rejected, you will never know about it.
src={`/logos/logo.png`} </span>
alt="SiPher" </AlertDialogDescription>
width={128} </AlertDialogHeader>
height={128} <AlertDialogFooter>
draggable={false} <AlertDialogCancel
className="relative transform transition-transform duration-500 group-hover:scale-105" onClick={() => {
/> setShowConsentForm(false);
</div> setInputDisabled(false);
}}
{/* Main text content with improved typography and spacing */} >Cancel</AlertDialogCancel>
<div className="max-w-2xl space-y-6 text-center"> <AlertDialogAction
<p className="text-lg md:text-xl font-medium leading-relaxed text-primary"> onClick={() => {
Where shadows dance and secrets nest, Silent Whisper serves as the dark sanctuary for those sendRequest(inputValue).then((result) => {
who value discretion above all. Born from ancient corvid traditions, this messenger's haven ensures your if (!result) setFormError("Could not send notification for whatever reason. Sorry.");
whispers remain unheard by all but their intended recipients. });
</p> setInputDisabled(false);
}}
<p className="text-sm md:text-base font-medium text-muted-foreground leading-relaxed"> >Continue</AlertDialogAction>
Like the sacred ravens of old, your messages fly through the darkness, their contents sealed by shadows and </AlertDialogFooter>
protected by forgotten wards. Each member of our dark fellowship is known only by their chosen name, their </AlertDialogContent>
true identity shrouded in mystery. </AlertDialog>
</p> <div
</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">
{/* Enhanced search component */} <div className="relative group">
<div className="relative mt-8"> <div
<div className="absolute inset-0 bg-primary/5 rounded-full blur-xl group-hover:bg-primary/10 transition-all duration-500"/>
className={`flex items-center rounded-full transition-all duration-300 ${ <Image
isSearchExpanded priority
? "bg-secondary/30 backdrop-blur-sm border border-primary/20 shadow-lg" src={`/logos/logo.png`}
: "" alt="SiPher"
}`} width={128}
style={{ height={128}
width: isSearchExpanded ? "240px" : "40px", draggable={false}
}} className="relative transform transition-transform duration-500 group-hover:scale-105"
>
<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"}`}
/> />
</div> </div>
{/* Decorative feather icon */} <div className="max-w-2xl space-y-6 text-center">
<Feather <p className="text-lg md:text-xl font-medium leading-relaxed text-primary">
className={`absolute -right-6 top-1/2 -translate-y-1/2 w-4 h-4 text-primary/30 transform rotate-45 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"}`} 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> </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 {usePathname} from "next/navigation"
import Link from "next/link" import Link from "next/link"
import {AnimatePresence, motion} from "framer-motion" 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 {Button} from "@/components/ui/button"
import {Avatar, AvatarFallback} from "@/components/ui/avatar" import {Avatar, AvatarFallback} from "@/components/ui/avatar"
import {Separator} from "@/components/ui/separator" import {Separator} from "@/components/ui/separator"
@ -16,6 +16,7 @@ import {useRefs, useUIState} from "@/hooks/shared-states";
import {useToast} from "@/hooks/use-toast"; import {useToast} from "@/hooks/use-toast";
import {useTheme} from "next-themes"; import {useTheme} from "next-themes";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
type SidebarProps = { type SidebarProps = {
children?: React.ReactNode children?: React.ReactNode
@ -38,6 +39,8 @@ function Sidebar(
const {isDrawerOpen, setIsDrawerOpen} = useUIState() const {isDrawerOpen, setIsDrawerOpen} = useUIState()
const {drawerRef} = useRefs(); const {drawerRef} = useRefs();
const [pendingRequest, setPendingRequest] = useState<number>(0);
const user = useUser().user!; const user = useUser().user!;
const { const {
@ -45,6 +48,10 @@ function Sidebar(
suuid suuid
} = user } = user
useEffect(() => {
setPendingRequest(user.requests?.length || 0);
}, [user])
useEffect(() => { useEffect(() => {
const getThreads = async () => { const getThreads = async () => {
try { try {
@ -126,6 +133,31 @@ function Sidebar(
<ScrollArea className="flex-grow max-h-[590px] px-4 py-4"> <ScrollArea className="flex-grow max-h-[590px] px-4 py-4">
<nav> <nav>
<ul className="space-y-1"> <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) => ( {threads.map((thread) => (
<li key={thread.id}> <li key={thread.id}>
<Link href={thread.id} passHref> <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 // contexts/user.tsx
'use client'; 'use client';
import {createContext, useContext} from 'react'; import {createContext, useContext, useState} from 'react';
import {useRouter} from 'next/navigation'; import {useRouter} from 'next/navigation';
interface UserContextType { interface UserContextType {
user: NonNullable<SiPher.User>; user: NonNullable<SiPher.User>;
getUser: (context: string) => Promise<NonNullable<SiPher.User>>; getUser: (context: string, userId?: string) => Promise<NonNullable<SiPher.User>>;
updateUser: (newUserData: NonNullable<SiPher.User>) => void;
} }
const UserContext = createContext<UserContextType | null>(null); const UserContext = createContext<UserContextType | null>(null);
export function useUser() { export function useUser() {
const context = useContext(UserContext); const context = useContext(UserContext);
const router = useRouter(); const router = useRouter();
if (!context) { if (!context) {
throw new Error('useUser must be used within a UserProvider'); throw new Error('useUser must be used within a UserProvider');
} }
return { return {
user: context.user, user: context.user,
getUser: async (context: string, userId?: string) => { updateUser: context.updateUser,
if (process.env.NODE_ENV !== 'production') { getUser: async (context: string, userId?: string) => {
console.log(`useUser().getUser(): Being called by ${context}`) if (process.env.NODE_ENV !== 'production') {
} console.log(`useUser().getUser(): Being called by ${context}`)
}
try { try {
const response = await fetch(`/api/auth/get_user?${ const response = await fetch(`/api/auth/get_user?${
userId && `uuid=${ userId && `uuid=${
encodeURIComponent(userId) encodeURIComponent(userId)
}` }`
}`); }`);
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
if (error.message?.includes("Auth session missing!")) { if (error.message?.includes("Auth session missing!")) {
throw new Error('No authenticated user'); throw new Error('No authenticated user');
} }
throw new Error(error.message || 'Authentication failed'); throw new Error(error.message || 'Authentication failed');
} }
const {user} = await response.json(); const {user} = await response.json();
return user as NonNullable<SiPher.User>; return user as NonNullable<SiPher.User>;
} catch (error) { } catch (error) {
console.error('Failed to get user:', error); console.error('Failed to get user:', error);
router.push('/auth/login'); router.push('/auth/login');
throw error; throw error;
} }
}, },
checkAuth: async (context: string) => { checkAuth: async (context: string) => {
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
console.log(`useUser().checkAuth(): Being called by ${context}`) console.log(`useUser().checkAuth(): Being called by ${context}`)
} }
try { try {
const response = await fetch('/api/auth/get_user'); const response = await fetch('/api/auth/get_user');
return response.ok; return response.ok;
} catch { } catch {
return false; return false;
} }
} }
}; };
} }
export function UserProvider( export function UserProvider(
{ {
children, children,
initialUser initialUser
}: { }: {
children: React.ReactNode; children: React.ReactNode;
initialUser: NonNullable<SiPher.User>; initialUser: NonNullable<SiPher.User>;
} }
) { ) {
return ( const [user, setUser] = useState<NonNullable<SiPher.User>>(initialUser);
<UserContext.Provider value={{
user: initialUser, const updateUser = (newUserData: NonNullable<SiPher.User>) => {
getUser: async () => { setUser(newUserData);
const response = await fetch('/api/auth/get_user'); };
if (!response.ok) {
throw new Error('Failed to get user'); return (
} <UserContext.Provider value={{
const {user} = await response.json(); user,
return user as NonNullable<SiPher.User>; updateUser,
} getUser: async (context: string, userId?: string) => {
}}> const response = await fetch(`/api/auth/get_user?${
{children} userId && `uuid=${
</UserContext.Provider> 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 {CookieOptions, createServerClient} from '@supabase/ssr';
import {cookies} from 'next/headers'; import {cookies} from 'next/headers';

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

@ -24,6 +24,8 @@ declare global {
created_at: string, created_at: string,
/** Messages field */ /** Messages field */
messages: Messages[] 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)', lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)', md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)' 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'
} }
} }
}, },