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_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); // 0–100
|
const [progress, setProgress] = useState(0); // 0–100
|
||||||
|
|
||||||
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'a yazıyor — büyük dosyalar 30-60 sn sürebilir
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user