diff --git a/.env.example b/.env.example index 1c0c93ab..e627d3f9 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,15 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback +# S3 / CloudFront +S3_BUCKET= +S3_REGION=us-west-2 +CLOUDFRONT_KEY_PAIR_ID= +CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key +CLOUDFRONT_PRIVATE_KEY= +CLOUDFRONT_DOMAIN= +COOKIE_DOMAIN= + # Frontend FRONTEND_PORT=3000 FRONTEND_ORIGIN=http://localhost:3000 diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 8e27d5e1..065d8051 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, }), })); @@ -296,6 +297,7 @@ describe("IssueDetailPage", () => { author_id: "user-1", parent_id: null, reactions: [], + attachments: [], created_at: "2026-01-18T00:00:00Z", updated_at: "2026-01-18T00:00:00Z", }; 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..78f3524e 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 "@/shared/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" - /> -
+
diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index b95662c4..0d61955f 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -1,16 +1,18 @@ "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 "@/shared/hooks/use-file-upload"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ReplyInputProps { + issueId: string; placeholder?: string; avatarType: string; avatarId: string; @@ -23,6 +25,7 @@ interface ReplyInputProps { // --------------------------------------------------------------------------- function ReplyInput({ + issueId, placeholder = "Leave a reply...", avatarType, avatarId, @@ -30,8 +33,22 @@ function ReplyInput({ size = "default", }: ReplyInputProps) { const editorRef = useRef(null); + const fileInputRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); const [submitting, setSubmitting] = useState(false); + const { uploadWithToast, uploading } = useFileUpload(); + + const handleUpload = (file: File) => uploadWithToast(file, { issueId }); + + const handleFileSelect = async (e: React.ChangeEvent) => { + 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 +84,7 @@ function ReplyInput({ placeholder={placeholder} onUpdate={(md) => setIsEmpty(!md.trim())} onSubmit={handleSubmit} + onUploadFile={handleUpload} debounceMs={100} /> @@ -76,7 +94,23 @@ function ReplyInput({ }`} >
-
+
+ +