From 423aa3888836d226059674f2dabe4f6d093f8093 Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 31 Mar 2026 15:17:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(upload):=20add=20file=20upload=20UI=20?= =?UTF-8?q?=E2=80=94=20avatar,=20editor=20paste/drop,=20attachments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add uploadFile method to ApiClient (FormData + 401 handling) - Add useFileUpload hook with client-side validation - ActorAvatar renders actual avatar images with fallback to initials - Account settings: replace URL input with clickable avatar upload - RichTextEditor: add Image extension, paste/drop/insertFile support - Markdown renderer: add img component for uploaded images - CommentInput & ReplyInput: add paperclip button for file attachments - Issue description: paste/drop file upload support Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(dashboard)/issues/[id]/page.test.tsx | 1 + apps/web/app/(dashboard)/issues/page.test.tsx | 1 + .../settings/_components/account-tab.tsx | 88 ++++++++++++++---- apps/web/components/common/actor-avatar.tsx | 25 +++++- .../components/common/rich-text-editor.tsx | 89 +++++++++++++++++++ apps/web/components/markdown/Markdown.tsx | 9 ++ .../issues/components/comment-input.tsx | 44 ++++++++- .../issues/components/issue-detail.tsx | 15 ++++ .../issues/components/reply-input.tsx | 45 +++++++++- apps/web/features/workspace/hooks.ts | 8 +- apps/web/hooks/use-file-upload.ts | 58 ++++++++++++ apps/web/package.json | 1 + apps/web/shared/api/client.ts | 39 ++++++++ pnpm-lock.yaml | 12 +++ 14 files changed, 409 insertions(+), 26 deletions(-) create mode 100644 apps/web/hooks/use-file-upload.ts diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 8e27d5e1..77739728 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -58,6 +58,7 @@ vi.mock("@/features/workspace", () => ({ if (type === "agent") return "CA"; return "??"; }, + getActorAvatarUrl: () => null, }), })); diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index c7af7dcc..5e9d662e 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -34,6 +34,7 @@ vi.mock("@/features/workspace", () => ({ getActorName: (type: string, id: string) => type === "member" ? "Test User" : "Claude Agent", getActorInitials: () => "TU", + getActorAvatarUrl: () => null, }), useWorkspaceStore: Object.assign( (selector?: any) => { diff --git a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx index d3ecb705..dbf40065 100644 --- a/apps/web/app/(dashboard)/settings/_components/account-tab.tsx +++ b/apps/web/app/(dashboard)/settings/_components/account-tab.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { Save } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Camera, Loader2, Save } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -9,27 +9,48 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useAuthStore } from "@/features/auth"; import { api } from "@/shared/api"; +import { useFileUpload } from "@/hooks/use-file-upload"; export function AccountTab() { const user = useAuthStore((s) => s.user); const setUser = useAuthStore((s) => s.setUser); const [profileName, setProfileName] = useState(user?.name ?? ""); - const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? ""); const [profileSaving, setProfileSaving] = useState(false); + const { upload, uploading } = useFileUpload(); + const fileInputRef = useRef(null); useEffect(() => { setProfileName(user?.name ?? ""); - setAvatarUrl(user?.avatar_url ?? ""); }, [user]); + const initials = (user?.name ?? "") + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + // Reset input so the same file can be re-selected + e.target.value = ""; + try { + const result = await upload(file); + if (!result) return; + const updated = await api.updateMe({ avatar_url: result.link }); + setUser(updated); + toast.success("Avatar updated"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to upload avatar"); + } + }; + const handleProfileSave = async () => { setProfileSaving(true); try { - const updated = await api.updateMe({ - name: profileName, - avatar_url: avatarUrl || undefined, - }); + const updated = await api.updateMe({ name: profileName }); setUser(updated); toast.success("Profile updated"); } catch (e) { @@ -45,7 +66,46 @@ export function AccountTab() {

Profile

- + + {/* Avatar upload */} +
+ + +
+ Click to upload avatar +
+
+
-
- - setAvatarUrl(e.target.value)} - placeholder="https://example.com/avatar.png" - className="mt-1" - /> -
+
@@ -76,7 +101,23 @@ function ReplyInput({ }`} >
-
+
+ +