multica/apps/web/shared/hooks/use-file-upload.ts
yushen 79cd2a3a5d fix(upload): link attachments to comments via client-side ID tracking
Instead of regex-parsing markdown content to find attachment URLs
(fragile), the frontend now tracks uploaded attachment IDs and sends
them with the comment creation request. The backend links them by ID.

Frontend: upload returns attachment ID, comment/reply inputs collect
IDs during editing session, pass as attachment_ids on submit.
Backend: CreateComment accepts attachment_ids, links by ID+issue scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:47:27 +08:00

84 lines
2 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { api } from "@/shared/api";
import type { Attachment } from "@/shared/types";
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 {
// Empty MIME type (browser couldn't determine) — let the server sniff and decide.
if (!type) return true;
const mediaType = type.split(";")[0] ?? "";
return ALLOWED_TYPES.has(mediaType.trim().toLowerCase());
}
export interface UploadResult {
id: string;
filename: string;
link: string;
}
export interface UploadContext {
issueId?: string;
commentId?: string;
}
export function useFileUpload() {
const [uploading, setUploading] = useState(false);
const upload = useCallback(
async (file: File, ctx?: UploadContext): 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 {
const att: Attachment = await api.uploadFile(file, {
issueId: ctx?.issueId,
commentId: ctx?.commentId,
});
return { id: att.id, filename: att.filename, link: att.url };
} finally {
setUploading(false);
}
},
[],
);
const uploadWithToast = useCallback(
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
try {
return await upload(file, ctx);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed");
return null;
}
},
[upload],
);
return { upload, uploadWithToast, uploading };
}