Files
lab/src/app/(dashboard)/jobs/[jobId]/components/job-files-panel.tsx
T
kovakmedya 4186d95447 feat(upload): bump per-file cap to 200MB end-to-end
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.
2026-05-21 21:24:11 +03:00

278 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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); // 0100
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&apos;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>
);
}