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:
parent
978a5af5de
commit
423aa38888
14 changed files with 409 additions and 26 deletions
|
|
@ -58,6 +58,7 @@ vi.mock("@/features/workspace", () => ({
|
|||
if (type === "agent") return "CA";
|
||||
return "??";
|
||||
},
|
||||
getActorAvatarUrl: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
58
apps/web/hooks/use-file-upload.ts
Normal file
58
apps/web/hooks/use-file-upload.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
12
pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue