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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<AttachmentItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadState, uploadAction, isUploading] = useActionState(
|
||||
uploadAttachmentAction,
|
||||
initialUploadState,
|
||||
);
|
||||
const [queue, setQueue] = useState<UploadingFile[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [, startDelete] = useTransition();
|
||||
const fileRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="grid gap-2">
|
||||
<Label>Ekler</Label>
|
||||
|
||||
<form action={uploadAction}>
|
||||
<input type="hidden" name="entityType" value={entityType} />
|
||||
<input type="hidden" name="entityId" value={entityId} />
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
name="file"
|
||||
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"
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm" disabled={isUploading}>
|
||||
{isUploading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="size-4" />
|
||||
)}
|
||||
Yükle
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => 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",
|
||||
)}
|
||||
>
|
||||
<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 ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-1 text-xs">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Yükleniyor...
|
||||
</div>
|
||||
) : attachments.length === 0 ? (
|
||||
<p className="text-muted-foreground py-1 text-xs">Henüz ek yok.</p>
|
||||
) : !hasItems ? (
|
||||
<p className="text-muted-foreground py-0.5 text-xs">Henüz ek dosya yok.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{attachments.map((a) => (
|
||||
<div
|
||||
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" />
|
||||
<a
|
||||
@@ -120,13 +211,13 @@ export function AttachmentsPanel({ entityType, entityId }: Props) {
|
||||
>
|
||||
{a.name}
|
||||
</a>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{formatBytes(a.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
|
||||
Reference in New Issue
Block a user