feat(upload): 200mb cap + API route with XHR progress
- next.config: serverActions.bodySizeLimit + experimental.proxyClientMaxBodySize bumped from 500mb back down to 200mb. Batch ceiling (client side) is 180mb to stay comfortably under the proxy cap. - New POST /api/jobs/[jobId]/files endpoint replaces the server action for upload. Same auth/permissions/rollback semantics, but Returns JSON so the client can read the response. Server action is retained for delete only. - JobFilesPanel switched from useActionState to XMLHttpRequest.upload — xhr.upload.onprogress feeds a Progress bar (real bytes, not a fake ticker). Cancel button aborts the in-flight request. Successful upload triggers router.refresh() to repopulate the file list. Server actions can't expose upload progress (no streaming feedback in the RSC protocol yet), so any progress UX needs to go through fetch/XHR against a route handler. Trade-off accepted.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"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";
|
||||
|
||||
@@ -15,13 +16,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
deleteJobFileAction,
|
||||
uploadJobFilesAction,
|
||||
} from "@/lib/appwrite/job-file-actions";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { deleteJobFileAction } from "@/lib/appwrite/job-file-actions";
|
||||
import {
|
||||
initialJobFileActionState,
|
||||
initialJobFileUploadState,
|
||||
JOB_FILE_KIND_LABELS,
|
||||
} from "@/lib/appwrite/job-file-types";
|
||||
import type { JobFileWithUrl } from "@/lib/appwrite/job-file-queries";
|
||||
@@ -64,93 +62,150 @@ export function JobFilesPanel({
|
||||
}
|
||||
|
||||
const MAX_FILE_BYTES = 30 * 1024 * 1024;
|
||||
const MAX_BATCH_BYTES = 400 * 1024 * 1024; // leaves headroom under the 500MB proxy cap
|
||||
const MAX_BATCH_BYTES = 180 * 1024 * 1024; // headroom under the 200MB proxy cap
|
||||
|
||||
function UploadForm({ jobId }: { jobId: string }) {
|
||||
const [state, action, pending] = useActionState(
|
||||
uploadJobFilesAction,
|
||||
initialJobFileUploadState,
|
||||
);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||||
const [selected, setSelected] = useState<File[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok && state.uploaded) {
|
||||
toast.success(`${state.uploaded} dosya yüklendi.`);
|
||||
formRef.current?.reset();
|
||||
setSelected([]);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
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 (
|
||||
<form
|
||||
ref={formRef}
|
||||
action={action}
|
||||
className="bg-muted/30 flex flex-wrap items-center gap-3 rounded-md border p-3"
|
||||
>
|
||||
<input type="hidden" name="jobId" value={jobId} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
name="files"
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<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} 30MB'tan büyük (her dosya maksimum 30MB).
|
||||
</span>
|
||||
) : overBatch ? (
|
||||
<span className="text-destructive">
|
||||
Toplam {formatSize(totalBytes)} — 400MB sınırını aşıyor. Daha az dosya seçin.
|
||||
</span>
|
||||
<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} 30MB'tan büyük (her dosya maksimum 30MB).
|
||||
</span>
|
||||
) : overBatch ? (
|
||||
<span className="text-destructive">
|
||||
Toplam {formatSize(totalBytes)} — 180MB sınırını aşıyor. Daha az dosya seçin.
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{selected.length} dosya seçildi ({formatSize(totalBytes)})
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{selected.length} dosya seçildi ({formatSize(totalBytes)})
|
||||
</>
|
||||
)
|
||||
"Tarama (STL/OBJ), görsel veya PDF — max 30MB / dosya, batch 180MB"
|
||||
)}
|
||||
</span>
|
||||
{uploading ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={cancelUpload}>
|
||||
İptal
|
||||
</Button>
|
||||
) : (
|
||||
"Tarama (STL/OBJ), görsel veya PDF — max 30MB / dosya"
|
||||
)}
|
||||
</span>
|
||||
<Button type="submit" size="sm" disabled={pending || selected.length === 0 || blocked}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Yükleniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={startUpload}
|
||||
disabled={selected.length === 0 || blocked}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
Yükle
|
||||
</>
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user