4186d95447
Cluster: Appwrite container _APP_STORAGE_LIMIT 30000000 → 209715200 (200MB) in /root/services/appwrite/.env on kovaksoft-coolify, then docker compose up -d to roll the worker pool with the new value. Backup of the .env left at .env.bak.<date>. Bucket: job-files maximumFileSize updated to 209715200 via Appwrite MCP (storage_update_bucket). App: MAX_FILE_BYTES in both the upload API route and the original server action raised to 200MB. Client-side panel guard relaxed accordingly — one large file is now allowed to fill the entire batch (the 200MB proxy/serverActions cap is the bottleneck, not the per-file rule). Error copy updated. isletmem and any other tenants on the cluster also get the new limit, which is the desired behaviour — old 30MB ceiling was a relic of an Appwrite default that no DLS workflow can actually live with.
278 lines
8.5 KiB
TypeScript
278 lines
8.5 KiB
TypeScript
"use client";
|
||
|
||
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogClose,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Progress } from "@/components/ui/progress";
|
||
import { deleteJobFileAction } from "@/lib/appwrite/job-file-actions";
|
||
import {
|
||
initialJobFileActionState,
|
||
JOB_FILE_KIND_LABELS,
|
||
} from "@/lib/appwrite/job-file-types";
|
||
import type { JobFileWithUrl } from "@/lib/appwrite/job-file-queries";
|
||
|
||
function formatSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||
}
|
||
|
||
function kindIcon(kind: string) {
|
||
if (kind === "image") return <ImageIcon className="size-4" />;
|
||
if (kind === "scan") return <Layers className="size-4" />;
|
||
return <FileText className="size-4" />;
|
||
}
|
||
|
||
export function JobFilesPanel({
|
||
jobId,
|
||
files,
|
||
}: {
|
||
jobId: string;
|
||
files: JobFileWithUrl[];
|
||
}) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<UploadForm jobId={jobId} />
|
||
{files.length === 0 ? (
|
||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||
Henüz dosya yok.
|
||
</p>
|
||
) : (
|
||
<ul className="divide-y rounded-md border">
|
||
{files.map((f) => (
|
||
<FileRow key={f.$id} file={f} />
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const MAX_FILE_BYTES = 200 * 1024 * 1024;
|
||
const MAX_BATCH_BYTES = 200 * 1024 * 1024; // proxy cap; one large file fills the whole batch
|
||
|
||
function UploadForm({ jobId }: { jobId: string }) {
|
||
const router = useRouter();
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||
const [selected, setSelected] = useState<File[]>([]);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [progress, setProgress] = useState(0); // 0–100
|
||
|
||
const totalBytes = selected.reduce((s, f) => s + f.size, 0);
|
||
const overSize = selected.find((f) => f.size > MAX_FILE_BYTES);
|
||
const overBatch = totalBytes > MAX_BATCH_BYTES;
|
||
const blocked = Boolean(overSize) || overBatch;
|
||
|
||
function reset() {
|
||
setSelected([]);
|
||
setProgress(0);
|
||
setUploading(false);
|
||
if (inputRef.current) inputRef.current.value = "";
|
||
}
|
||
|
||
function startUpload() {
|
||
if (selected.length === 0 || blocked) return;
|
||
const formData = new FormData();
|
||
for (const f of selected) formData.append("files", f);
|
||
|
||
const xhr = new XMLHttpRequest();
|
||
xhrRef.current = xhr;
|
||
setUploading(true);
|
||
setProgress(0);
|
||
|
||
xhr.upload.addEventListener("progress", (e) => {
|
||
if (!e.lengthComputable) return;
|
||
setProgress(Math.round((e.loaded / e.total) * 100));
|
||
});
|
||
|
||
xhr.addEventListener("load", () => {
|
||
setUploading(false);
|
||
let payload: { ok?: boolean; uploaded?: number; error?: string } = {};
|
||
try {
|
||
payload = JSON.parse(xhr.responseText);
|
||
} catch {
|
||
/* non-JSON response */
|
||
}
|
||
if (xhr.status >= 200 && xhr.status < 300 && payload.ok) {
|
||
toast.success(`${payload.uploaded ?? selected.length} dosya yüklendi.`);
|
||
reset();
|
||
router.refresh();
|
||
} else {
|
||
toast.error(payload.error || `Yükleme başarısız (HTTP ${xhr.status}).`);
|
||
setProgress(0);
|
||
}
|
||
});
|
||
|
||
xhr.addEventListener("error", () => {
|
||
setUploading(false);
|
||
setProgress(0);
|
||
toast.error("Ağ hatası. Tekrar deneyin.");
|
||
});
|
||
|
||
xhr.addEventListener("abort", () => {
|
||
setUploading(false);
|
||
setProgress(0);
|
||
toast.message("Yükleme iptal edildi.");
|
||
});
|
||
|
||
xhr.open("POST", `/api/jobs/${jobId}/files`);
|
||
xhr.send(formData);
|
||
}
|
||
|
||
function cancelUpload() {
|
||
xhrRef.current?.abort();
|
||
}
|
||
|
||
return (
|
||
<div className="bg-muted/30 flex flex-col gap-3 rounded-md border p-3">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
multiple
|
||
className="hidden"
|
||
accept=".pdf,.jpg,.jpeg,.png,.webp,.tiff,.tif,.bmp,.heic,.heif,.stl,.obj,.ply,.3mf,.zip,.rar,.7z,.dcm,.stm"
|
||
onChange={(e) => {
|
||
const list = e.target.files ? Array.from(e.target.files) : [];
|
||
setSelected(list);
|
||
setProgress(0);
|
||
}}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => inputRef.current?.click()}
|
||
disabled={uploading}
|
||
>
|
||
<Upload className="size-4" />
|
||
Dosya seç
|
||
</Button>
|
||
<span className="text-muted-foreground text-xs flex-1">
|
||
{selected.length > 0 ? (
|
||
overSize ? (
|
||
<span className="text-destructive">
|
||
{overSize.name} 200MB'tan büyük (her dosya maksimum 200MB).
|
||
</span>
|
||
) : overBatch ? (
|
||
<span className="text-destructive">
|
||
Toplam {formatSize(totalBytes)} — 200MB sınırını aşıyor. Daha az dosya seçin.
|
||
</span>
|
||
) : (
|
||
<>
|
||
{selected.length} dosya seçildi ({formatSize(totalBytes)})
|
||
</>
|
||
)
|
||
) : (
|
||
"Tarama (STL/OBJ), görsel veya PDF — max 200MB / dosya"
|
||
)}
|
||
</span>
|
||
{uploading ? (
|
||
<Button type="button" size="sm" variant="outline" onClick={cancelUpload}>
|
||
İptal
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={startUpload}
|
||
disabled={selected.length === 0 || blocked}
|
||
>
|
||
<Upload className="size-4" />
|
||
Yükle
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{uploading && (
|
||
<div className="flex items-center gap-3">
|
||
<Progress value={progress} className="flex-1" />
|
||
<span className="text-muted-foreground min-w-[3rem] text-right text-xs tabular-nums">
|
||
{progress}%
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FileRow({ file }: { file: JobFileWithUrl }) {
|
||
const [state, action, pending] = useActionState(
|
||
deleteJobFileAction,
|
||
initialJobFileActionState,
|
||
);
|
||
const [, startTransition] = useTransition();
|
||
const [open, setOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (state.ok) {
|
||
toast.success("Dosya silindi.");
|
||
setOpen(false);
|
||
} else if (state.error) {
|
||
toast.error(state.error);
|
||
}
|
||
}, [state]);
|
||
|
||
return (
|
||
<li className="flex items-center gap-3 px-3 py-2">
|
||
<span className="text-muted-foreground">{kindIcon(file.kind)}</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||
<p className="text-muted-foreground text-xs">
|
||
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind} · {formatSize(file.size)}
|
||
</p>
|
||
</div>
|
||
<Badge variant="outline" className="hidden sm:inline-flex">
|
||
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind}
|
||
</Badge>
|
||
<Button asChild size="sm" variant="outline">
|
||
<a href={file.url} target="_blank" rel="noopener noreferrer" download={file.name}>
|
||
<Download className="size-4" />
|
||
</a>
|
||
</Button>
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||
<Trash2 className="size-4" />
|
||
</Button>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Dosya silinsin mi?</DialogTitle>
|
||
<DialogDescription>{file.name}</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<DialogClose asChild>
|
||
<Button type="button" variant="outline">
|
||
Vazgeç
|
||
</Button>
|
||
</DialogClose>
|
||
<form
|
||
action={(fd) => {
|
||
startTransition(() => action(fd));
|
||
}}
|
||
>
|
||
<input type="hidden" name="rowId" value={file.$id} />
|
||
<Button type="submit" variant="destructive" disabled={pending}>
|
||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||
Sil
|
||
</Button>
|
||
</form>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</li>
|
||
);
|
||
}
|