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>
84 lines
2 KiB
TypeScript
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 };
|
|
}
|