From b5a9d49de3660aa9b4732a62fb42646bde3f30fe Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Wed, 8 Apr 2026 15:57:27 -0700 Subject: [PATCH 1/4] feat: added upload endpoint for guild icons and rendered guild icons in the guild sidebar when present - fixed UI bug with horizontal scrollable channel panel --- apps/api/src/routes/v1/guilds/handlers.ts | 64 ++++ apps/api/src/routes/v1/guilds/index.ts | 1 + apps/api/src/routes/v1/guilds/routes.ts | 30 ++ apps/api/src/routes/v1/guilds/schema.ts | 17 + apps/api/src/routes/v1/uploads/handlers.ts | 44 ++- apps/api/src/routes/v1/uploads/index.ts | 1 + apps/api/src/routes/v1/uploads/routes.ts | 29 ++ apps/api/src/routes/v1/uploads/schema.ts | 26 ++ apps/desktop/src-tauri/tauri.conf.json | 3 +- .../guild/guild-settings-dialog.tsx | 298 ++++++++++++++++++ .../sidebar/channel-panel/channel-panel.tsx | 11 +- .../sidebar/channel-panel/guild-header.tsx | 68 ++-- .../sidebar/channel-panel/search-bar.tsx | 2 +- .../sidebar/channel-panel/user-bar.tsx | 2 +- .../sidebar/guild-bar/guild-bar.tsx | 62 ++-- apps/web/src/components/sidebar/index.tsx | 7 +- apps/web/src/routes/_authenticated.tsx | 2 +- 17 files changed, 610 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/components/guild/guild-settings-dialog.tsx diff --git a/apps/api/src/routes/v1/guilds/handlers.ts b/apps/api/src/routes/v1/guilds/handlers.ts index 319e821..f3bc579 100644 --- a/apps/api/src/routes/v1/guilds/handlers.ts +++ b/apps/api/src/routes/v1/guilds/handlers.ts @@ -21,6 +21,7 @@ import type { SearchMessagesRoute, TimeoutGuildMemberRoute, UpdateGuildMemberRoleRoute, + UpdateGuildRoute, } from "@/routes/v1/guilds/routes" const PRESENCE_MEMBERSHIP_CHUNK_SIZE = 250 @@ -470,6 +471,69 @@ export const clearGuildMemberTimeout: AppRouteHandler< ) } +// ── Guild Settings ───────────────────────────────────── + +export const updateGuild: AppRouteHandler = async (c) => { + const guild = c.var.guild + const actor = c.var.member + + assertGuildPermission(actor, guild, { + organization: ["update"], + }) + + const body = c.req.valid("json") + + const updates: Record = {} + if (body.name !== undefined) updates.name = body.name + if (body.logo !== undefined) updates.logo = body.logo + + if (Object.keys(updates).length === 0) { + return c.json( + { + success: true as const, + guild: { + id: guild.id, + name: guild.name, + slug: guild.slug, + logo: guild.logo, + }, + }, + HttpStatusCodes.OK + ) + } + + const [updated] = await db + .update(schema.guild) + .set(updates) + .where(eq(schema.guild.id, guild.id)) + .returning({ + id: schema.guild.id, + name: schema.guild.name, + slug: schema.guild.slug, + logo: schema.guild.logo, + }) + + if (!updated) { + return c.json( + { success: false, message: "Guild not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + return c.json( + { + success: true as const, + guild: { + id: updated.id, + name: updated.name, + slug: updated.slug, + logo: updated.logo, + }, + }, + HttpStatusCodes.OK + ) +} + // ── Search ────────────────────────────────────────────── export const searchMessages: AppRouteHandler = async ( diff --git a/apps/api/src/routes/v1/guilds/index.ts b/apps/api/src/routes/v1/guilds/index.ts index 50442dd..d94aa01 100644 --- a/apps/api/src/routes/v1/guilds/index.ts +++ b/apps/api/src/routes/v1/guilds/index.ts @@ -5,6 +5,7 @@ import * as routes from "@/routes/v1/guilds/routes" const guildsRouter = createRouter() .openapi(routes.listGuildMembers, handlers.listGuildMembers) .openapi(routes.searchMessages, handlers.searchMessages) + .openapi(routes.updateGuild, handlers.updateGuild) .openapi(routes.updateGuildMemberRole, handlers.updateGuildMemberRole) .openapi(routes.kickGuildMember, handlers.kickGuildMember) .openapi(routes.banGuildMember, handlers.banGuildMember) diff --git a/apps/api/src/routes/v1/guilds/routes.ts b/apps/api/src/routes/v1/guilds/routes.ts index d4358d8..f2874e5 100644 --- a/apps/api/src/routes/v1/guilds/routes.ts +++ b/apps/api/src/routes/v1/guilds/routes.ts @@ -21,6 +21,8 @@ import { timeoutGuildMemberResponseSchema, updateGuildMemberRoleRequestSchema, updateGuildMemberRoleResponseSchema, + updateGuildRequestSchema, + updateGuildResponseSchema, } from "./schema" export const listGuildMembers = createRoute({ @@ -210,3 +212,31 @@ export const searchMessages = createRoute({ }) export type SearchMessagesRoute = typeof searchMessages + +export const updateGuild = createRoute({ + path: "/guilds/{guildSlug}", + method: "patch", + summary: "Update guild settings", + description: "Updates guild name and/or logo. Requires admin or owner role.", + tags: ["Guilds"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildSlugParamsSchema, + body: jsonContent({ + schema: updateGuildRequestSchema, + description: "Guild fields to update", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: updateGuildResponseSchema, + description: "Updated guild", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type UpdateGuildRoute = typeof updateGuild diff --git a/apps/api/src/routes/v1/guilds/schema.ts b/apps/api/src/routes/v1/guilds/schema.ts index 5401a32..c92d068 100644 --- a/apps/api/src/routes/v1/guilds/schema.ts +++ b/apps/api/src/routes/v1/guilds/schema.ts @@ -106,6 +106,23 @@ export const timeoutGuildMemberResponseSchema = z.object({ member: guildMemberPresenceSchema, }) +// ── Guild Settings ───────────────────────────────────── + +export const updateGuildRequestSchema = z.object({ + name: z.string().trim().min(1).max(100).optional(), + logo: z.string().url().nullable().optional(), +}) + +export const updateGuildResponseSchema = z.object({ + success: z.literal(true), + guild: z.object({ + id: z.string().uuid(), + name: z.string(), + slug: z.string(), + logo: z.string().nullable(), + }), +}) + // ── Search ────────────────────────────────────────────── export const searchMessagesQuerySchema = paginationQuerySchema.extend({ diff --git a/apps/api/src/routes/v1/uploads/handlers.ts b/apps/api/src/routes/v1/uploads/handlers.ts index 7c74774..76bab42 100644 --- a/apps/api/src/routes/v1/uploads/handlers.ts +++ b/apps/api/src/routes/v1/uploads/handlers.ts @@ -8,8 +8,16 @@ import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" import { assertMemberCanCommunicate } from "@/lib/permissions" import { s3Client } from "@/lib/s3" import type { AppRouteHandler } from "@/lib/types/app-types" -import type { AvatarPresignRoute, PresignRoute } from "./routes" -import { MAX_AVATAR_SIZE, PRESIGNED_URL_EXPIRY_SECONDS } from "./schema" +import type { + AvatarPresignRoute, + GuildIconPresignRoute, + PresignRoute, +} from "./routes" +import { + MAX_AVATAR_SIZE, + MAX_GUILD_ICON_SIZE, + PRESIGNED_URL_EXPIRY_SECONDS, +} from "./schema" const DM_CHANNEL_TYPES = ["dm", "group_dm"] as const @@ -143,3 +151,35 @@ export const avatarPresign: AppRouteHandler = async (c) => { return c.json({ uploadUrl, fileUrl }, HttpStatusCodes.OK) } + +export const guildIconPresign: AppRouteHandler = async ( + c +) => { + const user = c.var.user + const { filename, contentType, size } = c.req.valid("json") + + if (size > MAX_GUILD_ICON_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 = `guild-icons/${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 692df02..c3b1237 100644 --- a/apps/api/src/routes/v1/uploads/index.ts +++ b/apps/api/src/routes/v1/uploads/index.ts @@ -5,5 +5,6 @@ import * as routes from "./routes" const uploadsRouter = createRouter() .openapi(routes.presign, handlers.presign) .openapi(routes.avatarPresign, handlers.avatarPresign) + .openapi(routes.guildIconPresign, handlers.guildIconPresign) export default uploadsRouter diff --git a/apps/api/src/routes/v1/uploads/routes.ts b/apps/api/src/routes/v1/uploads/routes.ts index 8a943ce..4fae0de 100644 --- a/apps/api/src/routes/v1/uploads/routes.ts +++ b/apps/api/src/routes/v1/uploads/routes.ts @@ -11,6 +11,8 @@ import { sessionAuthMiddleware } from "@/middleware/session-auth" import { avatarPresignRequestSchema, avatarPresignResponseSchema, + guildIconPresignRequestSchema, + guildIconPresignResponseSchema, presignRequestSchema, presignResponseSchema, } from "./schema" @@ -69,3 +71,30 @@ export const avatarPresign = createRoute({ }) export type AvatarPresignRoute = typeof avatarPresign + +export const guildIconPresign = createRoute({ + path: "/uploads/guild-icon/presign", + method: "post", + summary: "Request a presigned URL for guild icon upload", + description: + "Returns a presigned URL for uploading a guild icon to S3-compatible storage.", + tags: ["Uploads"], + middleware: [sessionAuthMiddleware] as const, + request: { + body: jsonContent({ + schema: guildIconPresignRequestSchema, + description: "Guild icon file metadata", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: guildIconPresignResponseSchema, + description: "Presigned URL for guild icon upload", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.REQUEST_TOO_LONG]: payloadTooLargeSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type GuildIconPresignRoute = typeof guildIconPresign diff --git a/apps/api/src/routes/v1/uploads/schema.ts b/apps/api/src/routes/v1/uploads/schema.ts index 22b9b2b..cef459d 100644 --- a/apps/api/src/routes/v1/uploads/schema.ts +++ b/apps/api/src/routes/v1/uploads/schema.ts @@ -47,3 +47,29 @@ export const avatarPresignResponseSchema = z.object({ uploadUrl: z.string().url(), fileUrl: z.string().url(), }) + +// ── Guild Icon ───────────────────────────────────────── + +const GUILD_ICON_MIME_TYPES = [ + "image/jpeg", + "image/png", + "image/webp", + "image/svg+xml", +] as const + +export const MAX_GUILD_ICON_SIZE = 2 * 1024 * 1024 // 2 MB + +export const guildIconPresignRequestSchema = z.object({ + filename: z.string().min(1).max(256), + contentType: z + .string() + .refine((ct) => (GUILD_ICON_MIME_TYPES as readonly string[]).includes(ct), { + message: "Unsupported image type", + }), + size: z.number().int().min(1).max(MAX_GUILD_ICON_SIZE), +}) + +export const guildIconPresignResponseSchema = z.object({ + uploadUrl: z.string().url(), + fileUrl: z.string().url(), +}) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index a81711f..454e0e2 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -18,7 +18,8 @@ "minWidth": 800, "minHeight": 600, "resizable": true, - "fullscreen": false + "fullscreen": false, + "dragDropEnabled": false } ], "security": { diff --git a/apps/web/src/components/guild/guild-settings-dialog.tsx b/apps/web/src/components/guild/guild-settings-dialog.tsx new file mode 100644 index 0000000..6483c03 --- /dev/null +++ b/apps/web/src/components/guild/guild-settings-dialog.tsx @@ -0,0 +1,298 @@ +import { authClient } from "@repo/auth/client" +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar" +import { Button } from "@repo/ui/components/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog" +import { Input } from "@repo/ui/components/input" +import { Label } from "@repo/ui/components/label" +import { cn } from "@repo/ui/lib/utils" +import { useQueryClient } from "@tanstack/react-query" +import { Camera, Loader2, Upload } from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { toast } from "sonner" +import { apiClient } from "@/lib/api-client" + +const MAX_GUILD_ICON_BYTES = 2 * 1024 * 1024 +const ACCEPTED_IMAGE_TYPES = [ + "image/jpeg", + "image/png", + "image/webp", + "image/svg+xml", +] + +function validateIconFile(file: File): string | null { + if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { + return "Only JPEG, PNG, WebP, and SVG images are allowed" + } + if (file.size > MAX_GUILD_ICON_BYTES) { + return "Icon must be under 2 MB" + } + return null +} + +type Guild = { + id: string + name: string + slug: string + logo?: string | null +} + +export function GuildSettingsDialog({ + open, + onOpenChange, + guild, +}: { + open: boolean + onOpenChange: (open: boolean) => void + guild: Guild +}) { + const queryClient = useQueryClient() + const [name, setName] = useState(guild.name) + const [iconPreview, setIconPreview] = useState(null) + const [iconFile, setIconFile] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const fileInputRef = useRef(null) + const iconPreviewRef = useRef(null) + const dragCountRef = useRef(0) + + useEffect(() => { + setName(guild.name) + setIconFile(null) + if (iconPreviewRef.current) { + URL.revokeObjectURL(iconPreviewRef.current) + iconPreviewRef.current = null + } + setIconPreview(null) + }, [guild, open]) + + useEffect(() => { + return () => { + if (iconPreviewRef.current) URL.revokeObjectURL(iconPreviewRef.current) + } + }, []) + + const setIconFromFile = useCallback((file: File) => { + const error = validateIconFile(file) + if (error) { + toast.error(error) + return + } + setIconFile(file) + if (iconPreviewRef.current) URL.revokeObjectURL(iconPreviewRef.current) + const url = URL.createObjectURL(file) + iconPreviewRef.current = url + setIconPreview(url) + }, []) + + const handleIconSelect = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) setIconFromFile(file) + }, + [setIconFromFile] + ) + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCountRef.current += 1 + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCountRef.current -= 1 + if (dragCountRef.current === 0) setIsDragging(false) + }, []) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCountRef.current = 0 + setIsDragging(false) + const file = e.dataTransfer.files[0] + if (file) setIconFromFile(file) + }, + [setIconFromFile] + ) + + const uploadIcon = useCallback(async (file: File): Promise => { + const res = await apiClient.v1.uploads["guild-icon"].presign.$post({ + json: { + filename: file.name, + contentType: file.type, + size: file.size, + }, + }) + + if (!res.ok) throw new Error("Failed to get upload URL") + + const { uploadUrl, fileUrl } = await res.json() + + const uploadRes = await fetch(uploadUrl, { + method: "PUT", + body: file, + headers: { "Content-Type": file.type }, + }) + + if (!uploadRes.ok) throw new Error("Failed to upload icon") + + return fileUrl + }, []) + + const handleSave = useCallback(async () => { + setIsSaving(true) + try { + let logoUrl: string | null | undefined + if (iconFile) { + logoUrl = await uploadIcon(iconFile) + } + + const res = await apiClient.v1.guilds[":guildSlug"].$patch({ + param: { guildSlug: guild.slug }, + json: { + ...(name.trim() !== guild.name ? { name: name.trim() } : {}), + ...(logoUrl !== undefined ? { logo: logoUrl } : {}), + }, + }) + + if (!res.ok) throw new Error("Failed to update guild") + + setIconFile(null) + if (iconPreview) { + URL.revokeObjectURL(iconPreview) + iconPreviewRef.current = null + setIconPreview(null) + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["guilds"] }), + queryClient.invalidateQueries({ queryKey: ["active-guild"] }), + ]) + + toast.success("Guild updated") + onOpenChange(false) + } catch { + toast.error("Failed to update guild") + } finally { + setIsSaving(false) + } + }, [ + guild, + name, + iconFile, + iconPreview, + uploadIcon, + queryClient, + onOpenChange, + ]) + + const hasChanges = name.trim() !== guild.name || iconFile !== null + const isValid = name.trim().length > 0 + + const displayIcon = iconPreview ?? guild.logo + const initials = guild.name + .split(" ") + .map((w) => w[0]) + .join("") + .slice(0, 2) + + return ( + + + + Guild Settings + + +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for icon upload */} +
+ +
+ + + + or drag & drop + +
+
+ +
+ + setName(e.target.value)} + maxLength={100} + placeholder="My Awesome Guild" + /> +
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx b/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx index 244c818..a1ad746 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx @@ -6,12 +6,15 @@ import { UserBar } from "./user-bar" export function ChannelPanel() { return ( -
+
- - - +
+ + +
+ +
) diff --git a/apps/web/src/components/sidebar/channel-panel/guild-header.tsx b/apps/web/src/components/sidebar/channel-panel/guild-header.tsx index c92f275..26e1117 100644 --- a/apps/web/src/components/sidebar/channel-panel/guild-header.tsx +++ b/apps/web/src/components/sidebar/channel-panel/guild-header.tsx @@ -1,5 +1,5 @@ import { authClient } from "@repo/auth/client" -import { isGuildRole } from "@repo/auth/permissions" +import { isGuildRole, roleHasPermissions } from "@repo/auth/permissions" import { DropdownMenu, DropdownMenuContent, @@ -9,8 +9,9 @@ import { } from "@repo/ui/components/dropdown-menu" import { useQuery } from "@tanstack/react-query" import { useParams } from "@tanstack/react-router" -import { ChevronDown, Link, UserPlus } from "lucide-react" +import { ChevronDown, Link, Settings, UserPlus } from "lucide-react" import { useMemo, useState } from "react" +import { GuildSettingsDialog } from "@/components/guild/guild-settings-dialog" import { CreateInviteDialog } from "@/components/invite/create-invite-dialog" import { ManageInvitesDialog } from "@/components/invite/manage-invites-dialog" import { canKickGuildMembers } from "@/lib/permissions" @@ -19,6 +20,7 @@ export function GuildHeader() { const { guildSlug } = useParams({ strict: false }) const [inviteDialogOpen, setInviteDialogOpen] = useState(false) const [manageInvitesOpen, setManageInvitesOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) const { data: guilds, isPending } = useQuery({ queryKey: ["guilds"], @@ -42,21 +44,31 @@ export function GuildHeader() { enabled: !!guildSlug, }) + const memberRole = + typeof activeMember?.role === "string" && isGuildRole(activeMember.role) + ? activeMember.role + : null + const canManageInvites = - typeof activeMember?.role === "string" && - isGuildRole(activeMember.role) && - canKickGuildMembers(activeMember.role) + memberRole !== null && canKickGuildMembers(memberRole) + const canEditGuild = + memberRole !== null && + roleHasPermissions(memberRole, { organization: ["update"] }) - const guildName = useMemo( - () => guilds?.find((g) => g.slug === guildSlug)?.name, + const activeGuild = useMemo( + () => guilds?.find((g) => g.slug === guildSlug) ?? null, [guilds, guildSlug] ) + const guildName = activeGuild?.name + const title = isPending ? "Loading..." : (guildName ?? "Guild not found") - if (!canManageInvites) { + const showDropdown = canManageInvites || canEditGuild + + if (!showDropdown) { return ( -
+

{title}

@@ -70,7 +82,7 @@ export function GuildHeader() { - setInviteDialogOpen(true)}> - - Invite People - - setManageInvitesOpen(true)}> - - Manage Invites - + {canManageInvites && ( + <> + setInviteDialogOpen(true)}> + + Invite People + + setManageInvitesOpen(true)}> + + Manage Invites + + + )} + {canEditGuild && ( + <> + {canManageInvites && } + setSettingsOpen(true)}> + + Guild Settings + + + )} @@ -98,6 +123,13 @@ export function GuildHeader() { open={manageInvitesOpen} onOpenChange={setManageInvitesOpen} /> + {canEditGuild && activeGuild && ( + + )} ) } diff --git a/apps/web/src/components/sidebar/channel-panel/search-bar.tsx b/apps/web/src/components/sidebar/channel-panel/search-bar.tsx index a41ae31..95b148b 100644 --- a/apps/web/src/components/sidebar/channel-panel/search-bar.tsx +++ b/apps/web/src/components/sidebar/channel-panel/search-bar.tsx @@ -129,7 +129,7 @@ export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { } return ( -
+
+
diff --git a/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx b/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx index 9241810..7f3295f 100644 --- a/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx +++ b/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx @@ -40,9 +40,13 @@ function GuildIcon({
{logo ? ( @@ -69,12 +73,12 @@ export function GuildBar() { }, }) return ( -
+
{/* Home / DMs button */}