diff --git a/.gitignore b/.gitignore index 5fc16a0..ccd1e13 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ build/ out/ *.local .agent/ +apps/backend/uploads # Prisma generated client apps/backend/generated/* diff --git a/apps/backend/src/controllers/auth.controller.ts b/apps/backend/src/controllers/auth.controller.ts index 9e83ae8..e330c9d 100644 --- a/apps/backend/src/controllers/auth.controller.ts +++ b/apps/backend/src/controllers/auth.controller.ts @@ -319,6 +319,7 @@ export class AuthController { email: true, emailVerified: true, avatar: true, + bio: true, createdAt: true, }, }) diff --git a/apps/backend/src/routes/user.routes.ts b/apps/backend/src/routes/user.routes.ts index c1c2a6c..8c51f90 100644 --- a/apps/backend/src/routes/user.routes.ts +++ b/apps/backend/src/routes/user.routes.ts @@ -41,12 +41,17 @@ router.get("/search", authMiddleware, async (req, res) => { }) -router.get("/:username", async (req, res) => { +router.get("/:username", authMiddleware, async (req, res) => { const parsed = usernameParamsSchema.safeParse(req.params) if (!parsed.success) { return respondWithZodError(res, parsed.error) } + const currentUserId = req.user?.id + if (!currentUserId) { + return res.status(401).json({ message: "Not authenticated" }) + } + const { username } = parsed.data; const user = await prisma.user.findUnique({ @@ -56,13 +61,97 @@ router.get("/:username", async (req, res) => { username: true, name: true, avatar: true, - publicNumericId: true + bio: true, + publicNumericId: true, + isOnline: true, + lastLogin: true, + createdAt: true, } }); if (!user) return res.status(404).json({ message: "User not found" }); - res.json({ user }); + const targetUserId = user.id + + const [friendship, pendingRequest, currentFriends, targetFriends, currentZones, targetZones] = + await Promise.all([ + prisma.friend.findFirst({ + where: { + OR: [ + { user1Id: currentUserId, user2Id: targetUserId }, + { user1Id: targetUserId, user2Id: currentUserId }, + ], + }, + select: { id: true }, + }), + prisma.friendRequest.findFirst({ + where: { + status: "PENDING", + OR: [ + { senderId: currentUserId, receiverId: targetUserId }, + { senderId: targetUserId, receiverId: currentUserId }, + ], + }, + select: { id: true }, + }), + prisma.friend.findMany({ + where: { OR: [{ user1Id: currentUserId }, { user2Id: currentUserId }] }, + select: { user1Id: true, user2Id: true }, + }), + prisma.friend.findMany({ + where: { OR: [{ user1Id: targetUserId }, { user2Id: targetUserId }] }, + select: { user1Id: true, user2Id: true }, + }), + prisma.chatParticipant.findMany({ + where: { userId: currentUserId, chat: { type: "ZONE" } }, + select: { chat: { select: { id: true, publicId: true, name: true } } }, + }), + prisma.chatParticipant.findMany({ + where: { userId: targetUserId, chat: { type: "ZONE" } }, + select: { chat: { select: { id: true, publicId: true, name: true } } }, + }), + ]) + + const friendStatus = friendship ? "accepted" : pendingRequest ? "pending" : "none" + + const currentFriendIds = new Set( + currentFriends.map((f) => (f.user1Id === currentUserId ? f.user2Id : f.user1Id)), + ) + const targetFriendIds = new Set( + targetFriends.map((f) => (f.user1Id === targetUserId ? f.user2Id : f.user1Id)), + ) + const mutualFriendIds = [...currentFriendIds].filter((id) => targetFriendIds.has(id)) + + const mutualFriends = mutualFriendIds.length + ? await prisma.user.findMany({ + where: { id: { in: mutualFriendIds } }, + select: { id: true, name: true, username: true }, + }) + : [] + + const currentZoneMap = new Map(currentZones.map((entry) => [entry.chat.id, entry.chat])) + const mutualZones = targetZones + .map((entry) => currentZoneMap.get(entry.chat.id)) + .filter((zone): zone is { id: number; publicId: string; name: string } => Boolean(zone)) + .map((zone) => ({ id: zone.publicId, name: zone.name })) + + res.json({ + user: { + id: String(user.id), + name: user.name ?? user.username, + avatar: user.avatar, + bio: user.bio, + friendStatus, + mutualFriends: mutualFriends.map((friend) => ({ + id: String(friend.id), + name: friend.name ?? friend.username, + })), + mutualZones, + isOnline: user.isOnline, + lastLogin: user.lastLogin, + createdAt: user.createdAt, + }, + }); }); router.patch( diff --git a/apps/frontend/package.json b/apps/frontend/package.json index cbbdc43..2280beb 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -9,6 +9,8 @@ "lint": "eslint ." }, "dependencies": { + "@emoji-mart/data": "^1.1.0", + "@emoji-mart/react": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/apps/frontend/src/app/auth/page.tsx b/apps/frontend/src/app/auth/page.tsx index 7562935..8bb8031 100644 --- a/apps/frontend/src/app/auth/page.tsx +++ b/apps/frontend/src/app/auth/page.tsx @@ -6,6 +6,7 @@ import { Eye, EyeOff, Mail, Lock, User, CheckCircle2, AlertCircle, Loader2, Spar import { useRouter } from "next/navigation"; import { api } from '@openchat/lib'; import { Checkbox, Label } from "@openchat/ui" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger, DialogFooter } from "@openchat/ui" import { signupSchema } from "@openchat/lib/validations/auth"; import { useGoogleLogin } from "@react-oauth/google"; import Link from 'next/link'; diff --git a/apps/frontend/src/app/dashboard/layout.tsx b/apps/frontend/src/app/dashboard/layout.tsx index 9aaa9d1..dd0d9b8 100644 --- a/apps/frontend/src/app/dashboard/layout.tsx +++ b/apps/frontend/src/app/dashboard/layout.tsx @@ -13,7 +13,7 @@ export default async function DashboardLayout({ } return ( -
+
{children}
) diff --git a/apps/frontend/src/app/layout.tsx b/apps/frontend/src/app/layout.tsx index bf8c825..d0e7085 100644 --- a/apps/frontend/src/app/layout.tsx +++ b/apps/frontend/src/app/layout.tsx @@ -22,7 +22,7 @@ export default async function RootLayout({ return ( - + < ClientProviders initialUser={user} > {children} diff --git a/apps/frontend/src/app/settings/_components/SettingsSidebar.tsx b/apps/frontend/src/app/settings/_components/SettingsSidebar.tsx index 65a9646..d239fe2 100644 --- a/apps/frontend/src/app/settings/_components/SettingsSidebar.tsx +++ b/apps/frontend/src/app/settings/_components/SettingsSidebar.tsx @@ -2,12 +2,18 @@ import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' +import { useState } from 'react' import { api, cn } from '@openchat/lib' -import { User, Shield, Lock, Bell, Trash2, LogOut, ArrowLeft, Keyboard } from 'lucide-react' +import { User, Shield, Lock, Bell, Trash2, LogOut, ArrowLeft, Keyboard, Menu, X } from 'lucide-react' import { useUserStore } from '@/app/stores/user-store' import { useChatsStore } from '@/app/stores/chat-store' import { useFriendsStore } from '@/app/stores/friends-store' import { Button } from 'packages/ui' +import { + Sheet, + SheetContent, + SheetTrigger, +} from 'packages/ui' const tabs = [ { name: 'Profile', href: '/settings/profile', icon: User }, @@ -18,7 +24,7 @@ const tabs = [ { name: 'Account', href: '/settings/account', icon: Trash2 }, ] -export default function SettingsSidebar() { +function SettingsNav({ onLinkClick }: { onLinkClick?: () => void }) { const pathname = usePathname() const router = useRouter() @@ -36,23 +42,21 @@ export default function SettingsSidebar() { useChatsStore.getState().reset() useFriendsStore.getState().reset() - // Use window.location.href to ensure a clean state and clear server-side caches window.location.href = '/auth' } return ( -
+
- {/* Tabs */}
{tabs.map((tab) => { const Icon = tab.icon @@ -62,11 +66,12 @@ export default function SettingsSidebar() { @@ -75,20 +80,40 @@ export default function SettingsSidebar() { ) })}
- - {/* Divider */} -
- - {/* Logout */} +
-
) } + +export default function SettingsSidebar() { + const [open, setOpen] = useState(false) + + return ( + <> +
+ +
+ +
+ + + + + + setOpen(false)} /> + + +
+ + ) +} \ No newline at end of file diff --git a/apps/frontend/src/app/settings/account/page.tsx b/apps/frontend/src/app/settings/account/page.tsx index 6c4a443..1486955 100644 --- a/apps/frontend/src/app/settings/account/page.tsx +++ b/apps/frontend/src/app/settings/account/page.tsx @@ -46,7 +46,7 @@ export default function AccountPage() {
{/* Sessions */} - + Sessions @@ -75,7 +75,7 @@ export default function AccountPage() { {/* Data */} - + Your Data diff --git a/apps/frontend/src/app/settings/keyboard/page.tsx b/apps/frontend/src/app/settings/keyboard/page.tsx index 6ad5bbf..396d224 100644 --- a/apps/frontend/src/app/settings/keyboard/page.tsx +++ b/apps/frontend/src/app/settings/keyboard/page.tsx @@ -102,7 +102,7 @@ function ShortcutRecorder({ action, value, onChange, otherShortcut }: ShortcutRe return (
- {label} + {label}
{recording ? (
@@ -113,11 +113,11 @@ function ShortcutRecorder({ action, value, onChange, otherShortcut }: ShortcutRe
) : value ? (
- - {value} + + {value} @@ -170,7 +170,7 @@ export default function KeyboardShortcutsPage() {

- + @@ -197,11 +197,11 @@ export default function KeyboardShortcutsPage() { - + Tips - +

• Shortcuts work only when the app window is focused

• Shortcuts are disabled when typing in input fields

• Press Escape to cancel recording

diff --git a/apps/frontend/src/app/settings/layout.tsx b/apps/frontend/src/app/settings/layout.tsx index e33f08f..05ec37b 100644 --- a/apps/frontend/src/app/settings/layout.tsx +++ b/apps/frontend/src/app/settings/layout.tsx @@ -18,11 +18,11 @@ export default async function SettingsLayout({ return ( -
+
-
-
+
+
{children}
diff --git a/apps/frontend/src/app/settings/notifications/page.tsx b/apps/frontend/src/app/settings/notifications/page.tsx index 58be871..dbc7601 100644 --- a/apps/frontend/src/app/settings/notifications/page.tsx +++ b/apps/frontend/src/app/settings/notifications/page.tsx @@ -51,7 +51,7 @@ export default function NotificationsPage() {
{/* In-App */} - + In-App Notifications @@ -80,7 +80,7 @@ export default function NotificationsPage() { {/* Email */} - + Email Notifications @@ -128,7 +128,7 @@ export default function NotificationsPage() { {/* Sound */} - + Sound diff --git a/apps/frontend/src/app/settings/privacy/page.tsx b/apps/frontend/src/app/settings/privacy/page.tsx index bd83e8f..7faa98a 100644 --- a/apps/frontend/src/app/settings/privacy/page.tsx +++ b/apps/frontend/src/app/settings/privacy/page.tsx @@ -50,7 +50,7 @@ export default function PrivacyPage() {
{/* Account Visibility */} - + Account Visibility @@ -79,7 +79,7 @@ export default function PrivacyPage() { {/* Activity Status */} - + Activity Status @@ -108,7 +108,7 @@ export default function PrivacyPage() { {/* Messaging */} - + Messaging diff --git a/apps/frontend/src/app/settings/profile/page.tsx b/apps/frontend/src/app/settings/profile/page.tsx index b4492ab..fd56a01 100644 --- a/apps/frontend/src/app/settings/profile/page.tsx +++ b/apps/frontend/src/app/settings/profile/page.tsx @@ -72,7 +72,7 @@ export default function ProfilePage() {
- +
@@ -139,6 +139,13 @@ export default function ProfilePage() { } const handleAvatarChange = async (file: File) => { + const MAX_SIZE = 2 * 1024 * 1024 // 2MB + + if (file.size > MAX_SIZE) { + toast.error('File too large. Maximum size is 2MB') + return + } + const preview = URL.createObjectURL(file) updateUser({ avatar: preview }) // optimistic preview @@ -204,7 +211,7 @@ export default function ProfilePage() {
{/* Avatar Card */} - + Profile Picture @@ -269,7 +276,7 @@ export default function ProfilePage() { {/* Personal Info Card */} - + Personal Information @@ -305,7 +312,7 @@ export default function ProfilePage() {