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)
149 lines
6.3 KiB
TypeScript
149 lines
6.3 KiB
TypeScript
import { useState } from "react";
|
|
import { PdfCanvas } from "./PdfCanvas";
|
|
import { X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
|
|
|
|
export const isImageFile = (name: string = "") => {
|
|
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
|
|
};
|
|
|
|
export const isPdfFile = (name: string = "") => {
|
|
return /\.pdf$/i.test(name.toLowerCase());
|
|
};
|
|
|
|
/**
|
|
* آیا مرورگر میتواند PDF را درونخطی نشان دهد؟
|
|
*
|
|
* دسکتاپ: بله (نمایشگر بومی) → <iframe> کافی است و بهترین تجربه را دارد.
|
|
* شبیهسازی موبایل/اندروید: پلاگین PDF وجود ندارد؛ <iframe> به دانلود
|
|
* منتهی میشود، پس آنجا pdf.js استفاده میکنیم.
|
|
*/
|
|
export function canRenderPdfNatively(): boolean {
|
|
if (typeof navigator === "undefined") return true;
|
|
const ua = navigator.userAgent || "";
|
|
const mobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
|
|
// pdfViewerEnabled در Chromium/Firefox وجود دارد و دقیقترین معیار است
|
|
const pdfViewer = (navigator as { pdfViewerEnabled?: boolean }).pdfViewerEnabled;
|
|
if (typeof pdfViewer === "boolean") return pdfViewer && !mobile;
|
|
return !mobile;
|
|
}
|
|
|
|
export interface FilePreviewModalProps {
|
|
// آدرس امضاشدهی کوتاهعمر — چون <img>/<iframe> نمیتوانند هدر توکن بفرستند
|
|
url: string;
|
|
name: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
/**
|
|
* مودال پیشنمایش فایل — مشترک بین لیست پیوستها (FileList) و کارت پیشنهاد
|
|
* در داشبورد. تصویر با <img> و PDF با pdf.js روی canvas رسم میشود (بدون
|
|
* وابستگی به پلاگین PDF مرورگر، که در شبیهسازی موبایل وجود ندارد)؛ سایر
|
|
* فرمتها فقط دکمهی دانلود دارند.
|
|
*/
|
|
export function FilePreviewModal({ url, name, onClose }: FilePreviewModalProps) {
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-3 sm:p-5 backdrop-blur-xs animate-in fade-in duration-200"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="flex flex-col w-full max-w-4xl max-h-[90vh] rounded-2xl border border-gray-200 bg-white shadow-2xl overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* هدر پیشنمایش */}
|
|
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 bg-white">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{isImageFile(name) ? (
|
|
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
|
|
) : (
|
|
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
|
|
)}
|
|
<span className="text-xs sm:text-sm font-bold text-gray-900 truncate">{name}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
|
|
title="باز کردن در برگه جدید"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
</a>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors ms-1"
|
|
title="بستن"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* بدنه نمایش فایل */}
|
|
<div className="flex-1 overflow-auto p-3 sm:p-4 bg-gray-50/70 flex items-center justify-center min-h-[300px]">
|
|
{isImageFile(name) ? (
|
|
<img
|
|
src={url}
|
|
alt={name}
|
|
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
|
|
/>
|
|
) : isPdfFile(name) ? (
|
|
// روی دسکتاپ نمایشگر بومی مرورگر بهترین نتیجه را میدهد؛ در
|
|
// شبیهسازی موبایل پلاگین PDF وجود ندارد، پس آنجا pdf.js روی
|
|
// canvas رسم میکند. انتخاب در زمان اجرا با تشخیص قابلیت.
|
|
canRenderPdfNatively() ? (
|
|
<iframe
|
|
src={url}
|
|
title={name}
|
|
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
|
|
/>
|
|
) : (
|
|
<PdfCanvas url={url} className="w-full" />
|
|
)
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center bg-white rounded-xl border border-gray-200 shadow-xs">
|
|
<FileText className="h-12 w-12 text-gray-400" />
|
|
<div className="text-xs text-gray-600">
|
|
امکان نمایش پیشنمایش زنده برای این فرمت فایل وجود ندارد.
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* هوک مدیریت پیشنمایش: بارگذاری blob احراز هویتشده + آزادسازی objectURL.
|
|
* خروجی برای رندر <FilePreviewModal> آماده است.
|
|
*/
|
|
/**
|
|
* هوک مدیریت پیشنمایش: گرفتن URL امضاشده و نگهداشتن آن برای مودال.
|
|
* URL یک آدرس واقعی است (نه blob) تا دانلودمنیجرها آن را نشکنند؛ پس
|
|
* نیازی به آزادسازی objectURL نیست.
|
|
*/
|
|
export function useFilePreview(load: (id: string, name: string) => Promise<string>) {
|
|
const [item, setItem] = useState<{ url: string; name: string } | null>(null);
|
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
|
|
|
const open = async (id: string, name: string) => {
|
|
if (!id) return;
|
|
setLoadingId(id);
|
|
try {
|
|
const url = await load(id, name);
|
|
setItem({ url, name });
|
|
} finally {
|
|
setLoadingId(null);
|
|
}
|
|
};
|
|
|
|
const close = () => setItem(null);
|
|
|
|
return { item, loadingId, open, close };
|
|
}
|