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";
|
"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) {
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
toast.error(`${file.name}: 20 MB sınırını aşıyor.`);
|
||||||
load();
|
return;
|
||||||
} else if (uploadState.error) {
|
|
||||||
toast.error(uploadState.error);
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [uploadState]);
|
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 = "";
|
||||||
|
},
|
||||||
|
[uploadOne],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
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}
|
||||||
|
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
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
name="file"
|
multiple
|
||||||
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"
|
className="hidden"
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
|
|
||||||
|
{/* 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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user