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:
kovakmedya
2026-05-21 21:38:54 +03:00
parent 7c777a5b27
commit 5fbc0a3c95
@@ -64,23 +64,26 @@ export function JobFilesPanel({
const MAX_FILE_BYTES = 200 * 1024 * 1024; const MAX_FILE_BYTES = 200 * 1024 * 1024;
const MAX_BATCH_BYTES = 200 * 1024 * 1024; // proxy cap; one large file fills the whole batch 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 }) { function UploadForm({ jobId }: { jobId: string }) {
const router = useRouter(); const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const xhrRef = useRef<XMLHttpRequest | null>(null); const xhrRef = useRef<XMLHttpRequest | null>(null);
const [selected, setSelected] = useState<File[]>([]); const [selected, setSelected] = useState<File[]>([]);
const [uploading, setUploading] = useState(false); const [phase, setPhase] = useState<UploadPhase>("idle");
const [progress, setProgress] = useState(0); // 0100 const [progress, setProgress] = useState(0); // 0100
const totalBytes = selected.reduce((s, f) => s + f.size, 0); const totalBytes = selected.reduce((s, f) => s + f.size, 0);
const overSize = selected.find((f) => f.size > MAX_FILE_BYTES); const overSize = selected.find((f) => f.size > MAX_FILE_BYTES);
const overBatch = totalBytes > MAX_BATCH_BYTES; const overBatch = totalBytes > MAX_BATCH_BYTES;
const blocked = Boolean(overSize) || overBatch; const blocked = Boolean(overSize) || overBatch;
const busy = phase !== "idle";
function reset() { function reset() {
setSelected([]); setSelected([]);
setProgress(0); setProgress(0);
setUploading(false); setPhase("idle");
if (inputRef.current) inputRef.current.value = ""; if (inputRef.current) inputRef.current.value = "";
} }
@@ -91,16 +94,24 @@ function UploadForm({ jobId }: { jobId: string }) {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhrRef.current = xhr; xhrRef.current = xhr;
setUploading(true); setPhase("uploading");
setProgress(0); setProgress(0);
xhr.upload.addEventListener("progress", (e) => { xhr.upload.addEventListener("progress", (e) => {
if (!e.lengthComputable) return; 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", () => { xhr.addEventListener("load", () => {
setUploading(false);
let payload: { ok?: boolean; uploaded?: number; error?: string } = {}; let payload: { ok?: boolean; uploaded?: number; error?: string } = {};
try { try {
payload = JSON.parse(xhr.responseText); payload = JSON.parse(xhr.responseText);
@@ -113,18 +124,19 @@ function UploadForm({ jobId }: { jobId: string }) {
router.refresh(); router.refresh();
} else { } else {
toast.error(payload.error || `Yükleme başarısız (HTTP ${xhr.status}).`); toast.error(payload.error || `Yükleme başarısız (HTTP ${xhr.status}).`);
setPhase("idle");
setProgress(0); setProgress(0);
} }
}); });
xhr.addEventListener("error", () => { xhr.addEventListener("error", () => {
setUploading(false); setPhase("idle");
setProgress(0); setProgress(0);
toast.error("Ağ hatası. Tekrar deneyin."); toast.error("Ağ hatası. Tekrar deneyin.");
}); });
xhr.addEventListener("abort", () => { xhr.addEventListener("abort", () => {
setUploading(false); setPhase("idle");
setProgress(0); setProgress(0);
toast.message("Yükleme iptal edildi."); toast.message("Yükleme iptal edildi.");
}); });
@@ -157,7 +169,7 @@ function UploadForm({ jobId }: { jobId: string }) {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
disabled={uploading} disabled={busy}
> >
<Upload className="size-4" /> <Upload className="size-4" />
Dosya seç Dosya seç
@@ -181,10 +193,15 @@ function UploadForm({ jobId }: { jobId: string }) {
"Tarama (STL/OBJ), görsel veya PDF — max 200MB / dosya" "Tarama (STL/OBJ), görsel veya PDF — max 200MB / dosya"
)} )}
</span> </span>
{uploading ? ( {phase === "uploading" ? (
<Button type="button" size="sm" variant="outline" onClick={cancelUpload}> <Button type="button" size="sm" variant="outline" onClick={cancelUpload}>
İptal İptal
</Button> </Button>
) : phase === "processing" ? (
<Button type="button" size="sm" variant="outline" disabled>
<Loader2 className="size-4 animate-spin" />
İşleniyor
</Button>
) : ( ) : (
<Button <Button
type="button" type="button"
@@ -197,7 +214,7 @@ function UploadForm({ jobId }: { jobId: string }) {
</Button> </Button>
)} )}
</div> </div>
{uploading && ( {phase === "uploading" && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Progress value={progress} className="flex-1" /> <Progress value={progress} className="flex-1" />
<span className="text-muted-foreground min-w-[3rem] text-right text-xs tabular-nums"> <span className="text-muted-foreground min-w-[3rem] text-right text-xs tabular-nums">
@@ -205,6 +222,15 @@ function UploadForm({ jobId }: { jobId: string }) {
</span> </span>
</div> </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&apos;a yazıyor büyük dosyalar 30-60 sn sürebilir
</span>
</div>
)}
</div> </div>
); );
} }