From 78f50755ed8a6baf159f29c0fe3f55629032f8f2 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 7 May 2026 20:34:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(attachments):=20XHR=20upload=20+=20drag&dr?= =?UTF-8?q?op=20+=20ilerleme=20=C3=A7ubu=C4=9Fu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/attachments route: istemci XHR ile doğrudan sunucuya yükler, gerçek zamanlı progress olur - AttachmentsPanel yeniden yazıldı: drag & drop zone, per-dosya ilerleme çubuğu (%), eş zamanlı çoklu yükleme - Appwrite bucket fileSecurity=true güncellendi (per-dosya tenant izolasyonu) --- src/app/api/attachments/route.ts | 105 +++++++++++++++ src/components/attachments-panel.tsx | 189 ++++++++++++++++++++------- 2 files changed, 245 insertions(+), 49 deletions(-) create mode 100644 src/app/api/attachments/route.ts diff --git a/src/app/api/attachments/route.ts b/src/app/api/attachments/route.ts new file mode 100644 index 0000000..18153c4 --- /dev/null +++ b/src/app/api/attachments/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { ID, Permission, Role } from "node-appwrite"; +import { InputFile } from "node-appwrite/file"; + +import { BUCKETS, DATABASE_ID, TABLES } from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import type { AttachmentItem } from "@/lib/appwrite/attachment-actions"; + +const MAX_BYTES = 20 * 1024 * 1024; + +function teamFilePermissions(tenantId: string) { + return [ + Permission.read(Role.team(tenantId)), + Permission.delete(Role.team(tenantId, "owner")), + Permission.delete(Role.team(tenantId, "admin")), + Permission.delete(Role.team(tenantId, "member")), + ]; +} + +export async function POST(request: NextRequest) { + let ctx; + try { + ctx = await requireTenant(); + } catch { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return NextResponse.json({ ok: false, error: "Invalid request" }, { status: 400 }); + } + + const entityType = String(formData.get("entityType") ?? "").trim(); + const entityId = String(formData.get("entityId") ?? "").trim(); + const file = formData.get("file"); + + if (!entityType || !entityId) { + return NextResponse.json({ ok: false, error: "Geçersiz istek." }, { status: 400 }); + } + if (!(file instanceof File) || file.size === 0) { + return NextResponse.json({ ok: false, error: "Dosya seçin." }, { status: 400 }); + } + if (file.size > MAX_BYTES) { + return NextResponse.json({ ok: false, error: "Dosya 20MB'dan büyük olamaz." }, { status: 400 }); + } + + const { storage, tablesDB } = createAdminClient(); + let createdFileId: string | null = null; + + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const inputFile = InputFile.fromBuffer(buffer, file.name); + const perms = teamFilePermissions(ctx.tenantId); + + const created = await storage.createFile({ + bucketId: BUCKETS.entityAttachments, + fileId: ID.unique(), + file: inputFile, + permissions: perms, + }); + createdFileId = created.$id; + + const row = await tablesDB.createRow( + DATABASE_ID, + TABLES.attachments, + ID.unique(), + { + tenantId: ctx.tenantId, + createdBy: ctx.user.id, + entityType, + entityId, + fileId: createdFileId, + name: file.name, + size: file.size, + mimeType: file.type || null, + }, + perms, + ); + + const attachment: AttachmentItem = { + id: row.$id, + fileId: createdFileId, + name: file.name, + size: file.size, + mimeType: file.type ?? "", + createdAt: row.$createdAt, + }; + + return NextResponse.json({ ok: true, attachment }); + } catch (e) { + if (createdFileId) { + try { + await storage.deleteFile({ bucketId: BUCKETS.entityAttachments, fileId: createdFileId }); + } catch { /* best-effort cleanup */ } + } + return NextResponse.json( + { ok: false, error: e instanceof Error ? e.message : "Yükleme başarısız." }, + { status: 500 }, + ); + } +} diff --git a/src/components/attachments-panel.tsx b/src/components/attachments-panel.tsx index 24ec486..6c2f384 100644 --- a/src/components/attachments-panel.tsx +++ b/src/components/attachments-panel.tsx @@ -1,38 +1,41 @@ "use client"; -import { useCallback, useActionState, useEffect, useRef, useState, useTransition } from "react"; -import { Loader2, Paperclip, Trash2, Upload } from "lucide-react"; +import { useCallback, useEffect, useRef, useState, useTransition } from "react"; +import { Loader2, Paperclip, Trash2, UploadCloud } from "lucide-react"; import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { Progress } from "@/components/ui/progress"; import { deleteAttachmentAction, fetchAttachmentsAction, - uploadAttachmentAction, type AttachmentItem, } from "@/lib/appwrite/attachment-actions"; +import { cn } from "@/lib/utils"; + +type UploadingFile = { + uid: string; + name: string; + size: number; + progress: number; +}; type Props = { entityType: "service" | "customer_software"; entityId: string; }; -const initialUploadState = { ok: false } as { ok: boolean; error?: string }; - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1048576).toFixed(1)} MB`; } export function AttachmentsPanel({ entityType, entityId }: Props) { const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); - const [uploadState, uploadAction, isUploading] = useActionState( - uploadAttachmentAction, - initialUploadState, - ); + const [queue, setQueue] = useState([]); + const [dragging, setDragging] = useState(false); const [, startDelete] = useTransition(); const fileRef = useRef(null); @@ -47,16 +50,69 @@ export function AttachmentsPanel({ entityType, entityId }: Props) { load(); }, [load]); - useEffect(() => { - if (uploadState.ok) { - toast.success("Dosya yüklendi."); + const uploadOne = useCallback( + (file: File) => { + if (file.size > 20 * 1024 * 1024) { + toast.error(`${file.name}: 20 MB sınırını aşıyor.`); + return; + } + + const uid = crypto.randomUUID(); + setQueue((q) => [...q, { uid, name: file.name, size: file.size, progress: 0 }]); + + const fd = new FormData(); + fd.append("file", file); + fd.append("entityType", entityType); + fd.append("entityId", entityId); + + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) { + const pct = Math.round((e.loaded / e.total) * 100); + setQueue((q) => q.map((u) => (u.uid === uid ? { ...u, progress: pct } : u))); + } + }); + + xhr.addEventListener("load", () => { + setQueue((q) => q.filter((u) => u.uid !== uid)); + try { + const res = JSON.parse(xhr.responseText) as { ok: boolean; error?: string }; + if (res.ok) { + load(); + } else { + toast.error(res.error ?? "Yükleme başarısız."); + } + } catch { + toast.error("Yükleme başarısız."); + } + }); + + xhr.addEventListener("error", () => { + setQueue((q) => q.filter((u) => u.uid !== uid)); + toast.error(`${file.name}: Bağlantı hatası.`); + }); + + xhr.open("POST", "/api/attachments"); + xhr.send(fd); + }, + [entityType, entityId, load], + ); + + const handleFiles = useCallback( + (list: FileList | null) => { + if (!list) return; + Array.from(list).forEach(uploadOne); if (fileRef.current) fileRef.current.value = ""; - load(); - } else if (uploadState.error) { - toast.error(uploadState.error); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [uploadState]); + }, + [uploadOne], + ); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + handleFiles(e.dataTransfer.files); + }; const handleDelete = (a: AttachmentItem) => { startDelete(async () => { @@ -64,52 +120,87 @@ export function AttachmentsPanel({ entityType, entityId }: Props) { fd.set("attachmentId", a.id); const result = await deleteAttachmentAction(fd); if (result.ok) { - toast.success("Dosya silindi."); setAttachments((prev) => prev.filter((x) => x.id !== a.id)); + toast.success("Dosya silindi."); } else { toast.error(result.error ?? "Silme başarısız."); } }); }; + const hasItems = attachments.length > 0 || queue.length > 0; + return (
-
- - -
- - -
-
+ {/* Drop zone */} +
fileRef.current?.click()} + onKeyDown={(e) => e.key === "Enter" && fileRef.current?.click()} + onDragOver={(e) => { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + className={cn( + "flex cursor-pointer select-none flex-col items-center justify-center gap-1.5 rounded-lg border-2 border-dashed px-4 py-5 text-center transition-colors", + dragging + ? "border-primary bg-primary/5" + : "border-border hover:border-primary/40 hover:bg-muted/30", + )} + > + +

+ Dosya seç + veya buraya sürükle +

+

Maks. 20 MB — birden fazla dosya eklenebilir

+
+ handleFiles(e.target.files)} + /> + + {/* Upload progress rows */} + {queue.map((u) => ( +
+
+
+ + {u.name} +
+ + {u.progress < 100 ? `${u.progress}%` : "İşleniyor..."} + +
+ +
+ ))} + + {/* Existing files */} {loading ? (
Yükleniyor...
- ) : attachments.length === 0 ? ( -

Henüz ek yok.

+ ) : !hasItems ? ( +

Henüz ek dosya yok.

) : (
{attachments.map((a) => (
{a.name} - + {formatBytes(a.size)}