fix(upload): two-phase UI — uploading bar then 'processing' spinner
XHR's upload.progress event only tracks browser→server byte transfer. It
fires 100% the moment the multipart body has been pushed, but at that
point our route handler hasn't even started streaming the files into
Appwrite Storage yet. A 200MB upload completes the network phase fast,
then sits 30-60 seconds while the server does sequential storage.createFile
+ tablesDB.createRow per file. The UI was stuck on '100%' the whole time,
making it look frozen.
Switched the form to a discriminated phase state:
idle → uploading (real bytes %) → processing (full bar + spinner)
→ idle (success/fail)
Listening on xhr.upload's own load event flips us to 'processing' the
instant the body is done. The outer xhr.load fires when the route handler
responds with JSON — that's when we toast + router.refresh().
User now sees: bar fills, then 'Sunucu Appwrite'a yazıyor — büyük dosyalar
30-60 sn sürebilir' message until the response comes back. No more
mystery wait.
This commit is contained in:
@@ -64,23 +64,26 @@ export function JobFilesPanel({
|
||||
const MAX_FILE_BYTES = 200 * 1024 * 1024;
|
||||
const MAX_BATCH_BYTES = 200 * 1024 * 1024; // proxy cap; one large file fills the whole batch
|
||||
|
||||
type UploadPhase = "idle" | "uploading" | "processing";
|
||||
|
||||
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 [phase, setPhase] = useState<UploadPhase>("idle");
|
||||
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;
|
||||
const busy = phase !== "idle";
|
||||
|
||||
function reset() {
|
||||
setSelected([]);
|
||||
setProgress(0);
|
||||
setUploading(false);
|
||||
setPhase("idle");
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
|
||||
@@ -91,16 +94,24 @@ function UploadForm({ jobId }: { jobId: string }) {
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhrRef.current = xhr;
|
||||
setUploading(true);
|
||||
setPhase("uploading");
|
||||
setProgress(0);
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (!e.lengthComputable) return;
|
||||
setProgress(Math.round((e.loaded / e.total) * 100));
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
setProgress(pct);
|
||||
});
|
||||
|
||||
// The browser has finished pushing bytes; the server is now writing to
|
||||
// Appwrite. Flip to "processing" so the user sees something is still
|
||||
// happening (large files take 30-60s to land in Storage).
|
||||
xhr.upload.addEventListener("load", () => {
|
||||
setPhase("processing");
|
||||
setProgress(100);
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
setUploading(false);
|
||||
let payload: { ok?: boolean; uploaded?: number; error?: string } = {};
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
@@ -113,18 +124,19 @@ function UploadForm({ jobId }: { jobId: string }) {
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(payload.error || `Yükleme başarısız (HTTP ${xhr.status}).`);
|
||||
setPhase("idle");
|
||||
setProgress(0);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", () => {
|
||||
setUploading(false);
|
||||
setPhase("idle");
|
||||
setProgress(0);
|
||||
toast.error("Ağ hatası. Tekrar deneyin.");
|
||||
});
|
||||
|
||||
xhr.addEventListener("abort", () => {
|
||||
setUploading(false);
|
||||
setPhase("idle");
|
||||
setProgress(0);
|
||||
toast.message("Yükleme iptal edildi.");
|
||||
});
|
||||
@@ -157,7 +169,7 @@ function UploadForm({ jobId }: { jobId: string }) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
disabled={busy}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
Dosya seç
|
||||
@@ -181,10 +193,15 @@ function UploadForm({ jobId }: { jobId: string }) {
|
||||
"Tarama (STL/OBJ), görsel veya PDF — max 200MB / dosya"
|
||||
)}
|
||||
</span>
|
||||
{uploading ? (
|
||||
{phase === "uploading" ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={cancelUpload}>
|
||||
İptal
|
||||
</Button>
|
||||
) : phase === "processing" ? (
|
||||
<Button type="button" size="sm" variant="outline" disabled>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
İşleniyor
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -197,7 +214,7 @@ function UploadForm({ jobId }: { jobId: string }) {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{uploading && (
|
||||
{phase === "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">
|
||||
@@ -205,6 +222,15 @@ function UploadForm({ jobId }: { jobId: string }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{phase === "processing" && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Progress value={100} className="flex-1" />
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Sunucu Appwrite'a yazıyor — büyük dosyalar 30-60 sn sürebilir
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user