Files
lab/src/app/(dashboard)/jobs/[jobId]/components/job-files-panel.tsx
T
kovakmedya cdb2a15643 fix(ui): router.refresh after server actions so status updates show without reload
Lab side reported that after accepting a job / advancing a step the
button kept its 'Yükleniyor' state and the page didn't reflect the new
status until they hit refresh. Two issues stacked on top of each other:

1. The button forms were passing the action through an extra
   startTransition wrap — 'action={(fd) => startTransition(() => action(fd))}'.
   With React 19 + useActionState this is unnecessary; useActionState
   already manages its own transition. The double transition can leave
   the dispatch's pending flag wedged in some race orderings, which
   matches what the user saw.

2. revalidatePath() on the server invalidates the RSC cache but does not
   trigger client navigation. So even after the action returned, the
   page kept rendering the stale Job snapshot — and since the buttons
   are conditional on job.status, the now-stale 'pending' status meant
   the button stayed visible.

Fix in JobActionsPanel and the four sibling components (connections
delete row, pending inbound, pending outbound, file row delete):
  - Removed the startTransition wrap; forms point at 'action' directly.
  - Added useRouter() and call router.refresh() in the same useEffect
    branch where the success toast fires. This forces the Server
    Component tree to re-fetch, picks up the new job.status, and the
    actions panel rerenders into whatever button is next in the flow.
  - Cleaned the now-unused useTransition imports.

Net effect: tap 'İşleme Al' → spinner appears, ~400ms later the toast
hits and the row updates in place to 'Sonraki Aşama' without any
manual refresh.
2026-05-22 01:15:32 +03:00

336 lines
11 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 } 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
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 [phase, setPhase] = useState<UploadPhase>("idle");
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;
const busy = phase !== "idle";
function reset() {
setSelected([]);
setProgress(0);
setPhase("idle");
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;
setPhase("uploading");
setProgress(0);
xhr.upload.addEventListener("progress", (e) => {
if (!e.lengthComputable) return;
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", () => {
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}).`);
setPhase("idle");
setProgress(0);
}
});
xhr.addEventListener("error", () => {
setPhase("idle");
setProgress(0);
toast.error("Ağ hatası. Tekrar deneyin.");
});
xhr.addEventListener("abort", () => {
setPhase("idle");
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={busy}
>
<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>
{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"
size="sm"
onClick={startUpload}
disabled={selected.length === 0 || blocked}
>
<Upload className="size-4" />
Yükle
</Button>
)}
</div>
{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">
{progress}%
</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&apos;a yazıyor büyük dosyalar 30-60 sn sürebilir
</span>
</div>
)}
</div>
);
}
function FileRow({ file }: { file: JobFileWithUrl }) {
const [state, action, pending] = useActionState(
deleteJobFileAction,
initialJobFileActionState,
);
const router = useRouter();
const [open, setOpen] = useState(false);
const [downloadOpen, setDownloadOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Dosya silindi.");
setOpen(false);
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
function triggerDownload() {
// Use a programmatic anchor click — the server route streams the file
// with Content-Disposition: attachment, so the browser hands it straight
// to the download manager. Toast confirms it left our side.
const a = document.createElement("a");
a.href = file.url;
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
setDownloadOpen(false);
toast.success("İndirme başladı.", { description: file.name });
}
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>
<Dialog open={downloadOpen} onOpenChange={setDownloadOpen}>
<Button size="sm" variant="outline" onClick={() => setDownloadOpen(true)}>
<Download className="size-4" />
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>Dosya indirilsin mi?</DialogTitle>
<DialogDescription>
<span className="font-medium">{file.name}</span>
<span className="text-muted-foreground"> · {formatSize(file.size)}</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="button" onClick={triggerDownload}>
<Download className="size-4" />
İndir
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<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={action}>
<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>
);
}