Downloads require an Authorization header, which <iframe>/<img> cannot
send. The previous blob: workaround broke under download managers (IDM
hooks blob URLs, fails to resolve them, and leaves a 0-byte file) and in
Chrome's mobile emulation, which has no inline PDF plugin.
Backend:
- preview_token.py: HMAC-signed token bound to one (quote, kind, file)
with a 5-minute TTL, keyed off an existing secret
- GET /quotes/{id}/preview-url/{kind}/{attachment} mints the URL
- the two file GET routes accept a Bearer session OR the signed ?t= token
(they leave the global auth dep, so the other six file routes now carry
it explicitly — nothing is open by omission)
Frontend:
- api.files.previewUrl() replaces the blob helper
- shared FilePreviewModal + useFilePreview; FileList and the dashboard
quote card both use it
- PDFs render with the browser's native viewer on desktop and pdf.js on
canvas where no inline viewer exists (mobile); pdf.js is fed the bytes
in memory so no PDF URL is exposed for a download manager to intercept
- removed the per-attachment download buttons on the dashboard card and
the modal header
Ops:
- nginx: explicit MIME types (.mjs as text/javascript) — a module worker
served as octet-stream is refused and crashed the page
- frontend service bypasses the nginx entrypoint, whose apk manifest call
hangs when the package mirror is unreachable (502s)
185 lines
7.7 KiB
TypeScript
185 lines
7.7 KiB
TypeScript
import { useRef, useState } from "react";
|
||
import { toast } from "sonner";
|
||
import { Eye, Download, Trash2, FileText, Image as ImageIcon } from "lucide-react";
|
||
import type { Attachment } from "../lib/types";
|
||
import { FilePreviewModal, isImageFile, isPdfFile } from "./FilePreviewModal";
|
||
|
||
export interface FileListProps {
|
||
files: Attachment[];
|
||
onChange: (files: Attachment[]) => void;
|
||
// آپلود واقعی هر فایل به سرور (باید Attachment با id برگرداند)
|
||
upload: (file: File) => Promise<Attachment>;
|
||
// حذف سمت سرور (اختیاری — بعد از حذف محلی صدا زده میشود)
|
||
onDelete?: (a: Attachment) => void;
|
||
// دانلود احراز هویتشده (توکن Bearer + blob — لینک خام توکن نمیفرستد)
|
||
onDownload?: (a: Attachment) => void | Promise<void>;
|
||
// آدرس blob احراز هویتشده برای پیشنمایش. مودال پیشنمایش با objectURL
|
||
// محلی کار میکند چون <img>/<iframe> نمیتوانند توکن بفرستند.
|
||
loadPreview?: (a: Attachment) => Promise<string>;
|
||
label?: string;
|
||
}
|
||
|
||
// مدیریت چند فاکتور: آپلود مستقیم، پیشنمایش آنلاین تصویر/PDF و دانلود
|
||
export function FileList({
|
||
files,
|
||
onChange,
|
||
upload,
|
||
onDelete,
|
||
onDownload,
|
||
loadPreview,
|
||
label = "افزودن فاکتور",
|
||
}: FileListProps) {
|
||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
|
||
const [loadingPreview, setLoadingPreview] = useState<string | null>(null);
|
||
|
||
const remove = (i: number) => {
|
||
const removed = files[i];
|
||
onChange(files.filter((_, idx) => idx !== i));
|
||
if (removed) onDelete?.(removed);
|
||
};
|
||
|
||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const picked = Array.from(e.target.files || []);
|
||
e.target.value = ""; // اجازهی انتخاب دوبارهی همان فایل
|
||
if (!picked.length) return;
|
||
const added: Attachment[] = [];
|
||
for (const file of picked) {
|
||
try {
|
||
added.push(await upload(file));
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : `آپلود «${file.name}» ناموفق بود`);
|
||
}
|
||
}
|
||
if (added.length) onChange([...files, ...added]);
|
||
};
|
||
|
||
// پیشنمایش: یک URL امضاشدهی کوتاهعمر میگیریم (نه blob) تا
|
||
// دانلودمنیجرها آن را شکار نکنند و <iframe>/<img> درست بارگذاری شود.
|
||
const handleOpenPreview = async (inv: Attachment) => {
|
||
if (!loadPreview) return;
|
||
if (!inv.id) {
|
||
toast.error("فایل هنوز به طور کامل در سرور ذخیره نشده است");
|
||
return;
|
||
}
|
||
setLoadingPreview(inv.id);
|
||
try {
|
||
const url = await loadPreview(inv);
|
||
setPreviewItem({ url, name: inv.name });
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "بارگذاری پیشنمایش ناموفق بود");
|
||
} finally {
|
||
setLoadingPreview(null);
|
||
}
|
||
};
|
||
|
||
const closePreview = () => setPreviewItem(null);
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{!files.length && (
|
||
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
|
||
هنوز فاکتور یا سندی بارگذاری نشده است.
|
||
</div>
|
||
)}
|
||
|
||
{files.length > 0 && (
|
||
<div className="flex flex-col gap-1.5">
|
||
{files.map((inv, i) => {
|
||
const canPreview = !!loadPreview && !!inv.id;
|
||
const isLoading = !!inv.id && loadingPreview === inv.id;
|
||
|
||
return (
|
||
<div
|
||
key={inv.id || i}
|
||
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs transition-colors hover:border-gray-300 shadow-xs"
|
||
>
|
||
{/* نام و آیکون فایل — با قابلیت کلیک برای پیشنمایش */}
|
||
<div
|
||
className={`flex items-center gap-2 min-w-0 flex-1 ${canPreview ? "cursor-pointer group" : ""}`}
|
||
onClick={() => canPreview && void handleOpenPreview(inv)}
|
||
title={canPreview ? "مشاهده پیشنمایش" : inv.name}
|
||
>
|
||
{isImageFile(inv.name) ? (
|
||
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
|
||
) : isPdfFile(inv.name) ? (
|
||
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
|
||
) : (
|
||
<FileText className="h-4 w-4 shrink-0 text-gray-400" />
|
||
)}
|
||
<span className="truncate font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
|
||
{inv.name}
|
||
</span>
|
||
</div>
|
||
|
||
{/* دکمههای عملیات: پیشنمایش، دانلود، حذف */}
|
||
<div className="flex items-center gap-1 shrink-0 me-1">
|
||
{canPreview && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleOpenPreview(inv)}
|
||
disabled={isLoading}
|
||
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-blue-50 hover:text-blue-600 transition-colors disabled:opacity-50"
|
||
title="مشاهده فاکتور"
|
||
>
|
||
<Eye className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
|
||
{onDownload && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
Promise.resolve(onDownload(inv)).catch((err) =>
|
||
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
|
||
);
|
||
}}
|
||
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
|
||
title="دانلود فایل"
|
||
>
|
||
<Download className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
||
title="حذف فایل"
|
||
onClick={() => remove(i)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept="image/*,.pdf,.png,.jpg,.jpeg"
|
||
multiple
|
||
className="hidden"
|
||
onChange={handleChange}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
|
||
onClick={() => inputRef.current?.click()}
|
||
>
|
||
<span>+</span>
|
||
<span>{label}</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* مودال پیشنمایش — کامپوننت مشترک با کارت پیشنهاد در داشبورد */}
|
||
{previewItem && (
|
||
<FilePreviewModal url={previewItem.url} name={previewItem.name} onClose={closePreview} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|