initial
start
This commit is contained in:
parent
8a1a954603
commit
365a89ac63
47 changed files with 4970 additions and 193 deletions
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -3,8 +3,12 @@
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.*
|
||||||
.yarn/install-state.gz
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
@ -25,9 +29,8 @@ npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# env files (can opt-in for committing if needed)
|
||||||
.env*.local
|
.env*
|
||||||
.env
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
|
||||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
14
.idea/discord.xml
generated
Normal file
14
.idea/discord.xml
generated
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DiscordProjectSettings">
|
||||||
|
<option name="show" value="ASK" />
|
||||||
|
<option name="description" value="" />
|
||||||
|
<option name="applicationTheme" value="default" />
|
||||||
|
<option name="iconsTheme" value="default" />
|
||||||
|
<option name="button1Title" value="" />
|
||||||
|
<option name="button1Url" value="" />
|
||||||
|
<option name="button2Title" value="" />
|
||||||
|
<option name="button2Url" value="" />
|
||||||
|
<option name="customApplicationId" value="" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
13
.idea/material_theme_project_new.xml
generated
Normal file
13
.idea/material_theme_project_new.xml
generated
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="MaterialThemeProjectNewConfig">
|
||||||
|
<option name="metadata">
|
||||||
|
<MTProjectMetadataState>
|
||||||
|
<option name="migrated" value="true" />
|
||||||
|
<option name="pristineConfig" value="false" />
|
||||||
|
<option name="userId" value="-231c7013:18e53d79f11:-8000" />
|
||||||
|
<option name="version" value="8.13.2" />
|
||||||
|
</MTProjectMetadataState>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/sipher.iml" filepath="$PROJECT_DIR$/.idea/sipher.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
12
.idea/sipher.iml
generated
Normal file
12
.idea/sipher.iml
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
103
README.md
103
README.md
|
|
@ -1,96 +1,33 @@
|
||||||
<a href="https://demo-nextjs-with-supabase.vercel.app/">
|
- 1 - What will your software do?
|
||||||
<img alt="Next.js and Supabase Starter Kit - the fastest way to build apps with Next.js and Supabase" src="https://demo-nextjs-with-supabase.vercel.app/opengraph-image.png">
|
|
||||||
<h1 align="center">Next.js and Supabase Starter Kit</h1>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<p align="center">
|
My software will encrypt messages just like WhatsApp does by using a system of people having a key and sharing them
|
||||||
The fastest way to build apps with Next.js and Supabase
|
with one another.
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
- 1.1 - What features will it have?
|
||||||
<a href="#features"><strong>Features</strong></a> ·
|
|
||||||
<a href="#demo"><strong>Demo</strong></a> ·
|
|
||||||
<a href="#deploy-to-vercel"><strong>Deploy to Vercel</strong></a> ·
|
|
||||||
<a href="#clone-and-run-locally"><strong>Clone and run locally</strong></a> ·
|
|
||||||
<a href="#feedback-and-issues"><strong>Feedback and issues</strong></a>
|
|
||||||
<a href="#more-supabase-examples"><strong>More Examples</strong></a>
|
|
||||||
</p>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
## Features
|
I'll let the user choose multiple encryption methods, this will make it more secure and reliable.
|
||||||
|
Only the user will have its password that he could share with another user.
|
||||||
|
|
||||||
- Works across the entire [Next.js](https://nextjs.org) stack
|
- 1.2 How will it be executed?
|
||||||
- App Router
|
|
||||||
- Pages Router
|
|
||||||
- Middleware
|
|
||||||
- Client
|
|
||||||
- Server
|
|
||||||
- It just works!
|
|
||||||
- supabase-ssr. A package to configure Supabase Auth to use cookies
|
|
||||||
- Styling with [Tailwind CSS](https://tailwindcss.com)
|
|
||||||
- Components with [shadcn/ui](https://ui.shadcn.com/)
|
|
||||||
- Optional deployment with [Supabase Vercel Integration and Vercel deploy](#deploy-your-own)
|
|
||||||
- Environment variables automatically assigned to Vercel project
|
|
||||||
|
|
||||||
## Demo
|
Mainly by creating a database that would only hold a username and a password, could use Supabase for that
|
||||||
|
or a simple MongoDb cluster.
|
||||||
|
|
||||||
You can view a fully working demo at [demo-nextjs-with-supabase.vercel.app](https://demo-nextjs-with-supabase.vercel.app/).
|
- 2- What new skills will you need to acquire?
|
||||||
|
|
||||||
## Deploy to Vercel
|
For this one, mainly how cryptography works on message exchanging.
|
||||||
|
|
||||||
Vercel deployment will guide you through creating a Supabase account and project.
|
- 2.1 - What topics will you need to research?
|
||||||
|
|
||||||
After installation of the Supabase integration, all relevant environment variables will be assigned to the project so the deployment is fully functioning.
|
I'll also have to research about the recommended cases on how to store or handle each user.
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&project-name=nextjs-with-supabase&repository-name=nextjs-with-supabase&demo-title=nextjs-with-supabase&demo-description=This+starter+configures+Supabase+Auth+to+use+cookies%2C+making+the+user%27s+session+available+throughout+the+entire+Next.js+app+-+Client+Components%2C+Server+Components%2C+Route+Handlers%2C+Server+Actions+and+Middleware.&demo-url=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2F&external-id=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&demo-image=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2Fopengraph-image.png)
|
- 3- If working with one or two classmates, who will do what?
|
||||||
|
|
||||||
The above will also clone the Starter kit to your GitHub, you can clone that locally and develop locally.
|
Will do by myself.
|
||||||
|
|
||||||
If you wish to just develop locally and not deploy to Vercel, [follow the steps below](#clone-and-run-locally).
|
- 4 - In the world of software, most everything takes longer to implement than you expect. And so it’s not uncommon to accomplish less in a fixed amount of time than you hope. What might you consider to be a good outcome for your project? A better outcome? The best outcome?
|
||||||
|
|
||||||
## Clone and run locally
|
The best outcome for this would be an app that could at least:
|
||||||
|
Log in/Register the user
|
||||||
1. You'll first need a Supabase project which can be made [via the Supabase dashboard](https://database.new)
|
Let the user choose its encryption method
|
||||||
|
Let the user change his password to a maximum of a 12-letter word
|
||||||
2. Create a Next.js app using the Supabase Starter template npx command
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx create-next-app -e with-supabase
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Use `cd` to change into the app's directory
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd name-of-new-app
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Rename `.env.example` to `.env.local` and update the following:
|
|
||||||
|
|
||||||
```
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL=[INSERT SUPABASE PROJECT URL]
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=[INSERT SUPABASE PROJECT API ANON KEY]
|
|
||||||
```
|
|
||||||
|
|
||||||
Both `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` can be found in [your Supabase project's API settings](https://app.supabase.com/project/_/settings/api)
|
|
||||||
|
|
||||||
5. You can now run the Next.js local development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The starter kit should now be running on [localhost:3000](http://localhost:3000/).
|
|
||||||
|
|
||||||
6. This template comes with the default shadcn/ui style initialized. If you instead want other ui.shadcn styles, delete `components.json` and [re-install shadcn/ui](https://ui.shadcn.com/docs/installation/next)
|
|
||||||
|
|
||||||
> Check out [the docs for Local Development](https://supabase.com/docs/guides/getting-started/local-development) to also run Supabase locally.
|
|
||||||
|
|
||||||
## Feedback and issues
|
|
||||||
|
|
||||||
Please file feedback and issues over on the [Supabase GitHub org](https://github.com/supabase/supabase/issues/new/choose).
|
|
||||||
|
|
||||||
## More Supabase examples
|
|
||||||
|
|
||||||
- [Next.js Subscription Payments Starter](https://github.com/vercel/nextjs-subscription-payments)
|
|
||||||
- [Cookie-based Auth and the Next.js 13 App Router (free course)](https://youtube.com/playlist?list=PL5S4mPUpp4OtMhpnp93EFSo42iQ40XjbF)
|
|
||||||
- [Supabase Auth and the Next.js App Router](https://github.com/supabase/supabase/tree/master/examples/auth/nextjs)
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "default",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.ts",
|
"config": "tailwind.config.ts",
|
||||||
"css": "app/globals.css",
|
"css": "src/app/globals.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils"
|
"utils": "@/lib/utils",
|
||||||
}
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
2804
package-lock.json
generated
Normal file
2804
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
51
package.json
51
package.json
|
|
@ -1,36 +1,41 @@
|
||||||
{
|
{
|
||||||
|
"name": "whispr",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-checkbox": "^1.1.1",
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@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-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@supabase/ssr": "latest",
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
"@supabase/supabase-js": "latest",
|
"@supabase/ssr": "^0.5.2",
|
||||||
"autoprefixer": "10.4.20",
|
"@supabase/supabase-js": "^2.47.3",
|
||||||
"class-variance-authority": "^0.7.0",
|
"argon2": "^0.41.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"geist": "^1.2.1",
|
"framer-motion": "^11.13.5",
|
||||||
"lucide-react": "^0.456.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "latest",
|
"next": "15.0.4",
|
||||||
"next-themes": "^0.4.3",
|
"next-themes": "^0.4.4",
|
||||||
"prettier": "^3.3.3",
|
"react": "^19.0.0",
|
||||||
"react": "18.3.1",
|
"react-dom": "^19.0.0",
|
||||||
"react-dom": "18.3.1"
|
"tailwind-merge": "^2.5.5",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "22.9.0",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "^19",
|
||||||
"postcss": "8.4.49",
|
"postcss": "^8",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwindcss": "^3.4.1",
|
||||||
"tailwindcss": "3.4.14",
|
"typescript": "^5"
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"typescript": "5.6.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
public/logos/logo-light.png
Normal file
BIN
public/logos/logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
public/logos/logo.png
Normal file
BIN
public/logos/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
public/logos/united-chat.png
Normal file
BIN
public/logos/united-chat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6 KiB |
44
src/app/api/auth/get_user/route.ts
Normal file
44
src/app/api/auth/get_user/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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) {
|
||||||
|
const {data: userData, error: userError} = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('*')
|
||||||
|
.eq('uuid', uuid)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (userError) throw userError;
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {searchParams} = new URL(request.url);
|
||||||
|
const uuid = searchParams.get('uuid');
|
||||||
|
|
||||||
|
if (uuid) {
|
||||||
|
// Get specific user by UUID
|
||||||
|
const userData = await getUserByUUID(supabase, uuid);
|
||||||
|
return NextResponse.json({user: userData});
|
||||||
|
} else {
|
||||||
|
// Get current authenticated user
|
||||||
|
const {data: {user}, error: authError} = await supabase.auth.getUser();
|
||||||
|
if (authError) throw authError;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({user: null}, {status: 401});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await getUserByUUID(supabase, user.id);
|
||||||
|
return NextResponse.json({user: userData});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: `Failed to fetch user: ${error}`},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app/api/auth/login/route.ts
Normal file
38
src/app/api/auth/login/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// app/api/auth/login/route.ts
|
||||||
|
import {createClient} from "@/lib/supabase/server";
|
||||||
|
import {NextResponse} from "next/server";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const {username, password} = await request.json()
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
// Mocks the email with the domain we configured on the local env
|
||||||
|
const email = `${username.toLowerCase()}@${process.env.DOMAIN}`
|
||||||
|
|
||||||
|
// Sends the request through supabase
|
||||||
|
const {data: {user}, error: authError} = await supabase.auth.signInWithPassword({
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (authError) throw authError
|
||||||
|
|
||||||
|
// Fetch our custom user data
|
||||||
|
const {data: userData, error: userError} = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('*')
|
||||||
|
.eq('uuid', user?.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (userError) throw userError
|
||||||
|
|
||||||
|
// Returns simple data
|
||||||
|
return NextResponse.json({user: userData})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: `Login failed: ${error}`},
|
||||||
|
{status: 401}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/app/api/auth/register/route.ts
Normal file
45
src/app/api/auth/register/route.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import {NextResponse} from 'next/server'
|
||||||
|
import {createClient} from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const {username, password} = await request.json()
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First create the auth user
|
||||||
|
const {data: {user}, error: authError} = await supabase.auth.signUp({
|
||||||
|
email: `${username}@${process.env.DOMAIN}`, // Using username as email
|
||||||
|
password: password,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (authError) throw authError
|
||||||
|
if (!user) throw new Error('No user returned from sign up')
|
||||||
|
|
||||||
|
// Then create our custom user record
|
||||||
|
const {error: insertError} = await supabase
|
||||||
|
.from('users')
|
||||||
|
.insert({
|
||||||
|
uuid: user.id,
|
||||||
|
username: username,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (insertError) {
|
||||||
|
// Rollback auth user if custom user creation fails
|
||||||
|
await supabase.auth.admin.deleteUser(user.id)
|
||||||
|
throw insertError
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({success: true})
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === "object") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: `Registration failed: ${JSON.stringify(error)}`},
|
||||||
|
{status: 400}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: `Registration failed: ${error}`},
|
||||||
|
{status: 400}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/user/get/threads/route.ts
Normal file
37
src/app/api/user/get/threads/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import {createClient} from "@/lib/supabase/server";
|
||||||
|
import {NextResponse} from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const {data: {user}, error: userError} = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (userError) {
|
||||||
|
NextResponse.json(
|
||||||
|
{error: userError},
|
||||||
|
{status: userError?.status}
|
||||||
|
)
|
||||||
|
} else if (!user) {
|
||||||
|
NextResponse.json(
|
||||||
|
{error: "User not found"},
|
||||||
|
{status: 401}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data, error} = await supabase.rpc(
|
||||||
|
"get_user_threads",
|
||||||
|
{
|
||||||
|
user_id: user!.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return NextResponse.json({threads: []}, {status: 200});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({threads: data}, {status: 200});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/app/auth/login/login.ts
Normal file
36
src/app/auth/login/login.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param username - The unique username of that user. This will be checked for collision.
|
||||||
|
* @param password - The plain-text password of the user. Supabase will try to match it.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export default async function Login(username: string, password: string) {
|
||||||
|
try {
|
||||||
|
let response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({username, password}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple error handling.
|
||||||
|
// Since we mock an email on the main app to bypass Supabase's authentication method, we can just return whatever the API returns.
|
||||||
|
// This also means this might be insecure, but oh well. Don't lose your password, I guess?
|
||||||
|
let resData = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return ({
|
||||||
|
code: resData.code,
|
||||||
|
message: resData.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ({
|
||||||
|
code: 200,
|
||||||
|
message: resData.data
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return {code: 500, message: "An unknown error occurred"};
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/app/auth/login/page.tsx
Normal file
198
src/app/auth/login/page.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, {useEffect, useState} from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import {motion} from 'framer-motion'
|
||||||
|
import {Button} from "@/components/ui/button"
|
||||||
|
import {Input} from "@/components/ui/input"
|
||||||
|
import {Label} from "@/components/ui/label"
|
||||||
|
import {Card, CardContent} from "@/components/ui/card"
|
||||||
|
import {EyeIcon, EyeOffIcon} from 'lucide-react'
|
||||||
|
import {useToast} from "@/hooks/use-toast"
|
||||||
|
import {ToastActionElement} from "@/components/ui/toast";
|
||||||
|
import {useUser} from "@/contexts/user";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {useTheme} from "next-themes";
|
||||||
|
import Register from "@/app/auth/login/register";
|
||||||
|
import Login from "@/app/auth/login/login";
|
||||||
|
|
||||||
|
export default function AuthPage() {
|
||||||
|
const {checkAuth} = useUser();
|
||||||
|
const {theme, systemTheme} = useTheme()
|
||||||
|
const {toast} = useToast();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = async () => {
|
||||||
|
const isAuthenticated = await checkAuth();
|
||||||
|
if (isAuthenticated) {
|
||||||
|
router.replace('/');
|
||||||
|
} else {
|
||||||
|
setMounted(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
}, [checkAuth, router]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
|
||||||
|
const getTheme = () => {
|
||||||
|
if (theme === "system") {
|
||||||
|
switch (systemTheme) {
|
||||||
|
case "dark":
|
||||||
|
return "dark"
|
||||||
|
default:
|
||||||
|
return "light"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme === "dark" ? "dark" : "light"
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoSrc = getTheme() === 'dark' ? '/logos/logo-light.png' : '/logos/logo.png';
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
const username = (document.getElementById('username') as HTMLInputElement).value;
|
||||||
|
const password = (document.getElementById('password') as HTMLInputElement).value;
|
||||||
|
|
||||||
|
let response: {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
action?: ToastActionElement | undefined;
|
||||||
|
}
|
||||||
|
if (!isLogin) {
|
||||||
|
response = await Register(username, password);
|
||||||
|
} else {
|
||||||
|
response = await Login(username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.code !== 200) {
|
||||||
|
if (isLogin && response.code === 400) {
|
||||||
|
console.log(response)
|
||||||
|
toast({
|
||||||
|
title: "E-mail not verified",
|
||||||
|
description: response.message,
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 5000, // Increased duration for better visibility
|
||||||
|
action: response.action!
|
||||||
|
});
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: response.message,
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 5000, // Increased duration for better visibility
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: response.message,
|
||||||
|
variant: "default",
|
||||||
|
duration: 5000, // Increased duration for better visibility
|
||||||
|
});
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}, 2000)
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/20 to-secondary/20 p-4">
|
||||||
|
<Card className="w-full max-w-4xl overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="flex flex-col md:flex-row min-h-[480px]">
|
||||||
|
<div
|
||||||
|
className="md:w-1/2 bg-primary p-8 text-primary-foreground flex flex-col justify-center items-center">
|
||||||
|
<Image
|
||||||
|
src={logoSrc}
|
||||||
|
alt="SiPher"
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
className="mb-8"
|
||||||
|
/>
|
||||||
|
<h1 className="text-3xl font-bold mb-4 text-center">
|
||||||
|
Silent Whisper
|
||||||
|
</h1>
|
||||||
|
<p className="text-center mb-8">
|
||||||
|
Trust the shadows. Whisper safely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="md:w-1/2 p-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 20}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
transition={{duration: 0.5}}
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-semibold mb-6 text-center">
|
||||||
|
{isLogin ? "Sign In" : "Sign Up"}
|
||||||
|
</h2>
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="username">
|
||||||
|
Username
|
||||||
|
</Label>
|
||||||
|
<Input id="username" type="text" placeholder="johndoe"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
className="pr-10"
|
||||||
|
placeholder="********"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOffIcon className="h-5 w-5 text-gray-400"/>
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="h-5 w-5 text-gray-400"/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "One second, please..." : (isLogin ? "Sign In" : "Sign Up")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() => setIsLogin(!isLogin)}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
{isLogin
|
||||||
|
? "Don't have an account? Sign Up"
|
||||||
|
: "Already have an account? Sign In"
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
src/app/auth/login/register.ts
Normal file
38
src/app/auth/login/register.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param username - The unique username of that user. This will be checked for collision.
|
||||||
|
* @param password - The plain-text password of the user. Will be encrypted later by Supabase
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export default async function Register(password: string, username: string) {
|
||||||
|
try {
|
||||||
|
// Sends the request to the API
|
||||||
|
let res = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({username, password}), // Stringifies the JSON
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default error handler, if not OK just return whatever the API returned
|
||||||
|
if (!res.ok) {
|
||||||
|
let data = await res.json();
|
||||||
|
return {
|
||||||
|
code: data.code,
|
||||||
|
message: data.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User was created, now it just needs to login on the service.
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "User created successfully, go ahead and login."
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
return {
|
||||||
|
code: 500,
|
||||||
|
message: `An unknown error occurred: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/app/globals.css
Normal file
61
src/app/globals.css
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 20 14.3% 4.1%;
|
||||||
|
--foreground: 60 9.1% 97.8%;
|
||||||
|
--card: 20 14.3% 4.1%;
|
||||||
|
--card-foreground: 60 9.1% 97.8%;
|
||||||
|
--popover: 20 14.3% 4.1%;
|
||||||
|
--popover-foreground: 60 9.1% 97.8%;
|
||||||
|
--primary: 20.5 90.2% 48.2%;
|
||||||
|
--primary-foreground: 60 9.1% 97.8%;
|
||||||
|
--secondary: 12 6.5% 15.1%;
|
||||||
|
--secondary-foreground: 60 9.1% 97.8%;
|
||||||
|
--muted: 12 6.5% 15.1%;
|
||||||
|
--muted-foreground: 24 5.4% 63.9%;
|
||||||
|
--accent: 12 6.5% 15.1%;
|
||||||
|
--accent-foreground: 60 9.1% 97.8%;
|
||||||
|
--destructive: 0 72.2% 50.6%;
|
||||||
|
--destructive-foreground: 60 9.1% 97.8%;
|
||||||
|
--border: 12 6.5% 15.1%;
|
||||||
|
--input: 12 6.5% 15.1%;
|
||||||
|
--ring: 20.5 90.2% 48.2%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 20 14.3% 4.1%;
|
||||||
|
--foreground: 60 9.1% 97.8%;
|
||||||
|
--card: 20 14.3% 4.1%;
|
||||||
|
--card-foreground: 60 9.1% 97.8%;
|
||||||
|
--popover: 20 14.3% 4.1%;
|
||||||
|
--popover-foreground: 60 9.1% 97.8%;
|
||||||
|
--primary: 20.5 90.2% 48.2%;
|
||||||
|
--primary-foreground: 60 9.1% 97.8%;
|
||||||
|
--secondary: 12 6.5% 15.1%;
|
||||||
|
--secondary-foreground: 60 9.1% 97.8%;
|
||||||
|
--muted: 12 6.5% 15.1%;
|
||||||
|
--muted-foreground: 24 5.4% 63.9%;
|
||||||
|
--accent: 12 6.5% 15.1%;
|
||||||
|
--accent-foreground: 60 9.1% 97.8%;
|
||||||
|
--destructive: 0 72.2% 50.6%;
|
||||||
|
--destructive-foreground: 60 9.1% 97.8%;
|
||||||
|
--border: 12 6.5% 15.1%;
|
||||||
|
--input: 12 6.5% 15.1%;
|
||||||
|
--ring: 20.5 90.2% 48.2%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/layout.tsx
Normal file
75
src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// app/layout.tsx
|
||||||
|
import type {Metadata} from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import {Public_Sans} from 'next/font/google';
|
||||||
|
import {UserProvider} from "@/contexts/user";
|
||||||
|
import Sidebar from "@/components/main/sidebar/sidebar";
|
||||||
|
import {getAuthenticatedUser} from "@/lib/auth";
|
||||||
|
import {SharedStateProvider} from "@/hooks/shared-states";
|
||||||
|
import {ThemeProvider} from "next-themes";
|
||||||
|
|
||||||
|
const publicSans = Public_Sans({
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
|
variable: '--font-public-sans'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Next App",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode & { props?: { childProp?: { segment?: string } } };
|
||||||
|
}) {
|
||||||
|
const initialUser = await getAuthenticatedUser();
|
||||||
|
const isAuthPage = (children as any)?.props?.childProp?.segment === 'auth';
|
||||||
|
|
||||||
|
// Auth layout
|
||||||
|
if (isAuthPage) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={`${publicSans.variable} font-sans antialiased`}>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
>
|
||||||
|
<UserProvider initialUser={initialUser}>
|
||||||
|
<main className="min-h-screen flex items-center justify-center">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</UserProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main layout
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={`${publicSans.variable} font-sans antialiased`}>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
>
|
||||||
|
<UserProvider initialUser={initialUser}>
|
||||||
|
<SharedStateProvider>
|
||||||
|
<div className={`max-h-[1080px] p-6 bg-secondary`}>
|
||||||
|
<div className="flex bg-background">
|
||||||
|
<Sidebar>
|
||||||
|
{children}
|
||||||
|
</Sidebar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SharedStateProvider>
|
||||||
|
</UserProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/page.tsx
Normal file
12
src/app/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
"use client"
|
||||||
|
import {useTheme} from "next-themes";
|
||||||
|
|
||||||
|
export default function SiPher() {
|
||||||
|
const {theme} = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex-1 ${theme === "dark" ? "dark" : ""}`}>
|
||||||
|
abc
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
src/components/main/sidebar/mobile.tsx
Normal file
58
src/components/main/sidebar/mobile.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { HamburgerMenuIcon } from "@radix-ui/react-icons"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useUIState } from "@/hooks/shared-states"
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const MobileHeader: React.FC = () => {
|
||||||
|
const { setIsDrawerOpen } = useUIState()
|
||||||
|
const { theme, systemTheme } = useTheme()
|
||||||
|
|
||||||
|
const getTheme = () => {
|
||||||
|
if (theme === "system") {
|
||||||
|
switch (systemTheme) {
|
||||||
|
case "dark":
|
||||||
|
return "dark"
|
||||||
|
default:
|
||||||
|
return "light"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return theme === "dark" ? "dark" : "light"
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoSrc = getTheme() === 'dark' ? '/logos/logo-light.png' : '/logos/logo.png'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-50 lg:hidden pb-10">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-background">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsDrawerOpen(true)}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<HamburgerMenuIcon className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center flex-1">
|
||||||
|
<Link href="/" className="block">
|
||||||
|
<Image
|
||||||
|
src={logoSrc}
|
||||||
|
alt="Logo"
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="w-12 h-12 cursor-pointer rounded-full hover:bg-secondary/20"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty div to maintain center alignment */}
|
||||||
|
<div className="w-10 mb-8" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileHeader
|
||||||
229
src/components/main/sidebar/sidebar.tsx
Normal file
229
src/components/main/sidebar/sidebar.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
"use client"
|
||||||
|
import React, {useCallback, useEffect, useRef, 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 {Button} from "@/components/ui/button"
|
||||||
|
import {Avatar, AvatarFallback} from "@/components/ui/avatar"
|
||||||
|
import {Separator} from "@/components/ui/separator"
|
||||||
|
import {ScrollArea} from "@/components/ui/scroll-area"
|
||||||
|
import {useTheme} from "next-themes"
|
||||||
|
import {GearIcon} from "@radix-ui/react-icons"
|
||||||
|
import Image from "next/image";
|
||||||
|
import MobileHeader from "@/components/main/sidebar/mobile";
|
||||||
|
import {useUser} from "@/contexts/user";
|
||||||
|
import {useUIState} from "@/hooks/shared-states";
|
||||||
|
import {useToast} from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
type SidebarProps = {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar(
|
||||||
|
{
|
||||||
|
children
|
||||||
|
}: SidebarProps
|
||||||
|
) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const drawerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [selectedThreads, setSelectedThreads] = useState("");
|
||||||
|
const [threads, setThreads] = useState<SiPher.Messages[] | []>([]);
|
||||||
|
const [threadMenu, setThreadMenu] = useState<SiPher.Messages[] | []>([]);
|
||||||
|
const {toast} = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getThreads = async () => {
|
||||||
|
const req = await fetch("/api/user/get/threads")
|
||||||
|
|
||||||
|
if (req.ok) {
|
||||||
|
const {threads} = await req.json() as { threads: SiPher.Messages[] | [] }
|
||||||
|
setThreads(threads)
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setThreads([]);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "An unknown error occurred",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 5000, // Increased duration for better visibility
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getThreads();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setThreads([]);
|
||||||
|
}
|
||||||
|
}, [setThreads])
|
||||||
|
|
||||||
|
const generateThreads = useCallback(() => {
|
||||||
|
threads.map(async(thread) => {
|
||||||
|
if (thread.participants.length > 2) {
|
||||||
|
return (
|
||||||
|
<li key={thread.id}>
|
||||||
|
<Link href={thread.id} passHref>
|
||||||
|
<Button
|
||||||
|
variant={pathname === thread.id ? "secondary" : "ghost"}
|
||||||
|
className="w-full justify-start text-[17px] py-4"
|
||||||
|
>
|
||||||
|
<Avatar className="w-8 h-8 mr-3 p-1">
|
||||||
|
<AvatarFallback>{thread.name!}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{thread.name!}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const fetchOtherUser = await useUser().getUser(thread.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [threads])
|
||||||
|
|
||||||
|
const user = useUser().user!;
|
||||||
|
|
||||||
|
const {
|
||||||
|
username,
|
||||||
|
suuid
|
||||||
|
} = user
|
||||||
|
|
||||||
|
const {isDrawerOpen, setIsDrawerOpen} = useUIState()
|
||||||
|
|
||||||
|
const {theme, systemTheme} = useTheme()
|
||||||
|
const getTheme = () => {
|
||||||
|
if (theme === "system") {
|
||||||
|
switch (systemTheme) {
|
||||||
|
case "dark":
|
||||||
|
return "dark"
|
||||||
|
default:
|
||||||
|
return "light"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme === "dark" ? "dark" : "light"
|
||||||
|
}
|
||||||
|
const isDarkMode = getTheme() === "dark";
|
||||||
|
|
||||||
|
const RightSidebarContent = () => (
|
||||||
|
<div className={`flex flex-col h-full w-[240px]`}>
|
||||||
|
<div
|
||||||
|
className={`flex items-center p-3 m-2 ${isDarkMode ? "hover:bg-accent/90" : "hover:bg-secondary/20"} rounded-full transition-colors duration-200`}>
|
||||||
|
<Avatar className="w-12 h-12 mr-3">
|
||||||
|
<AvatarFallback>{username.charAt(0)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-semibold text-[17px] ${isDarkMode ? "text-white" : "text-black"}`}>{username}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">@{username}</p>
|
||||||
|
<p className="text-xs text-muted">${suuid}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="my-2"/>
|
||||||
|
<ScrollArea className="flex-grow max-h-[590px] px-4 py-4">
|
||||||
|
<nav>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{threads.map((thread) => (
|
||||||
|
<li key={thread.id}>
|
||||||
|
<Link href={thread.id} passHref>
|
||||||
|
<Button
|
||||||
|
variant={pathname === thread.id ? "secondary" : "ghost"}
|
||||||
|
className="w-full justify-start text-[17px] py-4"
|
||||||
|
>
|
||||||
|
<Avatar className="w-8 h-8 mr-3 p-1">
|
||||||
|
<AvatarFallback>{thread.id}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{thread.id}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
<Separator/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-[17px] py-2 text-primary"
|
||||||
|
onClick={() => window.location.href = "/config"}
|
||||||
|
>
|
||||||
|
<GearIcon className="w-4 h-4 mr-3"/>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => {
|
||||||
|
fetch("/api/auth/logout", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = "/auth/login"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}} variant="outline" className="w-full justify-start text-[17px] py-2 text-destructive">
|
||||||
|
<LogOut className="w-4 h-4 mr-3"/>
|
||||||
|
Log Out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MobileHeader/>
|
||||||
|
<aside
|
||||||
|
className={`hidden lg:flex flex-col items-end h-screen max-h-[900px] sticky top-0 border-r border-border ${
|
||||||
|
isDarkMode ? "bg-background" : "white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-items-start w-[240px] mt-1.5">
|
||||||
|
<Link href={"/"} passHref>
|
||||||
|
<Image
|
||||||
|
src={isDarkMode ? "/logos/logo.png" : "/logos/logo-light.png"}
|
||||||
|
alt="Tocka‘s Nest"
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="w-16 h-16 cursor-pointer rounded-full hover:bg-secondary/20"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<RightSidebarContent/>
|
||||||
|
</aside>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isDrawerOpen && (
|
||||||
|
<motion.div
|
||||||
|
ref={drawerRef}
|
||||||
|
initial={{x: '-100%'}}
|
||||||
|
animate={{x: 0}}
|
||||||
|
exit={{x: '-100%'}}
|
||||||
|
transition={{type: 'tween'}}
|
||||||
|
className={`fixed inset-y-0 left-0 w-64 ${
|
||||||
|
isDarkMode ? "bg-background" : "bg-white"
|
||||||
|
} border-r border-border shadow-lg z-50 lg:hidden`}
|
||||||
|
>
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={() => setIsDrawerOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5"/>
|
||||||
|
<span className="sr-only">Close menu</span>
|
||||||
|
</Button>
|
||||||
|
<RightSidebarContent/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
{
|
||||||
|
children ?? null
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sidebar
|
||||||
50
src/components/ui/avatar.tsx
Normal file
50
src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
76
src/components/ui/card.tsx
Normal file
76
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
48
src/components/ui/scroll-area.tsx
Normal file
48
src/components/ui/scroll-area.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
31
src/components/ui/separator.tsx
Normal file
31
src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
129
src/components/ui/toast.tsx
Normal file
129
src/components/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
}
|
||||||
35
src/components/ui/toaster.tsx
Normal file
35
src/components/ui/toaster.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
src/contexts/user.tsx
Normal file
82
src/contexts/user.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
// contexts/user.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import {createContext, useContext} from 'react';
|
||||||
|
import {useRouter} from 'next/navigation';
|
||||||
|
|
||||||
|
interface UserContextType {
|
||||||
|
user: NonNullable<SiPher.User>;
|
||||||
|
getUser: () => Promise<NonNullable<SiPher.User>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (userId?: string) => {
|
||||||
|
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 () => {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/hooks/shared-states.tsx
Normal file
171
src/hooks/shared-states.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
// src/hooks/useSharedState.tsx
|
||||||
|
import React, {createContext, MutableRefObject, useContext, useRef, useState} from 'react'
|
||||||
|
import {useTheme} from 'next-themes'
|
||||||
|
|
||||||
|
// Define the shape of our shared state
|
||||||
|
interface SharedState {
|
||||||
|
// UI States
|
||||||
|
isScrolled: boolean
|
||||||
|
setIsScrolled: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
isSearchExpanded: boolean
|
||||||
|
setIsSearchExpanded: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
isDrawerOpen: boolean
|
||||||
|
setIsDrawerOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
isCreateModalOpen: boolean
|
||||||
|
setIsCreateModalOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
isUserModalOpen: boolean
|
||||||
|
setIsUserModalOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
isNotificationsOpen: boolean
|
||||||
|
setIsNotificationsOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
showBackToTop: boolean
|
||||||
|
setShowBackToTop: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
drawerRef: React.RefObject<HTMLDivElement>
|
||||||
|
userModalRef: React.RefObject<HTMLDivElement>
|
||||||
|
notificationsRef: React.RefObject<HTMLDivElement>
|
||||||
|
createModalRef: React.RefObject<HTMLDivElement>
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement>
|
||||||
|
observerRef: MutableRefObject<IntersectionObserver | null>
|
||||||
|
loadingRef: MutableRefObject<boolean>
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
theme: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMutableRef<T>(initialValue: T): MutableRefObject<T> {
|
||||||
|
return useRef<T>(initialValue) as MutableRefObject<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the context
|
||||||
|
const SharedStateContext = createContext<SharedState | undefined>(undefined)
|
||||||
|
|
||||||
|
// Create the provider component
|
||||||
|
export function SharedStateProvider({children}: { children: React.ReactNode }) {
|
||||||
|
// UI States
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false)
|
||||||
|
const [isSearchExpanded, setIsSearchExpanded] = useState(false)
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||||
|
const [isUserModalOpen, setIsUserModalOpen] = useState(false)
|
||||||
|
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false)
|
||||||
|
const [showBackToTop, setShowBackToTop] = useState(false)
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const drawerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const userModalRef = useRef<HTMLDivElement>(null)
|
||||||
|
const notificationsRef = useRef<HTMLDivElement>(null)
|
||||||
|
const createModalRef = useRef<HTMLDivElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const loadingRef = useMutableRef<boolean>(false)
|
||||||
|
const observerRef = useMutableRef<IntersectionObserver | null>(null)
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
const {theme} = useTheme()
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
// UI States
|
||||||
|
isScrolled,
|
||||||
|
setIsScrolled,
|
||||||
|
isSearchExpanded,
|
||||||
|
setIsSearchExpanded,
|
||||||
|
isDrawerOpen,
|
||||||
|
setIsDrawerOpen,
|
||||||
|
isCreateModalOpen,
|
||||||
|
setIsCreateModalOpen,
|
||||||
|
isUserModalOpen,
|
||||||
|
setIsUserModalOpen,
|
||||||
|
isNotificationsOpen,
|
||||||
|
setIsNotificationsOpen,
|
||||||
|
showBackToTop,
|
||||||
|
setShowBackToTop,
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
drawerRef,
|
||||||
|
userModalRef,
|
||||||
|
notificationsRef,
|
||||||
|
createModalRef,
|
||||||
|
fileInputRef,
|
||||||
|
observerRef,
|
||||||
|
loadingRef,
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SharedStateContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SharedStateContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the custom hook
|
||||||
|
export function useSharedState() {
|
||||||
|
const context = useContext(SharedStateContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSharedState must be used within a SharedStateProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Create specific hooks for different parts of the state
|
||||||
|
export function useUIState() {
|
||||||
|
const {
|
||||||
|
isScrolled,
|
||||||
|
setIsScrolled,
|
||||||
|
isSearchExpanded,
|
||||||
|
setIsSearchExpanded,
|
||||||
|
isDrawerOpen,
|
||||||
|
setIsDrawerOpen,
|
||||||
|
isCreateModalOpen,
|
||||||
|
setIsCreateModalOpen,
|
||||||
|
isUserModalOpen,
|
||||||
|
setIsUserModalOpen,
|
||||||
|
isNotificationsOpen,
|
||||||
|
setIsNotificationsOpen,
|
||||||
|
showBackToTop,
|
||||||
|
setShowBackToTop,
|
||||||
|
} = useSharedState()
|
||||||
|
|
||||||
|
return {
|
||||||
|
isScrolled,
|
||||||
|
setIsScrolled,
|
||||||
|
isSearchExpanded,
|
||||||
|
setIsSearchExpanded,
|
||||||
|
isDrawerOpen,
|
||||||
|
setIsDrawerOpen,
|
||||||
|
isCreateModalOpen,
|
||||||
|
setIsCreateModalOpen,
|
||||||
|
isUserModalOpen,
|
||||||
|
setIsUserModalOpen,
|
||||||
|
isNotificationsOpen,
|
||||||
|
setIsNotificationsOpen,
|
||||||
|
showBackToTop,
|
||||||
|
setShowBackToTop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefs() {
|
||||||
|
const {
|
||||||
|
drawerRef,
|
||||||
|
userModalRef,
|
||||||
|
notificationsRef,
|
||||||
|
createModalRef,
|
||||||
|
fileInputRef,
|
||||||
|
observerRef,
|
||||||
|
loadingRef,
|
||||||
|
} = useSharedState()
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawerRef,
|
||||||
|
userModalRef,
|
||||||
|
notificationsRef,
|
||||||
|
createModalRef,
|
||||||
|
fileInputRef,
|
||||||
|
observerRef,
|
||||||
|
loadingRef,
|
||||||
|
}
|
||||||
|
}
|
||||||
194
src/hooks/use-toast.ts
Normal file
194
src/hooks/use-toast.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ToastActionElement,
|
||||||
|
ToastProps,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"]
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"]
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
||||||
41
src/lib/auth/index.ts
Normal file
41
src/lib/auth/index.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// lib/auth/index.ts
|
||||||
|
import {createClient} from '@/lib/supabase/server';
|
||||||
|
import {headers} from 'next/headers';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = [
|
||||||
|
'/auth/login',
|
||||||
|
'/auth/signup',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mostly used for getting the first user to prevent it being null
|
||||||
|
*/
|
||||||
|
export async function getAuthenticatedUser() {
|
||||||
|
const headersList = await headers();
|
||||||
|
const path = headersList.get("x-invoke-path") || "";
|
||||||
|
|
||||||
|
// If we're on a public path, don't require authentication
|
||||||
|
if (PUBLIC_PATHS.some(publicPath => path.startsWith(publicPath))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const {data: {user: session}, error: sessionError} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (sessionError || !session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data: profile, error: profileError} = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('*')
|
||||||
|
.eq('uuid', session.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (profileError || !profile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile
|
||||||
|
}
|
||||||
38
src/lib/supabase/server.ts
Normal file
38
src/lib/supabase/server.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import {CookieOptions, createServerClient} from '@supabase/ssr';
|
||||||
|
|
||||||
|
import {cookies} from 'next/headers';
|
||||||
|
|
||||||
|
export async function createClient() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll().map(cookie => ({
|
||||||
|
name: cookie.name,
|
||||||
|
value: cookie.value,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
setAll(cookiesList: { name: string; value: string; options?: CookieOptions }[]) {
|
||||||
|
try {
|
||||||
|
cookiesList.forEach(({name, value, options}) => {
|
||||||
|
cookieStore.set({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
...options,
|
||||||
|
// Ensure cookies are secure in production
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting cookies:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
59
src/middleware.ts
Normal file
59
src/middleware.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import {NextRequest, NextResponse} from "next/server";
|
||||||
|
import {createClient} from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
const PUBLIC_ROUTES = [
|
||||||
|
'/auth/login',
|
||||||
|
'/auth/signup',
|
||||||
|
'/api/auth',
|
||||||
|
'/_next',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/static',
|
||||||
|
'/images',
|
||||||
|
];
|
||||||
|
|
||||||
|
const isPublicRoute = (path: string) => {
|
||||||
|
return PUBLIC_ROUTES.some(route => path.startsWith(route));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
let response = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: request.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {data: {user}, error} = await supabase.auth.getUser();
|
||||||
|
const path = request.nextUrl.pathname;
|
||||||
|
|
||||||
|
if (!user && !isPublicRoute(path)) {
|
||||||
|
const redirectUrl = new URL('/auth/login', request.url);
|
||||||
|
if (request.nextUrl.search) {
|
||||||
|
redirectUrl.search = request.nextUrl.search;
|
||||||
|
}
|
||||||
|
redirectUrl.searchParams.set('redirectTo', request.nextUrl.pathname);
|
||||||
|
return NextResponse.redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && path.startsWith('/auth/') && !path.includes("/auth/complete")) {
|
||||||
|
return NextResponse.redirect(new URL('/', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.id) {
|
||||||
|
response.headers.set('x-user-id', user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Middleware error:', error);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||||
|
"/api/preferences/language",
|
||||||
|
],
|
||||||
|
}
|
||||||
31
src/types/user.d.ts
vendored
Normal file
31
src/types/user.d.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
declare global {
|
||||||
|
namespace SiPher {
|
||||||
|
type Messages = {
|
||||||
|
id: string;
|
||||||
|
participants: string[];
|
||||||
|
name?: string;
|
||||||
|
messages: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
}[];
|
||||||
|
indexable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
/** Represents the unique username of a user. */
|
||||||
|
username: string,
|
||||||
|
/** The encrypted password of said user. */
|
||||||
|
password: string,
|
||||||
|
/** Unique UUID, long */
|
||||||
|
uuid: string,
|
||||||
|
/** Short UUID, for index reasons */
|
||||||
|
suuid: string,
|
||||||
|
/** Created at timestamp in UTC */
|
||||||
|
created_at: string,
|
||||||
|
/** Messages field */
|
||||||
|
messages: Messages[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
@ -1,80 +1,62 @@
|
||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
const config = {
|
export default {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: [
|
content: [
|
||||||
"./pages/**/*.{ts,tsx}",
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./components/**/*.{ts,tsx}",
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./app/**/*.{ts,tsx}",
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./src/**/*.{ts,tsx}",
|
|
||||||
],
|
],
|
||||||
prefix: "",
|
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
extend: {
|
||||||
center: true,
|
colors: {
|
||||||
padding: "2rem",
|
background: 'hsl(var(--background))',
|
||||||
screens: {
|
foreground: 'hsl(var(--foreground))',
|
||||||
"2xl": "1400px",
|
card: {
|
||||||
},
|
DEFAULT: 'hsl(var(--card))',
|
||||||
},
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
extend: {
|
},
|
||||||
colors: {
|
popover: {
|
||||||
border: "hsl(var(--border))",
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
input: "hsl(var(--input))",
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
ring: "hsl(var(--ring))",
|
},
|
||||||
background: "hsl(var(--background))",
|
primary: {
|
||||||
foreground: "hsl(var(--foreground))",
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
primary: {
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
DEFAULT: "hsl(var(--primary))",
|
},
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
secondary: {
|
||||||
},
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
secondary: {
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
},
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
muted: {
|
||||||
},
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
destructive: {
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
},
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
accent: {
|
||||||
},
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
muted: {
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
DEFAULT: "hsl(var(--muted))",
|
},
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
destructive: {
|
||||||
},
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
accent: {
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
DEFAULT: "hsl(var(--accent))",
|
},
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
border: 'hsl(var(--border))',
|
||||||
},
|
input: 'hsl(var(--input))',
|
||||||
popover: {
|
ring: 'hsl(var(--ring))',
|
||||||
DEFAULT: "hsl(var(--popover))",
|
chart: {
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
'1': 'hsl(var(--chart-1))',
|
||||||
},
|
'2': 'hsl(var(--chart-2))',
|
||||||
card: {
|
'3': 'hsl(var(--chart-3))',
|
||||||
DEFAULT: "hsl(var(--card))",
|
'4': 'hsl(var(--chart-4))',
|
||||||
foreground: "hsl(var(--card-foreground))",
|
'5': 'hsl(var(--chart-5))'
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require("tailwindcss-animate")],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
|
|
@ -20,7 +19,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue