feat(attachments): XHR upload + drag&drop + ilerleme çubuğu

- 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)
This commit is contained in:
kovakmedya
2026-05-07 20:34:28 +03:00
parent 1299cd10ce
commit 78f50755ed
2 changed files with 245 additions and 49 deletions
+105
View File
@@ -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 },
);
}
}
+140 -49
View File
@@ -1,38 +1,41 @@
"use client"; "use client";
import { useCallback, useActionState, useEffect, useRef, useState, useTransition } from "react"; import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { Loader2, Paperclip, Trash2, Upload } from "lucide-react"; import { Loader2, Paperclip, Trash2, UploadCloud } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { import {
deleteAttachmentAction, deleteAttachmentAction,
fetchAttachmentsAction, fetchAttachmentsAction,
uploadAttachmentAction,
type AttachmentItem, type AttachmentItem,
} from "@/lib/appwrite/attachment-actions"; } from "@/lib/appwrite/attachment-actions";
import { cn } from "@/lib/utils";
type UploadingFile = {
uid: string;
name: string;
size: number;
progress: number;
};
type Props = { type Props = {
entityType: "service" | "customer_software"; entityType: "service" | "customer_software";
entityId: string; entityId: string;
}; };
const initialUploadState = { ok: false } as { ok: boolean; error?: string }; function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
function formatBytes(bytes: number): string { if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`;
if (bytes < 1024) return `${bytes} B`; return `${(n / 1048576).toFixed(1)} MB`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
export function AttachmentsPanel({ entityType, entityId }: Props) { export function AttachmentsPanel({ entityType, entityId }: Props) {
const [attachments, setAttachments] = useState<AttachmentItem[]>([]); const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploadState, uploadAction, isUploading] = useActionState( const [queue, setQueue] = useState<UploadingFile[]>([]);
uploadAttachmentAction, const [dragging, setDragging] = useState(false);
initialUploadState,
);
const [, startDelete] = useTransition(); const [, startDelete] = useTransition();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -47,16 +50,69 @@ export function AttachmentsPanel({ entityType, entityId }: Props) {
load(); load();
}, [load]); }, [load]);
useEffect(() => { const uploadOne = useCallback(
if (uploadState.ok) { (file: File) => {
toast.success("Dosya yüklendi."); 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 = ""; if (fileRef.current) fileRef.current.value = "";
load(); },
} else if (uploadState.error) { [uploadOne],
toast.error(uploadState.error); );
}
// eslint-disable-next-line react-hooks/exhaustive-deps const handleDrop = (e: React.DragEvent) => {
}, [uploadState]); e.preventDefault();
setDragging(false);
handleFiles(e.dataTransfer.files);
};
const handleDelete = (a: AttachmentItem) => { const handleDelete = (a: AttachmentItem) => {
startDelete(async () => { startDelete(async () => {
@@ -64,52 +120,87 @@ export function AttachmentsPanel({ entityType, entityId }: Props) {
fd.set("attachmentId", a.id); fd.set("attachmentId", a.id);
const result = await deleteAttachmentAction(fd); const result = await deleteAttachmentAction(fd);
if (result.ok) { if (result.ok) {
toast.success("Dosya silindi.");
setAttachments((prev) => prev.filter((x) => x.id !== a.id)); setAttachments((prev) => prev.filter((x) => x.id !== a.id));
toast.success("Dosya silindi.");
} else { } else {
toast.error(result.error ?? "Silme başarısız."); toast.error(result.error ?? "Silme başarısız.");
} }
}); });
}; };
const hasItems = attachments.length > 0 || queue.length > 0;
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Ekler</Label> <Label>Ekler</Label>
<form action={uploadAction}> {/* Drop zone */}
<input type="hidden" name="entityType" value={entityType} /> <div
<input type="hidden" name="entityId" value={entityId} /> role="button"
<div className="flex items-center gap-2"> tabIndex={0}
<input onClick={() => fileRef.current?.click()}
ref={fileRef} onKeyDown={(e) => e.key === "Enter" && fileRef.current?.click()}
type="file" onDragOver={(e) => {
name="file" e.preventDefault();
className="flex-1 text-sm file:mr-2 file:cursor-pointer file:rounded file:border-0 file:bg-muted file:px-3 file:py-1.5 file:text-xs file:font-medium" setDragging(true);
/> }}
<Button type="submit" variant="outline" size="sm" disabled={isUploading}> onDragLeave={() => setDragging(false)}
{isUploading ? ( onDrop={handleDrop}
<Loader2 className="size-4 animate-spin" /> 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",
<Upload className="size-4" /> dragging
)} ? "border-primary bg-primary/5"
Yükle : "border-border hover:border-primary/40 hover:bg-muted/30",
</Button> )}
</div> >
</form> <UploadCloud
className={cn("size-7", dragging ? "text-primary" : "text-muted-foreground")}
/>
<p className="text-sm">
<span className="text-primary font-medium">Dosya seç</span>
<span className="text-muted-foreground"> veya buraya sürükle</span>
</p>
<p className="text-muted-foreground text-xs">Maks. 20 MB birden fazla dosya eklenebilir</p>
</div>
<input
ref={fileRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
{/* Upload progress rows */}
{queue.map((u) => (
<div key={u.uid} className="space-y-1.5 rounded-lg border px-3 py-2.5">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<Loader2 className="text-primary size-3.5 shrink-0 animate-spin" />
<span className="truncate text-xs font-medium">{u.name}</span>
</div>
<span className="text-muted-foreground shrink-0 text-xs">
{u.progress < 100 ? `${u.progress}%` : "İşleniyor..."}
</span>
</div>
<Progress value={u.progress} className="h-1.5" />
</div>
))}
{/* Existing files */}
{loading ? ( {loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-1 text-xs"> <div className="text-muted-foreground flex items-center gap-2 py-1 text-xs">
<Loader2 className="size-3 animate-spin" /> <Loader2 className="size-3 animate-spin" />
Yükleniyor... Yükleniyor...
</div> </div>
) : attachments.length === 0 ? ( ) : !hasItems ? (
<p className="text-muted-foreground py-1 text-xs">Henüz ek yok.</p> <p className="text-muted-foreground py-0.5 text-xs">Henüz ek dosya yok.</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{attachments.map((a) => ( {attachments.map((a) => (
<div <div
key={a.id} key={a.id}
className="flex items-center gap-2 rounded border px-2.5 py-1.5" className="bg-muted/30 flex items-center gap-2 rounded-md border px-2.5 py-2"
> >
<Paperclip className="text-muted-foreground size-3.5 shrink-0" /> <Paperclip className="text-muted-foreground size-3.5 shrink-0" />
<a <a
@@ -120,13 +211,13 @@ export function AttachmentsPanel({ entityType, entityId }: Props) {
> >
{a.name} {a.name}
</a> </a>
<span className="text-muted-foreground shrink-0 text-xs"> <span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatBytes(a.size)} {formatBytes(a.size)}
</span> </span>
<button <button
type="button" type="button"
onClick={() => handleDelete(a)} onClick={() => handleDelete(a)}
className="text-muted-foreground hover:text-destructive ml-1 shrink-0" className="text-muted-foreground hover:text-destructive ml-0.5 shrink-0 transition-colors"
title="Sil" title="Sil"
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />