feat(upload): add file upload UI — avatar, editor paste/drop, attachments

- 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) <noreply@anthropic.com>
This commit is contained in:
yushen 2026-03-31 15:17:54 +08:00
parent 978a5af5de
commit 423aa38888
14 changed files with 409 additions and 26 deletions

View file

@ -58,6 +58,7 @@ vi.mock("@/features/workspace", () => ({
if (type === "agent") return "CA";
return "??";
},
getActorAvatarUrl: () => null,
}),
}));

View file

@ -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) => {

View file

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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() {
<h2 className="text-sm font-semibold">Profile</h2>
<Card>
<CardContent className="space-y-3">
<CardContent className="space-y-4">
{/* Avatar upload */}
<div className="flex items-center gap-4">
<button
type="button"
className="group relative h-16 w-16 shrink-0 rounded-full bg-muted overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{user?.avatar_url ? (
<img
src={user.avatar_url}
alt={user.name}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-lg font-semibold text-muted-foreground">
{initials}
</span>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
{uploading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" />
) : (
<Camera className="h-5 w-5 text-white" />
)}
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
/>
<div className="text-xs text-muted-foreground">
Click to upload avatar
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
@ -55,16 +115,6 @@ export function AccountTab() {
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Avatar URL</Label>
<Input
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
className="mt-1"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"

View file

@ -1,5 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { Bot } from "lucide-react";
import { cn } from "@/lib/utils";
import { useActorName } from "@/features/workspace";
@ -8,8 +9,10 @@ interface ActorAvatarProps {
actorType: string;
actorId: string;
size?: number;
avatarUrl?: string | null;
getName?: (type: string, id: string) => string;
getInitials?: (type: string, id: string) => string;
getAvatarUrl?: (type: string, id: string) => string | null;
className?: string;
}
@ -17,29 +20,47 @@ function ActorAvatar({
actorType,
actorId,
size = 20,
avatarUrl,
getName,
getInitials,
getAvatarUrl,
className,
}: ActorAvatarProps) {
const actorNameHook = useActorName();
const resolveName = getName ?? actorNameHook.getActorName;
const resolveInitials = getInitials ?? actorNameHook.getActorInitials;
const resolveAvatarUrl = getAvatarUrl ?? actorNameHook.getActorAvatarUrl;
const name = resolveName(actorType, actorId);
const initials = resolveInitials(actorType, actorId);
const isAgent = actorType === "agent";
const resolvedUrl = avatarUrl !== undefined ? avatarUrl : resolveAvatarUrl(actorType, actorId);
const [imgError, setImgError] = useState(false);
// Reset error state when URL changes (e.g. user uploads new avatar)
useEffect(() => {
setImgError(false);
}, [resolvedUrl]);
return (
<div
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-medium",
"inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden",
"bg-muted text-muted-foreground",
className
)}
style={{ width: size, height: size, fontSize: size * 0.45 }}
title={name}
>
{isAgent ? (
{resolvedUrl && !imgError ? (
<img
src={resolvedUrl}
alt={name}
className="h-full w-full object-cover"
onError={() => setImgError(true)}
/>
) : isAgent ? (
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
) : (
initials

View file

@ -12,9 +12,12 @@ import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Mention from "@tiptap/extension-mention";
import Image from "@tiptap/extension-image";
import { Markdown } from "tiptap-markdown";
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/hooks/use-file-upload";
import { createMentionSuggestion } from "./mention-suggestion";
import "./rich-text-editor.css";
@ -30,12 +33,14 @@ interface RichTextEditorProps {
className?: string;
debounceMs?: number;
onSubmit?: () => void;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
}
interface RichTextEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
insertFile: (filename: string, url: string, isImage: boolean) => void;
}
// ---------------------------------------------------------------------------
@ -139,6 +144,77 @@ function createSubmitExtension(onSubmit: () => void) {
});
}
// ---------------------------------------------------------------------------
// File upload extension (paste + drop)
// ---------------------------------------------------------------------------
function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
return Extension.create({
name: "fileUpload",
addProseMirrorPlugins() {
const { editor } = this;
const handleFiles = async (files: FileList, pos?: number) => {
const handler = onUploadFileRef.current;
if (!handler) return false;
let handled = false;
for (const file of Array.from(files)) {
handled = true;
try {
const result = await handler(file);
if (!result) continue;
const isImage = file.type.startsWith("image/");
if (isImage) {
editor
.chain()
.focus()
.setImage({ src: result.link, alt: result.filename })
.run();
} else {
// Insert as a markdown link
const linkText = `[${result.filename}](${result.link})`;
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
} else {
editor.chain().focus().insertContent(linkText).run();
}
}
} catch {
// Upload errors handled by the hook/caller via toast
}
}
return handled;
};
return [
new Plugin({
key: new PluginKey("fileUpload"),
props: {
handlePaste(_view, event) {
const files = event.clipboardData?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
handleDrop(_view, event) {
const files = (event as DragEvent).dataTransfer?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
},
}),
];
},
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@ -153,12 +229,14 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
className,
debounceMs = 300,
onSubmit,
onUploadFile,
},
ref,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
const onUploadFileRef = useRef(onUploadFile);
// Helper to get markdown from tiptap-markdown storage
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -168,6 +246,7 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
onUploadFileRef.current = onUploadFile;
const editor = useEditor({
immediatelyRender: false,
@ -184,12 +263,14 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
LinkExtension,
Typography,
MentionExtension,
Image.configure({ inline: false, allowBase64: false }),
Markdown.configure({
html: false,
transformPastedText: true,
transformCopiedText: true,
}),
createSubmitExtension(() => onSubmitRef.current?.()),
createFileUploadExtension(onUploadFileRef),
],
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
@ -234,6 +315,14 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
focus: () => {
editor?.commands.focus();
},
insertFile: (filename: string, url: string, isImage: boolean) => {
if (!editor) return;
if (isImage) {
editor.chain().focus().setImage({ src: url, alt: filename }).run();
} else {
editor.chain().focus().insertContent(`[${filename}](${url})`).run();
}
},
}));
if (!editor) return null;

View file

@ -56,6 +56,15 @@ function createComponents(
onFileClick?: (path: string) => void
): Partial<Components> {
const baseComponents: Partial<Components> = {
// Images: render uploaded images with constrained sizing
img: ({ src, alt }) => (
<img
src={src}
alt={alt ?? ""}
className="max-w-full h-auto rounded-md my-2"
loading="lazy"
/>
),
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id or mention://agent/id

View file

@ -1,9 +1,11 @@
"use client";
import { useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { ArrowUp, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { toast } from "sonner";
interface CommentInputProps {
onSubmit: (content: string) => Promise<void>;
@ -11,8 +13,30 @@ interface CommentInputProps {
function CommentInput({ onSubmit }: CommentInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { upload, uploading } = useFileUpload();
const handleUpload = async (file: File) => {
try {
const result = await upload(file);
return result;
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed");
return null;
}
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const result = await handleUpload(file);
if (result) {
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
}
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
@ -35,10 +59,26 @@ function CommentInput({ onSubmit }: CommentInputProps) {
placeholder="Leave a comment..."
onUpdate={(md) => setIsEmpty(!md.trim())}
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
/>
</div>
<div className="absolute bottom-1.5 right-1.5">
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-muted-foreground hover:text-foreground"
>
<Paperclip className="h-4 w-4" />
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
/>
<Button
size="icon-sm"
disabled={isEmpty || submitting}

View file

@ -69,6 +69,7 @@ import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline";
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
import { ReactionBar } from "@/components/common/reaction-bar";
import { useFileUpload } from "@/hooks/use-file-upload";
import { timeAgo } from "@/shared/utils";
function shortDate(date: string | null): string {
@ -179,6 +180,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
const { getActorName, getActorInitials } = useActorName();
const { upload: uploadFile } = useFileUpload();
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: layoutId,
});
@ -249,6 +251,18 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
[issue, id],
);
const handleDescriptionUpload = useCallback(
async (file: File) => {
try {
return await uploadFile(file);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed");
return null;
}
},
[uploadFile],
);
const handleDelete = async () => {
setDeleting(true);
try {
@ -574,6 +588,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
defaultValue={issue.description || ""}
placeholder="Add description..."
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
onUploadFile={handleDescriptionUpload}
debounceMs={1500}
className="mt-5"
/>

View file

@ -1,10 +1,12 @@
"use client";
import { useRef, useState } from "react";
import { ArrowUp } from "lucide-react";
import { ArrowUp, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useFileUpload } from "@/hooks/use-file-upload";
import { toast } from "sonner";
// ---------------------------------------------------------------------------
// Types
@ -30,8 +32,30 @@ function ReplyInput({
size = "default",
}: ReplyInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { upload, uploading } = useFileUpload();
const handleUpload = async (file: File) => {
try {
const result = await upload(file);
return result;
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed");
return null;
}
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const result = await handleUpload(file);
if (result) {
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
}
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
@ -67,6 +91,7 @@ function ReplyInput({
placeholder={placeholder}
onUpdate={(md) => setIsEmpty(!md.trim())}
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
/>
</div>
@ -76,7 +101,23 @@ function ReplyInput({
}`}
>
<div className="overflow-hidden">
<div className="flex items-center justify-end pt-1">
<div className="flex items-center justify-end gap-1 pt-1">
<Button
variant="ghost"
size="icon-xs"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
tabIndex={isEmpty ? -1 : 0}
className="text-muted-foreground hover:text-foreground"
>
<Paperclip className="h-3.5 w-3.5" />
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
/>
<Button
size="icon-xs"
disabled={isEmpty || submitting}

View file

@ -32,5 +32,11 @@ export function useActorName() {
.slice(0, 2);
};
return { getMemberName, getAgentName, getActorName, getActorInitials };
const getActorAvatarUrl = (type: string, id: string): string | null => {
if (type === "member") return members.find((m) => m.user_id === id)?.avatar_url ?? null;
if (type === "agent") return agents.find((a) => a.id === id)?.avatar_url ?? null;
return null;
};
return { getMemberName, getAgentName, getActorName, getActorInitials, getActorAvatarUrl };
}

View file

@ -0,0 +1,58 @@
"use client";
import { useState, useCallback } from "react";
import { api } from "@/shared/api";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const ALLOWED_TYPES = new Set([
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/svg+xml",
"application/pdf",
"text/plain",
"text/csv",
"application/json",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/wav",
"application/zip",
]);
function isAllowedType(type: string): boolean {
const mediaType = type.split(";")[0] ?? "";
return ALLOWED_TYPES.has(mediaType.trim().toLowerCase());
}
export interface UploadResult {
filename: string;
link: string;
}
export function useFileUpload() {
const [uploading, setUploading] = useState(false);
const upload = useCallback(
async (file: File): Promise<UploadResult | null> => {
if (file.size > MAX_FILE_SIZE) {
throw new Error("File exceeds 10 MB limit");
}
if (!isAllowedType(file.type)) {
throw new Error(`File type not allowed: ${file.type}`);
}
setUploading(true);
try {
return await api.uploadFile(file);
} finally {
setUploading(false);
}
},
[],
);
return { upload, uploading };
}

View file

@ -17,6 +17,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@tiptap/extension-image": "^3.21.0",
"@tiptap/extension-link": "^3.20.5",
"@tiptap/extension-mention": "^3.20.5",
"@tiptap/extension-placeholder": "^3.20.5",

View file

@ -519,4 +519,43 @@ export class ApiClient {
async revokePersonalAccessToken(id: string): Promise<void> {
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
}
// File Upload
async uploadFile(file: File): Promise<{ filename: string; link: string }> {
const formData = new FormData();
formData.append("file", file);
const headers: Record<string, string> = {};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
const res = await fetch(`${this.baseUrl}/api/upload-file`, {
method: "POST",
headers,
body: formData,
});
if (!res.ok) {
if (res.status === 401 && typeof window !== "undefined") {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
this.token = null;
this.workspaceId = null;
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
let message = `Upload failed: ${res.status}`;
try {
const data = (await res.json()) as { error?: string };
if (typeof data.error === "string" && data.error) message = data.error;
} catch {
// Ignore non-JSON error bodies.
}
throw new Error(message);
}
return res.json() as Promise<{ filename: string; link: string }>;
}
}

12
pnpm-lock.yaml generated
View file

@ -72,6 +72,9 @@ importers:
'@emoji-mart/data':
specifier: ^1.2.1
version: 1.2.1
'@tiptap/extension-image':
specifier: ^3.21.0
version: 3.21.0(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
'@tiptap/extension-link':
specifier: ^3.20.5
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
@ -1365,6 +1368,11 @@ packages:
'@tiptap/core': ^3.20.5
'@tiptap/pm': ^3.20.5
'@tiptap/extension-image@3.21.0':
resolution: {integrity: sha512-W9786a2K4LSZJMPeRLmoDulJeXOsM0ueRV2MHjTol7ikPRauROB7GUbAz9DyPAJHA2AGUfpswnGAYPO3tz5CLg==}
peerDependencies:
'@tiptap/core': ^3.21.0
'@tiptap/extension-italic@3.20.5':
resolution: {integrity: sha512-7bZCgdJVTvhR5vSmNgFQbGvgRoC6m26KcUpHqWiKA95kLL5Wk4YlMCIqdiDpvJ1eakeFEvDcGZvFLg5+1NiQ+w==}
peerDependencies:
@ -4952,6 +4960,10 @@ snapshots:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
'@tiptap/pm': 3.20.5
'@tiptap/extension-image@3.21.0(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
dependencies:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
'@tiptap/extension-italic@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))':
dependencies:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)