From 229be7a5e89fdf3c1a14fd909f8181ec5f77ef7d Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Mon, 9 Mar 2026 07:56:14 -0700 Subject: [PATCH 1/2] feat: added settings dialog and user settings page --- ROADMAP.md | 6 +- apps/api/src/routes/v1/uploads/handlers.ts | 34 +- apps/api/src/routes/v1/uploads/index.ts | 4 +- apps/api/src/routes/v1/uploads/routes.ts | 34 +- apps/api/src/routes/v1/uploads/schema.ts | 24 + .../components/chat/message-action-bar.tsx | 16 +- .../components/chat/message-edit-input.tsx | 9 + apps/web/src/components/chat/message-item.tsx | 4 +- .../settings/my-account-settings.tsx | 308 ++++++++ .../components/settings/settings-dialog.tsx | 139 ++++ .../sidebar/channel-panel/user-bar.tsx | 10 +- apps/web/src/context/settings-context.tsx | 31 + apps/web/src/routes/_authenticated.tsx | 17 +- packages/auth/src/lib/auth.ts | 10 + packages/db/src/schemas/users.ts | 11 +- packages/ui/src/components/breadcrumb.tsx | 106 +++ packages/ui/src/components/sidebar.tsx | 725 ++++++++++++++++++ packages/ui/src/hooks/use-mobile.ts | 19 + packages/ui/src/styles/globals.css | 120 ++- 19 files changed, 1538 insertions(+), 89 deletions(-) create mode 100644 apps/web/src/components/settings/my-account-settings.tsx create mode 100644 apps/web/src/components/settings/settings-dialog.tsx create mode 100644 apps/web/src/context/settings-context.tsx create mode 100644 packages/ui/src/components/breadcrumb.tsx create mode 100644 packages/ui/src/components/sidebar.tsx create mode 100644 packages/ui/src/hooks/use-mobile.ts diff --git a/ROADMAP.md b/ROADMAP.md index 64da683..c056629 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,10 +23,10 @@ - [x] File/image attachment uploads (R2) - [x] Message deletion -- [ ] Message editing UI -- [ ] User profiles (bio, custom status, avatar upload) +- [x] Message editing UI +- [x] User profiles (bio, custom status, avatar upload) - [ ] Channel edit/delete -- [ ] Settings pages +- [x] Settings pages ## Phase 2 — Permissions & Moderation diff --git a/apps/api/src/routes/v1/uploads/handlers.ts b/apps/api/src/routes/v1/uploads/handlers.ts index a39ab34..3d79650 100644 --- a/apps/api/src/routes/v1/uploads/handlers.ts +++ b/apps/api/src/routes/v1/uploads/handlers.ts @@ -7,8 +7,8 @@ import { and, eq } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" import { s3Client } from "@/lib/s3" import type { AppRouteHandler } from "@/lib/types/app-types" -import type { PresignRoute } from "./routes" -import { PRESIGNED_URL_EXPIRY_SECONDS } from "./schema" +import type { AvatarPresignRoute, PresignRoute } from "./routes" +import { MAX_AVATAR_SIZE, PRESIGNED_URL_EXPIRY_SECONDS } from "./schema" const DM_CHANNEL_TYPES = ["dm", "group_dm"] as const @@ -107,3 +107,33 @@ export const presign: AppRouteHandler = async (c) => { return c.json({ uploadUrl, fileUrl, key }, HttpStatusCodes.OK) } + +export const avatarPresign: AppRouteHandler = async (c) => { + const user = c.var.user + const { filename, contentType, size } = c.req.valid("json") + + if (size > MAX_AVATAR_SIZE) { + return c.json( + { success: false, message: "File too large" }, + HttpStatusCodes.REQUEST_TOO_LONG + ) + } + + const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "_") + const key = `avatars/${user.id}/${crypto.randomUUID()}/${sanitizedFilename}` + + const command = new PutObjectCommand({ + Bucket: env.S3_BUCKET_NAME, + Key: key, + ContentType: contentType, + ContentLength: size, + }) + + const uploadUrl = await getSignedUrl(s3Client, command, { + expiresIn: PRESIGNED_URL_EXPIRY_SECONDS, + }) + + const fileUrl = `${env.S3_PUBLIC_URL.replace(/\/$/, "")}/${key}` + + return c.json({ uploadUrl, fileUrl }, HttpStatusCodes.OK) +} diff --git a/apps/api/src/routes/v1/uploads/index.ts b/apps/api/src/routes/v1/uploads/index.ts index dc9628b..692df02 100644 --- a/apps/api/src/routes/v1/uploads/index.ts +++ b/apps/api/src/routes/v1/uploads/index.ts @@ -2,6 +2,8 @@ import { createRouter } from "@/lib/helpers/app/create-app" import * as handlers from "./handlers" import * as routes from "./routes" -const uploadsRouter = createRouter().openapi(routes.presign, handlers.presign) +const uploadsRouter = createRouter() + .openapi(routes.presign, handlers.presign) + .openapi(routes.avatarPresign, handlers.avatarPresign) export default uploadsRouter diff --git a/apps/api/src/routes/v1/uploads/routes.ts b/apps/api/src/routes/v1/uploads/routes.ts index a16994b..8a943ce 100644 --- a/apps/api/src/routes/v1/uploads/routes.ts +++ b/apps/api/src/routes/v1/uploads/routes.ts @@ -8,7 +8,12 @@ import { unauthorizedSchema, } from "@/lib/helpers/openapi/schemas" import { sessionAuthMiddleware } from "@/middleware/session-auth" -import { presignRequestSchema, presignResponseSchema } from "./schema" +import { + avatarPresignRequestSchema, + avatarPresignResponseSchema, + presignRequestSchema, + presignResponseSchema, +} from "./schema" export const presign = createRoute({ path: "/uploads/presign", @@ -37,3 +42,30 @@ export const presign = createRoute({ }) export type PresignRoute = typeof presign + +export const avatarPresign = createRoute({ + path: "/uploads/avatar/presign", + method: "post", + summary: "Request a presigned URL for avatar upload", + description: + "Returns a presigned URL for uploading a user avatar to S3-compatible storage.", + tags: ["Uploads"], + middleware: [sessionAuthMiddleware] as const, + request: { + body: jsonContent({ + schema: avatarPresignRequestSchema, + description: "Avatar file metadata", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: avatarPresignResponseSchema, + description: "Presigned URL for avatar upload", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.REQUEST_TOO_LONG]: payloadTooLargeSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type AvatarPresignRoute = typeof avatarPresign diff --git a/apps/api/src/routes/v1/uploads/schema.ts b/apps/api/src/routes/v1/uploads/schema.ts index 64efbef..22b9b2b 100644 --- a/apps/api/src/routes/v1/uploads/schema.ts +++ b/apps/api/src/routes/v1/uploads/schema.ts @@ -23,3 +23,27 @@ export const presignResponseSchema = z.object({ fileUrl: z.string().url(), key: z.string(), }) + +const AVATAR_MIME_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +] as const + +export const MAX_AVATAR_SIZE = 2 * 1024 * 1024 // 2 MB + +export const avatarPresignRequestSchema = z.object({ + filename: z.string().min(1).max(256), + contentType: z + .string() + .refine((ct) => (AVATAR_MIME_TYPES as readonly string[]).includes(ct), { + message: "Unsupported image type", + }), + size: z.number().int().min(1).max(MAX_AVATAR_SIZE), +}) + +export const avatarPresignResponseSchema = z.object({ + uploadUrl: z.string().url(), + fileUrl: z.string().url(), +}) diff --git a/apps/web/src/components/chat/message-action-bar.tsx b/apps/web/src/components/chat/message-action-bar.tsx index 52e3a19..48c73fc 100644 --- a/apps/web/src/components/chat/message-action-bar.tsx +++ b/apps/web/src/components/chat/message-action-bar.tsx @@ -12,7 +12,7 @@ import { TooltipTrigger, } from "@repo/ui/components/tooltip" import { MoreHorizontal, Reply } from "lucide-react" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { EmojiReactionPicker } from "./emoji-reaction-picker" interface MessageActionBarProps { @@ -36,11 +36,21 @@ export function MessageActionBar({ }: MessageActionBarProps) { const [isEmojiOpen, setIsEmojiOpen] = useState(false) const [isMoreOpen, setIsMoreOpen] = useState(false) + const [suppressTooltip, setSuppressTooltip] = useState(false) useEffect(() => { onOverlayOpenChange?.(isEmojiOpen || isMoreOpen) }, [isEmojiOpen, isMoreOpen, onOverlayOpenChange]) + const handleMoreOpenChange = useCallback((open: boolean) => { + setIsMoreOpen(open) + if (!open) { + // Suppress tooltip briefly after dropdown closes to prevent it from sticking + setSuppressTooltip(true) + setTimeout(() => setSuppressTooltip(false), 150) + } + }, []) + return (
@@ -61,8 +71,8 @@ export function MessageActionBar({ Reply - - + + +
+

{user.name}

+

+ {user.username ? `@${user.username}` : user.email} +

+
+ + + + or drag & drop + +
+
+
+ + + +
+
+ + setName(e.target.value)} + maxLength={50} + /> +
+ +
+ + +
+ +
+
+ + + {status.length}/{MAX_STATUS_LENGTH} + +
+ setStatus(e.target.value)} + placeholder="What are you up to?" + maxLength={MAX_STATUS_LENGTH} + /> +
+ +
+
+ + + {bio.length}/{MAX_BIO_LENGTH} + +
+