diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 15b1ed9..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next" -} diff --git a/.gitignore b/.gitignore index d32cc78..5ef6a52 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +.pnpm-debug.log* # env files (can opt-in for committing if needed) .env* diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/discord.xml b/.idea/discord.xml deleted file mode 100644 index 912db82..0000000 --- a/.idea/discord.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml deleted file mode 100644 index 9c331f5..0000000 --- a/.idea/material_theme_project_new.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 6d06177..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/sipher.iml b/.idea/sipher.iml deleted file mode 100644 index 24643cc..0000000 --- a/.idea/sipher.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f66bd35 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "css.customData": [ + ".vscode/tailwind.json" + ], + "editor.tabSize": 2, + "editor.wordWrap": "wordWrapColumn", + "editor.insertSpaces": false, + "editor.wordWrapColumn": 120, + "editor.detectIndentation": false, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.formatOnType": true, + "typescript.preferences.quoteStyle": "double", + "editor.formatOnSaveMode": "file", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } +} \ No newline at end of file diff --git a/.vscode/tailwind.json b/.vscode/tailwind.json new file mode 100644 index 0000000..8f1241e --- /dev/null +++ b/.vscode/tailwind.json @@ -0,0 +1,105 @@ +{ + "version": 4.0, + "atDirectives": [ + { + "name": "@import", + "description": "Use the `@import` directive to inline import CSS files, including Tailwind itself.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#import-directive" + } + ] + }, + { + "name": "@theme", + "description": "Use the `@theme` directive to define your project's custom design tokens, like fonts, colors, and breakpoints.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#theme-directive" + } + ] + }, + { + "name": "@source", + "description": "Use the `@source` directive to explicitly specify source files that aren't picked up by Tailwind's automatic content detection.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#source-directive" + } + ] + }, + { + "name": "@utility", + "description": "Use the `@utility` directive to add custom utilities to your project that work with variants like `hover`, `focus` and `lg`.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#utility-directive" + } + ] + }, + { + "name": "@variant", + "description": "Use the `@variant` directive to apply a Tailwind variant to styles in your CSS. If you need to apply multiple variants at the same time, use nesting.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#variant-directive" + } + ] + }, + { + "name": "@custom-variant", + "description": "Use the `@custom-variant` directive to add a custom variant in your project. This lets you write utilities like `pointer-coarse:size-48` and `theme-midnight:bg-slate-900`.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#custom-variant-directive" + } + ] + }, + { + "name": "@apply", + "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you need to write custom CSS (like to override the styles in a third-party library) but still want to work with your design tokens and use the same syntax you’re used to using in your HTML.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#apply-directive" + } + ] + }, + { + "name": "@reference", + "description": "If you want to use `@apply` or `@variant` in the ` + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/logo-light.png b/public/logos/logo-light.png deleted file mode 100644 index cb478f1..0000000 Binary files a/public/logos/logo-light.png and /dev/null differ diff --git a/public/logos/logo.png b/public/logos/logo.png deleted file mode 100644 index f05e14c..0000000 Binary files a/public/logos/logo.png and /dev/null differ diff --git a/public/logos/united-chat.png b/public/logos/united-chat.png deleted file mode 100644 index fb550dc..0000000 Binary files a/public/logos/united-chat.png and /dev/null differ diff --git a/src/app/[id]/page.tsx b/src/app/[id]/page.tsx deleted file mode 100644 index 6cd8647..0000000 --- a/src/app/[id]/page.tsx +++ /dev/null @@ -1,411 +0,0 @@ -"use client" -import {useEffect, useState} from 'react'; -import {AnimatePresence, motion} from 'framer-motion'; -import {useTheme} from 'next-themes'; -import {Button} from '@/components/ui/button'; -import {Input} from '@/components/ui/input'; -import {ScrollArea} from '@/components/ui/scroll-area'; -import {Avatar, AvatarFallback} from '@/components/ui/avatar'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from '@/components/ui/tooltip'; -import { - Archive, - Ban, - Clock, - Download, - Info, - Key, - KeyRound, - MoreVertical, - Send, - ShieldCheck, - UserCheck, - UserX -} from 'lucide-react'; -import {usePathname} from "next/navigation"; -import {useUser} from "@/contexts/user"; -import {useToast} from "@/hooks/use-toast"; -import {useSharedState} from "@/hooks/shared-states"; -import {createBrowserClient} from '@/lib/supabase/browser' -import {CryptoManager} from "@/lib/crypto/keys"; -import {REALTIME_SUBSCRIBE_STATES} from "@supabase/realtime-js"; -import ChatSkeleton from "@/app/[id]/skeleton"; - -export default function ChatPage() { - const {toast} = useToast(); - const supabase = createBrowserClient(); - - const [messages, setMessages] = useState([]); - const [inputMessage, setInputMessage] = useState(''); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [showKeyDialog, setShowKeyDialog] = useState(false); - const [showUserDialog, setShowUserDialog] = useState(false); - const [isEncrypted, setIsEncrypted] = useState(true); - - const [realtimeSubscribed, setRealtimeSubscribed] = useState(REALTIME_SUBSCRIBE_STATES.CLOSED); - - const [isLoaded, setIsLoaded] = useState(false); - - const [user, setUser] = useState(null); - const pathName = usePathname(); - const threadId = pathName.replace("/", ""); - - const { - user: currentUser, - getUser - } = useUser() - - const {threads} = useSharedState(); - - useEffect(() => { - const channel = supabase - .channel(`messages:${threadId}`) - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - table: 'messages', - }, - async (payload) => { - if (payload.eventType === "INSERT") { - try { - const messageData = payload.new as SiPher.RealtimeMessageData; - const isSender = messageData.sender_uuid === currentUser.uuid; - - const decryptedMsg = await CryptoManager.decryptMessage( - // I forgot to add this, without this, it's pretty much unusable. - isSender ? messageData.sender_content : messageData.recipient_content - ) - - setMessages((prevState) => { - return [ - ...prevState, - { - id: messageData.id, - content: decryptedMsg, - sender_uuid: messageData.sender_uuid, - created_at: messageData.created_at, - isSender - } - ] - }) - } catch (e: any) { - console.error(`Something went wrong on the message update: ${e}`) - } - } - } - ) - .subscribe((status) => { - setRealtimeSubscribed(status) - console.info(`Subscription for thread ${threadId} has the status "${status}"`) - console.info("If closed, something bad might be happening at the backend.") - }) - - return () => { - supabase.removeChannel(channel) - } - }, [threadId, currentUser.uuid, supabase]) - - useEffect(() => { - const getUserDataAndChat = async () => { - const {thread: getThread} = await (await fetch(`/api/user/get/thread?threadId=${threadId}`)).json() as { - thread: SiPher.Thread - }; - - const otherUser = getThread.participant_suuids.filter((ids) => ids !== currentUser.suuid); - const user = await getUser(`Being called from chat page (${threadId}`, otherUser[0], "suuid", true) - - if (!(user.user[0].suuid && user.user[0].username)) { - toast({ - title: "Error", - description: "Could not verify the existence of this user", - variant: "destructive", - duration: 5000 - }); - } - - setUser(user.user[0]) - - const decryptedMsg = await CryptoManager.decryptThreadMessages(getThread["messages"], currentUser.uuid) - setMessages(decryptedMsg) - } - - if (threads.length > 0) { - setIsLoaded(true) - getUserDataAndChat() - } - - return () => { - setUser(null) - setMessages([]) - setIsLoaded(false) - } - }, [threadId, currentUser.uuid, supabase]) // Never trusting the lint again - - useEffect(() => { - if (!realtimeSubscribed) return; - - const timeoutId = setTimeout(() => { - if (realtimeSubscribed === 'TIMED_OUT' || realtimeSubscribed === 'CLOSED') { - toast({ - title: "Connection Issue", - description: "You might need to restart your browser due to connection issues.", - variant: "destructive", - duration: 10000, - }); - } - }, 10000); - - return () => clearTimeout(timeoutId); - }, [realtimeSubscribed, toast]); - - if (!isLoaded || !user || realtimeSubscribed !== "SUBSCRIBED") { - return ; - } - - const checkUserValidity = async () => { - // Implementation for checking user validity - setShowUserDialog(true); - }; - - const checkCurrentKey = async () => { - // Implementation for checking current key - setShowKeyDialog(true); - }; - - const deleteUser = async () => { - // Implementation for deleting user - setShowDeleteDialog(true); - }; - - const sendMessage = async (content: string) => { - if (!content.trim()) return; - setInputMessage(''); - - await CryptoManager.prepareAndSendMessage( - content, - currentUser.public_key, - user.public_key, - threadId - ) - }; - - return ( -
-
-
- - - { - user.username.charAt(0).toLocaleUpperCase() - } - - -
-

- { - user.username.charAt(0).toLocaleUpperCase() + user.username.slice(1) - } -

-
-
- -
- - - - - - - {isEncrypted ? 'Encrypted Chat' : 'Encryption Issue'} - - - - - - - - - - Chat Options - - - - - Check User - - - - - Check Current Key - - - - - - - Message History - - - - - Archive Chat - - - - - Export Chat - - - - - - - Delete User - - - -
-
- - -
- - {messages.map((message) => ( - -
-

{message.content}

-
- - {new Date(message.created_at).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit' - })} - -
-
-
- ))} -
-
-
- -
-
- setInputMessage(e.target.value)} - placeholder="Type a message..." - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(inputMessage); - } - }} - /> - -
-
- - - - - Delete User - - Are you sure you want to delete this user? This will remove them from your contacts - and delete all messages. This action cannot be undone. - - - - Cancel - Delete - - - - - - - - Encryption Status - -
- - Local private key is valid and active -
-
- - Remote public key is verified -
-
- - End-to-end encryption is active -
-
-
- - Close - -
-
- - - - - User Verification - -
- - User is verified and active -
-
- - Last active: 2 minutes ago -
-
- - Secure connection established -
-
-
- - Close - -
-
-
- ); -} \ No newline at end of file diff --git a/src/app/[id]/skeleton.tsx b/src/app/[id]/skeleton.tsx deleted file mode 100644 index ff18e85..0000000 --- a/src/app/[id]/skeleton.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import {Skeleton} from "@/components/ui/skeleton"; -import {ScrollArea} from "@/components/ui/scroll-area"; - -export default function ChatSkeleton() { - return ( -
- {/* Header Skeleton */} -
-
- - -
-
- - -
-
- - {/* Messages Skeleton */} - -
- {/* Left message */} -
- -
- {/* Right message */} -
- -
- {/* Left message */} -
- -
- {/* Right message */} -
- -
-
-
- - {/* Input Area Skeleton */} -
-
- - -
-
-
- ); -} \ No newline at end of file diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx deleted file mode 100644 index 015ee0d..0000000 --- a/src/app/about/page.tsx +++ /dev/null @@ -1,234 +0,0 @@ -"use client" -import {motion} from "framer-motion"; -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; -import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert"; -import {Accordion, AccordionContent, AccordionItem, AccordionTrigger,} from "@/components/ui/accordion"; -import {Separator} from "@/components/ui/separator"; -import {AlertTriangle, KeyRound, Lock, MessageSquare, Shield, UserCheck,} from "lucide-react"; - -export default function AboutPage() { - const containerVariants = { - hidden: {opacity: 0}, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1 - } - } - }; - - const itemVariants = { - hidden: {opacity: 0, y: 20}, - visible: {opacity: 1, y: 0} - }; - - return ( - - -

About SiPher

-

- Where privacy meets simplicity in secure communication -

-
- - - - - - - Important Notice - - SiPher is a CS50X final project and is not intended for production use. - While we implement strong encryption, please do not use it for sensitive communications. - - - - - - - - How SiPher Works - - Understanding the security behind your messages - - - -
-
- -
-

Key Generation

-

- Each user has a unique public-private key pair generated in their browser. Lost it and didn't - make a - backup? Welp, skill issue I guess. -

-
-
- -
- -
-

End-to-End Encryption

-

- Messages are encrypted before leaving your device -

-
-
- -
- -
-

Zero (And A Half) Trust

-

- Server never sees your decrypted messages. But we do store their encrypted version though lmao. -

-
-
- -
- -
-

User Privacy

-

- Users are identified by unique IDs, not personal information. No e-mail, no nothing, only your ID - (and probably IP due to Supabase logging it) -

-
-
-
-
-
-
- - - - - Technical Details - - The technology powering SiPher's "security" - - - -
-

Encryption

-
    -
  • RSA-OAEP for key exchange
  • -
  • AES-GCM for message encryption
  • -
  • PBKDF2 for key derivation
  • -
  • SHA-256 for message integrity
  • -
-
- -
-

Implementation

-
    -
  • Web Crypto API for cryptographic operations
  • -
  • Next.js for the application framework
  • -
  • Supabase for real-time messaging
  • -
  • TailwindCSS and ShadcnUI for the interface (I suck at design)
  • -
-
-
-
-
- - - - - Frequently Asked Questions - - - - - How secure are my messages? - - Messages are encrypted using industry-standard algorithms and never stored in plaintext. - However, as this is an educational project, I recommend not using it for sensitive communications. - If you do and I get a notice, I will give out the data I have on you. I don't care. - - - - - What happens if I lose my private key? - - If you lose your private key, you won't be able to decrypt previous messages. - You can generate a new key pair, but you'll need to start fresh conversations, previous messages - from - other conversations will be lost forever. - Always backup your private key in the settings. - - - - - Can I recover deleted messages? - - You can't even delete chats, imagine messages lmao. - - - - - How do I verify a user's identity? - - Each user has a unique SUUID (Short UUID) that can be shared and verified. - You can verify a user's identity by comparing their SUUID in a secure channel. - - - - - Is SiPher open source? - - Not yet. As this is a CS50X final project, the code will be made available - for educational purposes in the future. - - - - - Will you continue this project after submitting it? - - Probably. It's quite fun dealing with encryption. - - - - - - - - - - - Message Flow - - How your message travels from you to the other user - - - -
-
- -
-
-
-
- -
-
-

- Messages are encrypted on your device before being sent through our servers, - ensuring end-to-end encryption for all communications. -

- - - - - -

Built with đź’– as a CS50X final project

-
- - ); -} \ No newline at end of file diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..6cfe400 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,3 @@ +import { nextJsHandler } from "@convex-dev/better-auth/nextjs"; + +export const { GET, POST } = nextJsHandler(); \ No newline at end of file diff --git a/src/app/api/auth/get_user/route.ts b/src/app/api/auth/get_user/route.ts deleted file mode 100644 index e4e5d7a..0000000 --- a/src/app/api/auth/get_user/route.ts +++ /dev/null @@ -1,58 +0,0 @@ -import {createClient} from "@/lib/supabase/server"; -import {NextResponse} from "next/server"; -import getUserByUUID from "@/lib/api/helpers/getUserByUUID"; - -// Helper function to get user data by UUID - -export async function GET(request: Request) { - try { - const supabase = await createClient(); - const {searchParams} = new URL(request.url); - const uuid = searchParams.get('uuid'); - const suuid = searchParams.get('suuid'); - const getDetails = searchParams.get("detailed") - - if (uuid) { - // Get specific user by UUID - const userData = await getUserByUUID(supabase, uuid); - return NextResponse.json({user: userData}); - } else if (suuid) { - const {data, error} = await supabase.rpc('search_users', { - search_term: suuid - }); - - if (error) { - return NextResponse.json({error: error}, {status: 500}); - } - - if (getDetails) { - return NextResponse.json({user: data}) - } - - return NextResponse.json({exists: !!(data[0].suuid && data[0].username)}, {status: 200}); - } 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) { - if (typeof error === "object") { - return NextResponse.json( - {error: `Failed to fetch user: ${JSON.stringify(error)}`}, - {status: 500} - ); - } - - return NextResponse.json( - {error: `Failed to fetch user: ${error}`}, - {status: 500} - ); - } -} \ No newline at end of file diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts deleted file mode 100644 index d7fa4d9..0000000 --- a/src/app/api/auth/login/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -// 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() - - const domain = process.env.DOMAIN; - - if (!domain) { - return NextResponse.json({ - error: "Server is misconfigured, please check env variables and try again." - }, - { - status: 500 - }) - } - - // Mocks the email with the domain we configured on the local env - const email = `${username.toLowerCase()}@${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('*, public_key') - .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} - ) - } -} \ No newline at end of file diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts deleted file mode 100644 index 37af265..0000000 --- a/src/app/api/auth/register/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import {NextResponse} from 'next/server' -import {createClient} from "@/lib/supabase/server"; - -export async function POST(request: Request) { - const {username, password, public_key} = await request.json() - const supabase = await createClient() - - try { - const domain = process.env.DOMAIN; - - if (!domain) { - return NextResponse.json({ - error: "Server is misconfigured, please check env variables and try again." - }, - { - status: 500 - }) - } else if (!username || !password || !public_key) { - return NextResponse.json({ - error: "Missing params" - }, {status: 400}) - } - - // First create the auth user - const {data: {user}, error: authError} = await supabase.auth.signUp({ - email: `${username}@${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, - public_key - }) - - 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: JSON.stringify(error)}, - {status: 400} - ) - } - return NextResponse.json( - {error: `Registration failed: ${error}`}, - {status: 400} - ) - } -} \ No newline at end of file diff --git a/src/app/api/user/create/thread/route.ts b/src/app/api/user/create/thread/route.ts deleted file mode 100644 index af5d214..0000000 --- a/src/app/api/user/create/thread/route.ts +++ /dev/null @@ -1,57 +0,0 @@ -import {NextResponse} from "next/server"; -import {createClient} from "@/lib/supabase/server"; -import getUserByUUID from "@/lib/api/helpers/getUserByUUID"; - -export async function POST(req: Request) { - const {participant} = await req.json(); - - if (!participant) { - return NextResponse.json({error: 'Participant not found'}, {status: 400}); - } - - const supabase = await createClient() - - const {data: {user}, error: userError} = await supabase.auth.getUser() - console.log("From user: ", user?.id) - if (userError) { - return NextResponse.json( - {error: userError}, - {status: userError?.status} - ) - } else if (!user) { - return NextResponse.json( - {error: "User not found"}, - {status: 401} - ) - } - - /** First we need to check if the requested participant is in the user's request array */ - const dbUser = await getUserByUUID(supabase, user.id) - - if (!dbUser) { - return NextResponse.json( - {error: "User not found"}, - {status: 401} - ) - } - - const requests = dbUser.requests as string[] - - if (!requests.includes(participant)) { - return NextResponse.json({error: "Requested user not in requests array."}, {status: 400}) - } else if (participant === dbUser.suuid) { - return NextResponse.json({error: "Cannot add self to a new thread"}, {status: 400}) - } - - /** Then we can create the thread */ - - const {error} = await supabase.rpc('create_private_thread', { - participant_suuid: participant - }); - - if (error) { - return NextResponse.json({error}, {status: 500}); - } - - return NextResponse.json({success: true}, {status: 200}); -} \ No newline at end of file diff --git a/src/app/api/user/get/thread/route.ts b/src/app/api/user/get/thread/route.ts deleted file mode 100644 index 4f7daa0..0000000 --- a/src/app/api/user/get/thread/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {createClient} from "@/lib/supabase/server"; -import {NextResponse} from "next/server"; - -export async function GET(request: Request) { - try { - const {searchParams} = new URL(request.url); - const threadId = searchParams.get('threadId'); - - if (!threadId) { - return NextResponse.json({ - error: "No thread id provided" - }, {status: 400}) - } - - 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_thread", - { - thread_uuid: threadId, - user_id: user!.id - } - ) - - if (error) { - return NextResponse.json({error}, {status: 400}) - } - - return NextResponse.json({thread: data[0]}, {status: 200}); - - } catch (e: any) { - console.log(e) - if (typeof e === "object") { - return NextResponse.json({error: JSON.stringify(e)}, {status: 500}) - } - - return NextResponse.json({error: e}, {status: 500}) - } -} \ No newline at end of file diff --git a/src/app/api/user/get/threads/route.ts b/src/app/api/user/get/threads/route.ts deleted file mode 100644 index 7d14dd9..0000000 --- a/src/app/api/user/get/threads/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 (error) { - return NextResponse.json({error}, {status: 400}) - } - - return NextResponse.json({threads: data}, {status: 200}); - - } catch (e) { - } -} \ No newline at end of file diff --git a/src/app/api/user/search/user/route.ts b/src/app/api/user/search/user/route.ts deleted file mode 100644 index 23f5e7e..0000000 --- a/src/app/api/user/search/user/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -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'); - const getDetails = searchParams.get("detailed") - - 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 {data, error} = await supabase.rpc('search_users', { - search_term: uuid - }); - - if (error) { - return NextResponse.json({error: error}, {status: 500}); - } - - if (getDetails) { - return NextResponse.json({user: data}) - } - - return NextResponse.json({exists: !!(data[0].suuid && data[0].username)}, {status: 200}); - - } catch (error) { - return NextResponse.json({error: error}, {status: 500}); - } -} \ No newline at end of file diff --git a/src/app/api/user/send/message/route.ts b/src/app/api/user/send/message/route.ts deleted file mode 100644 index 2d16b2e..0000000 --- a/src/app/api/user/send/message/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {createClient} from "@/lib/supabase/server"; -import {NextResponse} from "next/server"; - -export async function POST(request: Request) { - try { - const {threadId, senderContent, recipientContent} = await request.json(); - const supabase = await createClient(); - - const {data, error} = await supabase.rpc('send_message', { - thread_uuid: threadId, - sender_content: senderContent, - recipient_content: recipientContent - }); - - if (error) throw error; - - return NextResponse.json({messageId: data}); - } catch (error: any) { - if (typeof error === "object") { - return NextResponse.json( - {error}, - {status: 500} - ); - } - - return NextResponse.json( - {error: 'Failed to send message', details: error.message}, - {status: 500} - ); - } -} \ No newline at end of file diff --git a/src/app/api/user/send/request/route.ts b/src/app/api/user/send/request/route.ts deleted file mode 100644 index f291698..0000000 --- a/src/app/api/user/send/request/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import {createClient} from "@/lib/supabase/server"; -import {NextResponse} from "next/server"; -import getUserByUUID from "@/lib/api/helpers/getUserByUUID"; -import updateUserRequests from "@/lib/api/helpers/updateUserRequests"; - -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 getUser = await getUserByUUID(supabase, user.id) - const userSuuid = getUser.suuid; - - if (userSuuid === searchTerm) { - return NextResponse.json({success: false, hint: "Cannot send request to self"}, {status: 409}); - } - - const result = await updateUserRequests(searchTerm, userSuuid, supabase); - - if (!result.success) { - return NextResponse.json( - {error: result.error}, - {status: 500} - ); - } - - return NextResponse.json({success: true}); - } catch (err) { - return NextResponse.json( - {error: `Failed to update requests: ${err}`}, - {status: 500} - ); - } -} \ No newline at end of file diff --git a/src/app/api/user/send/update/key/route.ts b/src/app/api/user/send/update/key/route.ts deleted file mode 100644 index f59e220..0000000 --- a/src/app/api/user/send/update/key/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {createClient} from "@/lib/supabase/server"; -import {NextResponse} from "next/server"; - -export async function POST(request: Request) { - try { - const {publicKey} = await request.json(); - const supabase = await createClient(); - - const {error} = await supabase - .from('users') - .update({public_key: publicKey}) - .eq('uuid', (await supabase.auth.getUser()).data.user?.id); - - if (error) throw error; - - return NextResponse.json({success: true}); - } catch (error) { - return NextResponse.json( - {error: 'Failed to update public key'}, - {status: 500} - ); - } -} \ No newline at end of file diff --git a/src/app/auth/components/sign-in-form.tsx b/src/app/auth/components/sign-in-form.tsx new file mode 100644 index 0000000..4815651 --- /dev/null +++ b/src/app/auth/components/sign-in-form.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth/client"; +import { ErrorContext } from "better-auth/react"; +import { Loader2 } from "lucide-react"; +import { redirect } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; + +export function SignInForm( + { captchaToken }: { captchaToken: string | null } +) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSignIn = async (e: React.FormEvent) => { + e.preventDefault(); + await authClient.signIn.username( + { + username, + password, + fetchOptions: { + headers: { + "x-captcha-response": captchaToken ?? "", + }, + }, + }, + { + onRequest: () => { + setLoading(true); + }, + onSuccess: () => { + setLoading(false); + toast.success("Signed in successfully"); + redirect("/"); + }, + onError: (ctx: ErrorContext) => { + setLoading(false); + toast.error(ctx.error.message); + }, + + } + ); + }; + + return ( +
+
+ + setUsername(e.target.value)} + className="bg-background/50 focus:bg-background transition-colors" + /> +
+
+ + setPassword(e.target.value)} + className="bg-background/50 focus:bg-background transition-colors" + /> +
+ +
+ ); +} + diff --git a/src/app/auth/components/sign-up-form.tsx b/src/app/auth/components/sign-up-form.tsx new file mode 100644 index 0000000..6dd22c0 --- /dev/null +++ b/src/app/auth/components/sign-up-form.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth/client"; +import { ErrorContext } from "better-auth/react"; +import { Check, Eye, EyeOff, Loader2, RefreshCw, X } from "lucide-react"; +import { redirect } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; + +export function SignUpForm( + { captchaToken }: { captchaToken: string | null } +) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isUsernameAvailable, setIsUsernameAvailable] = useState(null); + const [loading, setLoading] = useState(false); + const [isValidatingUsername, setIsValidatingUsername] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + const handleSignUp = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirmPassword) { + toast.error("Passwords do not match"); + return; + } + + if (password.length > 30) { + toast.error("Password must be less than 30 characters"); + return; + } + + await authClient.signUp.email( + { + email: `${username}.user@sipher.space`, + name: username, + username, + password, + fetchOptions: { + headers: { + "x-captcha-response": captchaToken ?? "", + }, + }, + }, + { + onRequest: () => { + setLoading(true); + }, + onSuccess: async () => { + setLoading(false); + toast.success("Account created successfully, logging in..."); + await authClient.signIn.username( + { + username, + password, + fetchOptions: { + headers: { + "x-captcha-response": captchaToken ?? "", + }, + }, + }, + { + onSuccess: () => { + toast.success("Logged in successfully"); + redirect("/"); + }, + onError: (ctx: ErrorContext) => { + toast.error(ctx.error.message); + }, + } + ); + }, + onError: (ctx: ErrorContext) => { + setLoading(false); + toast.error(ctx.error.message); + }, + + } + ); + }; + + const generatePassword = () => { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+"; + let newPassword = ""; + for (let i = 0; i < 16; i++) { + newPassword += chars.charAt(Math.floor(Math.random() * chars.length)); + } + setPassword(newPassword); + setConfirmPassword(newPassword); + navigator.clipboard.writeText(newPassword); + toast.success("Password generated and copied to clipboard"); + }; + + return ( +
+
+ +
+ { + const val = e.target.value; + setUsername(val); + if (val) { + setIsValidatingUsername(true); + // @ts-ignore + const isValid = await authClient.isUsernameAvailable({ username: val }); + setIsUsernameAvailable(!!isValid); + setIsValidatingUsername(false); + } else { + setIsUsernameAvailable(null); + } + }} + className={`bg-background/50 focus:bg-background transition-colors pr-10 ${isUsernameAvailable === false ? "border-red-500 focus-visible:ring-red-500" : + isUsernameAvailable === true ? "border-green-500 focus-visible:ring-green-500" : "" + }`} + /> +
+ {isValidatingUsername ? ( + + ) : isUsernameAvailable === true ? ( + + ) : isUsernameAvailable === false ? ( + + ) : null} +
+
+ {isUsernameAvailable === false && ( +

Username is already taken

+ )} +
+
+ +
+ setPassword(e.target.value)} + className={`bg-background/50 focus:bg-background transition-colors pr-24 ${password.length >= 8 && password.length <= 30 + ? "border-green-500 focus-visible:ring-green-500" + : password.length > 30 + ? "border-red-500 focus-visible:ring-red-500" + : "" + }`} + /> +
+ {password.length > 30 ? ( + + ) : password.length >= 8 && ( + + )} + + +
+
+ {password.length > 30 && ( +

Password must be less than 30 characters

+ )} +
+
+ +
+ setConfirmPassword(e.target.value)} + className={`bg-background/50 focus:bg-background transition-colors pr-10 ${confirmPassword && password === confirmPassword && password.length <= 30 + ? "border-green-500 focus-visible:ring-green-500" + : (confirmPassword && password !== confirmPassword) || (confirmPassword && password.length > 30) + ? "border-red-500 focus-visible:ring-red-500" + : "" + }`} + /> +
+ {confirmPassword && password === confirmPassword && password.length <= 30 ? ( + + ) : confirmPassword && (password !== confirmPassword || password.length > 30) ? ( + + ) : null} +
+
+ {confirmPassword && password !== confirmPassword && ( +

Passwords do not match

+ )} +
+ +
+ ); +} diff --git a/src/app/auth/login/login.ts b/src/app/auth/login/login.ts deleted file mode 100644 index c59245b..0000000 --- a/src/app/auth/login/login.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * - * @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"}; - } -} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx deleted file mode 100644 index c5bcf9a..0000000 --- a/src/app/auth/login/page.tsx +++ /dev/null @@ -1,225 +0,0 @@ -"use client" - -import React, {useCallback, 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(); - - const check = useCallback(async () => { - const isAuthenticated = await checkAuth("Called on Login page"); - if (isAuthenticated) { - router.replace('/'); - } else { - setMounted(true); - } - }, [checkAuth, router, setMounted]) - - useEffect(() => { - check().then(() => { - console.log("Login page check finished") - }) - }, [check]); - - if (!mounted) { - return
- -
; - } - - - 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) { - const msg = response.message - - try { - const parsed = JSON.parse(msg); - let desc = parsed.name; - - switch (desc) { - case "AuthWeakPasswordError": { - desc = "Password too weak, please try again."; - break; - } - default: { - desc = "An unknown error occurred"; - } - } - - toast({ - title: "Error", - description: desc, - variant: "destructive", - duration: 5000 - }); - } catch (e) { - // If msg isn't valid JSON, show the raw message - toast({ - title: "Error", - description: msg, - variant: "destructive", - duration: 5000 - }); - } - } else { - toast({ - title: "Success", - description: response.message, - variant: "default", - duration: 5000, // Increased duration for better visibility - }); - window.location.href = "/"; - } - - setTimeout(() => { - setIsSubmitting(false); - }, 2000) - }; - - return ( -
- - -
-
- SiPher -

- Silent Whisper -

-

- Trust the shadows. Whisper safely. -

-
-
- -

- {isLogin ? "Sign In" : "Sign Up"} -

-
-
- - -
-
- -
- - -
-
- -
-
- -
-
-
-
-
-
-
- ) -} \ No newline at end of file diff --git a/src/app/auth/login/register.ts b/src/app/auth/login/register.ts deleted file mode 100644 index e9aa465..0000000 --- a/src/app/auth/login/register.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {CryptoManager} from "@/lib/crypto/keys"; - -/** - * - * @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(username: string, password: string) { - try { - const keyPair = await CryptoManager.generateUserKeys(); - await CryptoManager.storePrivateKey(keyPair.privateKey); - - // Export public key for server - const exportedPublic = await crypto.subtle.exportKey('jwk', keyPair.publicKey); - - // 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, public_key: exportedPublic}), // 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: res.status, - message: data.error - } - } - - // 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: e.error - } - } -} diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx new file mode 100644 index 0000000..c45220a --- /dev/null +++ b/src/app/auth/page.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { ModeToggle } from "@/components/mode-toggle"; +import { Button } from "@/components/ui/button"; +import Captcha, { CaptchaRef } from "@/components/ui/captcha"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { authClient } from "@/lib/auth/client"; +import { AnimatePresence, motion } from "framer-motion"; +import { RefreshCw } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { SignInForm } from "./components/sign-in-form"; +import { SignUpForm } from "./components/sign-up-form"; + +export default function AuthPage() { + const { data, error, isPending } = authClient.useSession(); + const [captchaToken, setCaptchaToken] = useState(null); + const [method, setMethod] = useState<"signIn" | "signUp">("signIn"); + const captchaRef = useRef(null); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (error && error.status !== 404) { + console.error("[AuthPage] > Error:", error); + toast.error(error.message); + } else if (data) { + console.log(`[AuthPage] > User ${data.user.username} logged in, redirecting to home...`); + redirect("/"); + } + + const toggleMethod = () => { + setMethod(method === "signIn" ? "signUp" : "signIn"); + }; + + return ( +
+ {/* Animated Background Blobs */} + + + + + +
+ +
+ + + + + {method === "signIn" ? "Welcome Back" : "Create Account"} + + + {method === "signIn" + ? "Enter your credentials to access your account" + : "Enter your details to get started with us"} + + + + + + {method === "signIn" ? : } + + +
+ {method === "signIn" ? "Don't have an account? " : "Already have an account? "} + +
+ {/* Turnstile */} +
+ + {/* Reload the captcha */} + +
+ +
+

+ built with{" "} + + better-auth + +

+
+
+
+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index b1b7658..b23277f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,61 +1,202 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1.0000 0 0); + --foreground: oklch(0.2686 0 0); + --card: oklch(1.0000 0 0); + --card-foreground: oklch(0.2686 0 0); + --popover: oklch(1.0000 0 0); + --popover-foreground: oklch(0.2686 0 0); + --primary: oklch(0.7686 0.1647 70.0804); + --primary-foreground: oklch(0 0 0); + --secondary: oklch(0.9670 0.0029 264.5419); + --secondary-foreground: oklch(0.4461 0.0263 256.8018); + --muted: oklch(0.9846 0.0017 247.8389); + --muted-foreground: oklch(0.5510 0.0234 264.3637); + --accent: oklch(0.9869 0.0214 95.2774); + --accent-foreground: oklch(0.4732 0.1247 46.2007); + --destructive: oklch(0.6368 0.2078 25.3313); + --destructive-foreground: oklch(1.0000 0 0); + --border: oklch(0.9276 0.0058 264.5313); + --input: oklch(0.9276 0.0058 264.5313); + --ring: oklch(0.7686 0.1647 70.0804); + --chart-1: oklch(0.7686 0.1647 70.0804); + --chart-2: oklch(0.6658 0.1574 58.3183); + --chart-3: oklch(0.5553 0.1455 48.9975); + --chart-4: oklch(0.4732 0.1247 46.2007); + --chart-5: oklch(0.4137 0.1054 45.9038); + --sidebar: oklch(0.9846 0.0017 247.8389); + --sidebar-foreground: oklch(0.2686 0 0); + --sidebar-primary: oklch(0.7686 0.1647 70.0804); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.9869 0.0214 95.2774); + --sidebar-accent-foreground: oklch(0.4732 0.1247 46.2007); + --sidebar-border: oklch(0.9276 0.0058 264.5313); + --sidebar-ring: oklch(0.7686 0.1647 70.0804); + --font-sans: Inter, sans-serif; + --font-serif: Source Serif 4, serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.375rem; + --shadow-x: 1px; + --shadow-y: 2px; + --shadow-blur: 8px; + --shadow-spread: -1px; + --shadow-opacity: 0.1; + --shadow-color: hsl(0 0% 0%); + --shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05); + --shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05); + --shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10); + --shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10); + --shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10); + --shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10); + --shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25); + --tracking-normal: 0em; + --spacing: 0.25rem; + --shadow-offset-x: 0px; + --shadow-offset-y: 4px; + --letter-spacing: 0em; +} + +.dark { + --background: oklch(0.2046 0 0); + --foreground: oklch(0.9219 0 0); + --card: oklch(0.2686 0 0); + --card-foreground: oklch(0.9219 0 0); + --popover: oklch(0.2686 0 0); + --popover-foreground: oklch(0.9219 0 0); + --primary: oklch(0.7686 0.1647 70.0804); + --primary-foreground: oklch(0 0 0); + --secondary: oklch(0.2686 0 0); + --secondary-foreground: oklch(0.9219 0 0); + --muted: oklch(0.2393 0 0); + --muted-foreground: oklch(0.7155 0 0); + --accent: oklch(0.4732 0.1247 46.2007); + --accent-foreground: oklch(0.9243 0.1151 95.7459); + --destructive: oklch(0.6368 0.2078 25.3313); + --destructive-foreground: oklch(1.0000 0 0); + --border: oklch(0.3715 0 0); + --input: oklch(0.3715 0 0); + --ring: oklch(0.7686 0.1647 70.0804); + --chart-1: oklch(0.8369 0.1644 84.4286); + --chart-2: oklch(0.6658 0.1574 58.3183); + --chart-3: oklch(0.4732 0.1247 46.2007); + --chart-4: oklch(0.5553 0.1455 48.9975); + --chart-5: oklch(0.4732 0.1247 46.2007); + --sidebar: oklch(0.1684 0 0); + --sidebar-foreground: oklch(0.9219 0 0); + --sidebar-primary: oklch(0.7686 0.1647 70.0804); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.4732 0.1247 46.2007); + --sidebar-accent-foreground: oklch(0.9243 0.1151 95.7459); + --sidebar-border: oklch(0.3715 0 0); + --sidebar-ring: oklch(0.7686 0.1647 70.0804); + --font-sans: Inter, sans-serif; + --font-serif: Source Serif 4, serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.375rem; + --shadow-x: 1px; + --shadow-y: 2px; + --shadow-blur: 8px; + --shadow-spread: -1px; + --shadow-opacity: 0.1; + --shadow-color: hsl(0 0% 0%); + --shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05); + --shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05); + --shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10); + --shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 1px 2px -2px hsl(0 0% 0% / 0.10); + --shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 2px 4px -2px hsl(0 0% 0% / 0.10); + --shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 4px 6px -2px hsl(0 0% 0% / 0.10); + --shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.10), 0px 8px 10px -2px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.25); + --shadow-offset-x: 0px; + --shadow-offset-y: 4px; + --letter-spacing: 0em; + --spacing: 0.25rem; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --font-sans: Inter, sans-serif; + --font-mono: JetBrains Mono, monospace; + --font-serif: Source Serif 4, serif; + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); + + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-normal: var(--tracking-normal); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); + --radius: 0.375rem; + --spacing: var(--spacing); + --letter-spacing: var(--letter-spacing); + --shadow-offset-y: var(--shadow-offset-y); + --shadow-offset-x: var(--shadow-offset-x); + --shadow-spread: var(--shadow-spread); + --shadow-blur: var(--shadow-blur); + --shadow-opacity: var(--shadow-opacity); + --color-shadow-color: var(--shadow-color); +} + +body { + letter-spacing: var(--tracking-normal); +} @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%; - } -} + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + letter-spacing: var(--tracking-normal); + } +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ad055f3..43552a3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,73 +1,57 @@ -// app/layout.tsx -import type {Metadata} from "next"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/sonner"; +import { ConvexClientProvider } from "@/lib/providers/Convex"; +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 "@/components/ui/theme-provider"; -import {headers} from "next/headers"; -import {Toaster} from "@/components/ui/toaster"; - -const publicSans = Public_Sans({ - subsets: ['latin'], - display: 'swap', - variable: '--font-public-sans' -}); export const metadata: Metadata = { - title: "SiPher - Where Shadows Live", - description: "Secrecy? Not here, absolutely.", - icons: [{rel: "icon", url: "/logos/logo.png"}], + title: "SiPher - Don't trust us. We don't trust you.", + description: "SiPher is a platform made for communication. Secure? Maybe. Reliable? I don't think so. We don't trust you. We don't trust us. We don't trust anyone.", + icons: { + icon: [ + { + url: "/assets/logo/logo-white.svg", + href: "/assets/logo/logo-white.svg", + media: "(prefers-color-scheme: dark)", + type: "image/svg+xml", + sizes: "32x32", + rel: "icon" + }, + { + url: "/assets/logo/logo-dark.svg", + href: "/assets/logo/logo-dark.svg", + media: "(prefers-color-scheme: light)", + type: "image/svg+xml", + sizes: "32x32", + rel: "icon" + } + ] + } }; -export default async function RootLayout( - { - children, - }: { - children: React.ReactNode & { props?: { childProp?: { segment?: string } } }; - }) { - const initialUser = await getAuthenticatedUser(); - const isAuthPage = (await headers()).get("x-current-pathname")?.includes("auth"); - - // Auth layout - if (isAuthPage) { - return ( - - - - - {children} - - - - - - ); - } - - // Main layout +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( - - - - - -
-
-
- - {children} - -
-
-
-
-
- -
- + + + + + {children} + + + + ); -} \ No newline at end of file +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 7834722..f00042e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,318 +1,28 @@ "use client" -import {useTheme} from "next-themes"; -import Image from "next/image"; -import {Feather, Search} from "lucide-react"; -import {useEffect, useState} from "react"; -import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; -import {Separator} from "@/components/ui/separator"; -import Link from "next/link"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger -} from "@/components/ui/alert-dialog"; -import {CryptoManager} from "@/lib/crypto/keys"; -import UpdateKey from "@/lib/crypto/helpers/updateKey"; +import AppSidebar from "@/components/home"; +import { Spinner } from "@/components/ui/spinner"; +import { authClient } from "@/lib/auth/client"; +import { redirect } from "next/navigation"; -export default function SiPher() { - const {theme, systemTheme} = useTheme(); - const [mounted, setMounted] = useState(false); - - /** CryptoManager Alert */ - const [privateKeyPresent, setPrivateKeyPresent] = useState(true); - const [backupPanel, setBackupPanel] = useState(false); // I still need to do this, but... ugh. - - /** Consent Form states */ - const [showConsentForm, setShowConsentForm] = useState(false); - const [formError, setFormError] = useState(""); - - /** Input states */ - const [inputDisabled, setInputDisabled] = useState(false); - const [inputValue, setInputValue] = useState(""); - - /** Search expandability state */ - const [isSearchExpanded, setIsSearchExpanded] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - useEffect(() => { - CryptoManager.getPrivateKey().then((res) => { - if (!res) { - console.log(res) - setPrivateKeyPresent(false); - } - }) - }, []) - - /** - * @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 +export default function Home() { + const { data, error, isPending, } = authClient.useSession(); + + if (isPending) { + return
+ +
} - 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) { - const res = await req.json(); - setFormError(res.hint); - 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; + + if (error || !data) { + return redirect(`/auth${error ? `?error=${error.cause}` : ""}`); } - - const getTheme = () => { - if (!mounted) return "light"; - if (theme === "system") { - return systemTheme === "dark" ? "dark" : "light"; - } - return theme === "dark" ? "dark" : "light"; - }; - - const currentTheme = getTheme(); - - const MainPageAlerts = () => { - return ( - <> - { - if (!open) setFormError(""); - }}> - - - - Consent Form - - { - formError ? ( - {formError} - ) : null - } - - Are you sure you want to contact {inputValue}? - - - By continuing, {inputValue} will receive a notification to accept - it. If accepted, that user will appear on your sidebar, if rejected, you will never know about it. - - - - - { - setShowConsentForm(false); - setInputDisabled(false); - }} - >Cancel - { - sendRequest(inputValue); - setInputDisabled(false); - setShowConsentForm(false); - }} - >Continue - - - - - - - - - Private Key Missing - - This app could not retrieve your private key, which means it's either lost, never stored or corrupted. Want to try again or insert it from a backup? - You can also regenerate it if you do not have it backed up, but this would mean that you'll loose access to all old messages. - - - - { - setShowConsentForm(false); - setInputDisabled(false); - }} - >Cancel - { - sendRequest(inputValue).then((result) => { - if (!result) setFormError("Could not send notification for whatever reason. Sorry."); - }); - setInputDisabled(false); - }} - >Try Again - { - UpdateKey().then((result) => { - if (result.status !== 200) { - return; - } - setPrivateKeyPresent(true) - }) - }} - >Regenerate - - - - - ) - } - + return ( <> - - -
-
-
-
- SiPher -
- -
-

- 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. -

- -

- 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. -

-
- -
-
- - - setInputValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - fetchUser(inputValue).then((res) => { - console.log(res); - }) - } - }} - /> -
- - -
- - -
-

- F.A.Q -

- - - How does this works? - - - Please, click here - - - - - Why does this exists? - - 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.) - - - - Is this open-source? - - No, not yet (As of 11/12/2024) - - - -
+ +
-
+ - ); + ) } \ No newline at end of file diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx deleted file mode 100644 index 9379e13..0000000 --- a/src/app/settings/page.tsx +++ /dev/null @@ -1,287 +0,0 @@ -"use client" -import {motion} from "framer-motion"; -import {useTheme} from "next-themes"; -import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; -import {Button} from "@/components/ui/button"; -import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; -import {Input} from "@/components/ui/input"; -import {Label} from "@/components/ui/label"; -import {Switch} from "@/components/ui/switch"; -import {Separator} from "@/components/ui/separator"; -import {useUser} from "@/contexts/user"; -import {useState} from "react"; -import {AlertTriangle, Copy, Download, Eye, EyeOff, Key, Lock, Save, User} from "lucide-react"; -import {CryptoManager} from "@/lib/crypto/keys"; -import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert"; - -export default function SettingsPage() { - const {theme, setTheme} = useTheme(); - const {user} = useUser(); - const [loading, setLoading] = useState(false); - const [privateKeyVisible, setPrivateKeyVisible] = useState(false); - const [privateKeyData, setPrivateKeyData] = useState<{ text: string; file: File } | null>(null); - const [backupError, setBackupError] = useState(""); - - const containerVariants = { - hidden: {opacity: 0, y: 20}, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.6, - staggerChildren: 0.1 - } - } - }; - - const itemVariants = { - hidden: {opacity: 0, y: 20}, - visible: {opacity: 1, y: 0} - }; - - return ( - -
-
-

Settings

-

- Manage your account settings and preferences -

-
-
- - - - - - Profile - - - - Privacy - - - - - - - - Profile Information - - Update your profile information and settings - - - -
- - -
-
- -
- - -
-
-
-
-
- - - - - Privacy Settings - - Manage your privacy and security preferences - - - -
-
- -

- End-to-end encryption is always enabled -

-
- -
- -
-
-
- -

- View and download your private key for backup -

-
-
- - -
-
- - {backupError && ( - - - Error - {backupError} - - )} - - {privateKeyData && ( - - -
- Private Key -
- - -
-
-
- -
-
-                            {privateKeyData.text}
-                          
-
-
-
- )} -
- -
-
- -

- Receive message requests from other users -

-
- -
-
-
- - - - Private Key Management - - Your private key is stored securely in your browser. - Make sure to back it up to avoid losing access to your messages. - - -
-
-
- - - - -
- ); -} \ No newline at end of file diff --git a/src/components/home/index.tsx b/src/components/home/index.tsx new file mode 100644 index 0000000..e7ce899 --- /dev/null +++ b/src/components/home/index.tsx @@ -0,0 +1,72 @@ +import LogoIcon from "@/components/ui/logo-icon"; +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger +} from "@/components/ui/sidebar"; +import { Button } from "../ui/button"; +import { Separator } from "../ui/separator"; + +const SidebarItems = [ + { + id: "home", + // The icon of the home item is the same as the logo + icon: + } +] + +/** + * The main component for the homepage. This component is used to wrap all the components of any page. + * It also is the controller for everything on the app, including going to other pages, showing conversations and other. + * @param children - The children to be rendered in the sidebar inset + */ +export default function AppSidebar({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {SidebarItems.map((item) => ( + + + + ))} + + + +
+
+
+ +
+

Your Header Title

+
{/* Spacer for centering on mobile */} +
+ +
+ {children} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/main/realtime/index.tsx b/src/components/main/realtime/index.tsx deleted file mode 100644 index ae11924..0000000 --- a/src/components/main/realtime/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// hooks/useRealtime.ts -import {Dispatch, SetStateAction, useCallback, useEffect} from 'react' -import {createBrowserClient} from '@/lib/supabase/browser' -import {useUser} from '@/contexts/user' - -interface UseRealtimeProps { - setThreads: Dispatch>; -} - -export function useRealtime({setThreads}: UseRealtimeProps) { - const supabase = createBrowserClient(); - const {user, updateUser} = useUser(); - - const fetchAndUpdateThreads = useCallback(async () => { - try { - const response = await fetch("/api/user/get/threads"); - if (response.ok) { - const {threads} = await response.json(); - setThreads(threads); - } - } catch (error) { - console.error('Error fetching threads:', error); - } - }, [setThreads]) - - useEffect(() => { - if (!user) return; - - const userUpdate = supabase - .channel("request updates") - .on("postgres_changes", { - event: "*", - schema: 'public', - table: 'users', - filter: `uuid=eq.${user.uuid}`, - }, async (payload) => { - console.log(payload) - if (payload.eventType === "UPDATE") { - // This will also handle updates for the threads, but only for the user that accepted the request. - // Why? Because the function that creates the thread will also update the current user request field and remove - // the corresponding request. - if (payload.new.requests !== payload.old.requests) { - updateUser({ - ...user, - requests: payload.new.requests - }) - } - } else if (payload.eventType === "DELETE") { - console.log(`Payload from delete: \n${payload}`) - updateUser({ - ...user, - //@ts-expect-error - requests: payload.new - }) - } - }).subscribe() - - const threadUpdate = supabase - .channel("thread updates") - .on("postgres_changes", { - event: "*", - schema: 'public', - table: "thread_participants", - filter: `user_uuid=eq.${user.uuid}` - }, async (payload) => { - if (payload.new !== payload.old) { - await fetchAndUpdateThreads(); - } - }).subscribe() - - return () => { - threadUpdate.unsubscribe() - userUpdate.unsubscribe() - } - - }, [user?.uuid, fetchAndUpdateThreads, supabase, updateUser, user]); -} \ No newline at end of file diff --git a/src/components/main/sidebar/mobile.tsx b/src/components/main/sidebar/mobile.tsx deleted file mode 100644 index e69ccf7..0000000 --- a/src/components/main/sidebar/mobile.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client" -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 ( -
-
- - -
- - Logo - -
- - {/* Empty div to maintain center alignment */} -
-
-
- ) -} - -export default MobileHeader \ No newline at end of file diff --git a/src/components/main/sidebar/rightsidebar.tsx b/src/components/main/sidebar/rightsidebar.tsx deleted file mode 100644 index 75a3851..0000000 --- a/src/components/main/sidebar/rightsidebar.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import React, {useCallback, useEffect, useState} from "react"; -import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; -import {Avatar, AvatarFallback} from "@/components/ui/avatar"; -import {Separator} from "@/components/ui/separator"; -import {ScrollArea} from "@/components/ui/scroll-area"; -import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; -import {Check, LogOut, Mail, MailPlus, X} from "lucide-react"; -import {Button} from "@/components/ui/button"; -import {GearIcon} from "@radix-ui/react-icons"; -import Link from "next/link"; -import {useRealtime} from "@/components/main/realtime"; -import {useUser} from "@/contexts/user"; -import {usePathname} from "next/navigation"; -import {useSharedState} from "@/hooks/shared-states"; - -interface RightSidebarContentProps { - isDarkMode: boolean; -} - -export default function RightSidebarContent( - { - isDarkMode, - }: RightSidebarContentProps) { - - const [copied, setCopied] = useState(false); - - const {threads, setThreads} = useSharedState(); - useRealtime({setThreads}); - - const {user} = useUser(); - const {username, suuid, requests = []} = user; - const pathname = usePathname(); - - const pendingRequests = requests?.length ?? 0; - - const fetchThreads = useCallback(async () => { - try { - const req = await fetch("/api/user/get/threads") - if (req.ok) { - const {threads} = await req.json() as { threads: SiPher.Thread[] | [] } - setThreads(threads) - } else { - setThreads([]) - } - } catch (error) { - console.log(error); - setThreads([]) - } - }, [setThreads]); - - useEffect(() => { - fetchThreads(); - }, [fetchThreads]); - - const handleAccept = async (request: string) => { - try { - const response = await fetch("/api/user/create/thread", { - method: "POST", - body: JSON.stringify({participant: request}), - }); - if (response.ok) { - fetchThreads(); - } - } catch (error) { - console.error('Error accepting request:', error); - } - } - - return ( - <> -
- - - - - Copied SUUID to clipboard! - - - -
{ - setCopied(true) - navigator.clipboard.writeText(suuid) - }} - className={`flex items-center p-3 m-2 ${isDarkMode ? "hover:bg-accent/90" : "hover:bg-secondary/20"} rounded-full transition-colors duration-200 cursor-pointer select-none`}> - - {username.charAt(0)} - -
-

{username}

-

${suuid}

-
-
- - - - -
- - - -
-
- - ) -} \ No newline at end of file diff --git a/src/components/main/sidebar/sidebar.tsx b/src/components/main/sidebar/sidebar.tsx deleted file mode 100644 index 2843719..0000000 --- a/src/components/main/sidebar/sidebar.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client" -import React from "react" -import Link from "next/link" -import {AnimatePresence, motion} from "framer-motion" -import {X} from "lucide-react" -import {Button} from "@/components/ui/button" -import Image from "next/image"; -import MobileHeader from "@/components/main/sidebar/mobile"; -import {useRefs, useUIState} from "@/hooks/shared-states"; -import {useTheme} from "next-themes"; -import RightSidebarContent from "@/components/main/sidebar/rightsidebar"; - -type SidebarProps = { - children?: React.ReactNode -} - -function Sidebar( - { - children - }: SidebarProps -) { - const {theme, systemTheme} = useTheme(); - - const {isDrawerOpen, setIsDrawerOpen} = useUIState(); - const {drawerRef} = useRefs(); - - const isDarkMode = theme === "system" - ? systemTheme === "dark" - : theme === "dark" - - return ( - <> - - - - {isDrawerOpen && ( - -
- - -
-
- )} -
-
{ - children ?? null - } -
- - ) -} - -export default Sidebar \ No newline at end of file diff --git a/src/components/mode-toggle.tsx b/src/components/mode-toggle.tsx new file mode 100644 index 0000000..a4776c9 --- /dev/null +++ b/src/components/mode-toggle.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" +import { Moon, Sun } from "lucide-react" +import { useTheme } from "next-themes" + +import { Button } from "@/components/ui/button" + +export function ModeToggle() { + const { theme, setTheme } = useTheme() + + return ( + + ) +} + diff --git a/src/components/socket-test.tsx b/src/components/socket-test.tsx new file mode 100644 index 0000000..b2c20ec --- /dev/null +++ b/src/components/socket-test.tsx @@ -0,0 +1,93 @@ +"use client" + +import { useEffect, useState } from "react" +import { io, Socket } from "socket.io-client" +import { Button } from "./ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" +import { Input } from "./ui/input" + +export default function SocketTest() { + const [socket, setSocket] = useState(null) + const [isConnected, setIsConnected] = useState(false) + const [messages, setMessages] = useState([]) + const [inputMessage, setInputMessage] = useState("") + + useEffect(() => { + // Initialize Socket.IO client + const socketInstance = io() + + socketInstance.on("connect", () => { + console.log("Connected to Socket.IO:", socketInstance.id) + setIsConnected(true) + setMessages(prev => [...prev, `✅ Connected: ${socketInstance.id}`]) + }) + + socketInstance.on("disconnect", (reason) => { + console.log("Disconnected:", reason) + setIsConnected(false) + setMessages(prev => [...prev, `❌ Disconnected: ${reason}`]) + }) + + socketInstance.on("message", (data) => { + console.log("Message received:", data) + setMessages(prev => [...prev, `📩 Received: ${data}`]) + }) + + setSocket(socketInstance) + + return () => { + socketInstance.disconnect() + } + }, []) + + const sendMessage = () => { + if (socket && inputMessage.trim()) { + socket.emit("message", inputMessage) + setMessages(prev => [...prev, `📤 Sent: ${inputMessage}`]) + setInputMessage("") + } + } + + return ( + + + Socket.IO Test Client + + Status: {isConnected ? ( + 🟢 Connected + ) : ( + 🔴 Disconnected + )} + + + +
+ setInputMessage(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && sendMessage()} + disabled={!isConnected} + /> + +
+ +
+
+ {messages.length === 0 ? ( +

No messages yet...

+ ) : ( + messages.map((msg, idx) => ( +

{msg}

+ )) + )} +
+
+
+
+ ) +} + diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..3f9d035 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,12 @@ +"use client" + +import { ThemeProvider as NextThemesProvider } from "next-themes" +import * as React from "react" + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children} +} + diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx deleted file mode 100644 index b432017..0000000 --- a/src/components/ui/accordion.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"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, - React.ComponentPropsWithoutRef ->(({className, ...props}, ref) => ( - -)) -AccordionItem.displayName = "AccordionItem" - -const AccordionTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({className, children, ...props}, ref) => ( - - svg]:rotate-180", - className - )} - {...props} - > - {children} - - - -)) -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName - -const AccordionContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({className, children, ...props}, ref) => ( - -
{children}
-
-)) -AccordionContent.displayName = AccordionPrimitive.Content.displayName - -export {Accordion, AccordionItem, AccordionTrigger, AccordionContent} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx deleted file mode 100644 index 461c340..0000000 --- a/src/components/ui/alert-dialog.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"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, - React.ComponentPropsWithoutRef ->(({className, ...props}, ref) => ( - -)) -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName - -const AlertDialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({className, ...props}, ref) => ( - - - - -)) -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName - -const AlertDialogHeader = ({ - className, - ...props - }: React.HTMLAttributes) => ( -
-) -AlertDialogHeader.displayName = "AlertDialogHeader" - -const AlertDialogFooter = ({ - className, - ...props - }: React.HTMLAttributes) => ( -
-) -AlertDialogFooter.displayName = "AlertDialogFooter" - -const AlertDialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({className, ...props}, ref) => ( - -)) -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName - -const AlertDialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({className, ...props}, ref) => ( - -)) -AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName - -const AlertDialogAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({className, ...props}, ref) => ( - -)) -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName - -const AlertDialogCancel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({className, ...props}, ref) => ( - -)) -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName - -export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, -} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx deleted file mode 100644 index c382e3a..0000000 --- a/src/components/ui/alert.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from "react" -import {cva, type VariantProps} from "class-variance-authority" - -import {cn} from "@/lib/utils" - -const alertVariants = cva( - "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", - { - variants: { - variant: { - default: "bg-background text-foreground", - destructive: - "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -const Alert = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes & VariantProps ->(({className, variant, ...props}, ref) => ( -
-)) -Alert.displayName = "Alert" - -const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({className, ...props}, ref) => ( -
-)) -AlertTitle.displayName = "AlertTitle" - -const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({className, ...props}, ref) => ( -
-)) -AlertDescription.displayName = "AlertDescription" - -export {Alert, AlertTitle, AlertDescription} diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx deleted file mode 100644 index 027d499..0000000 --- a/src/components/ui/avatar.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"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, - React.ComponentPropsWithoutRef ->(({className, ...props}, ref) => ( - -)) -Avatar.displayName = AvatarPrimitive.Root.displayName - -const AvatarImage = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({className, ...props}, ref) => ( - -)) -AvatarImage.displayName = AvatarPrimitive.Image.displayName - -const AvatarFallback = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({className, ...props}, ref) => ( - -)) -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName - -export {Avatar, AvatarImage, AvatarFallback} diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx new file mode 100644 index 0000000..8600af0 --- /dev/null +++ b/src/components/ui/button-group.tsx @@ -0,0 +1,83 @@ +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Separator } from "@/components/ui/separator" + +const buttonGroupVariants = cva( + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + } +) + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean +}) { + const Comp = asChild ? Slot : "div" + + return ( + + ) +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index e69ddcc..6810932 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,30 +1,33 @@ +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" 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" +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", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { - default: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", + default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 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", + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + "icon-xl": "size-12", }, }, defaultVariants: { @@ -34,24 +37,25 @@ const buttonVariants = cva( } ) -export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) } -const Button = React.forwardRef( - ({className, variant, size, asChild = false, ...props}, ref) => { - const Comp = asChild ? Slot : "button" - return ( - - ) - } -) -Button.displayName = "Button" - -export {Button, buttonVariants} +export { Button, buttonVariants } diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..6f304b5 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,216 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "flex gap-4 flex-col md:flex-row relative", + defaultClassNames.months + ), + month: cn("flex flex-col w-full gap-4", defaultClassNames.month), + nav: cn( + "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", + defaultClassNames.button_next + ), + month_caption: cn( + "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute bg-popover inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", + defaultClassNames.weekday + ), + week: cn("flex w-full mt-2", defaultClassNames.week), + week_number_header: cn( + "select-none w-(--cell-size)", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] select-none text-muted-foreground", + defaultClassNames.week_number + ), + day: cn( + "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" + : "[&:first-child[data-selected=true]_button]:rounded-l-md", + defaultClassNames.day + ), + range_start: cn( + "rounded-l-md bg-accent", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( +