- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
244 lines
9.4 KiB
TypeScript
244 lines
9.4 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Download, X, AlertCircle, FileText, Film, Music, Loader2, Image } from 'lucide-react';
|
||
import { apiRequest } from '@/lib/queryClient';
|
||
import { addFileToken, extractFileKey } from '@/lib/fileUtils';
|
||
|
||
interface FileViewerModalProps {
|
||
url: string;
|
||
name: string;
|
||
mimeType?: string;
|
||
onClose: () => void;
|
||
}
|
||
|
||
type FileViewerType = 'pdf' | 'word' | 'excel' | 'powerpoint' | 'video' | 'audio' | 'image' | 'other';
|
||
|
||
const WORD_VIEWER_TIMEOUT_MS = 15000;
|
||
|
||
function getExtFromPath(path: string): string {
|
||
try {
|
||
const pathname = new URL(path, 'http://x').pathname;
|
||
return pathname.split('.').pop()?.toLowerCase() ?? '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function detectViewerType(url: string, name: string, mimeType?: string): FileViewerType {
|
||
if (mimeType === 'application/pdf') return 'pdf';
|
||
if (
|
||
mimeType === 'application/msword' ||
|
||
mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||
) return 'word';
|
||
if (
|
||
mimeType === 'application/vnd.ms-excel' ||
|
||
mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
) return 'excel';
|
||
if (
|
||
mimeType === 'application/vnd.ms-powerpoint' ||
|
||
mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||
) return 'powerpoint';
|
||
if (mimeType?.startsWith('video/')) return 'video';
|
||
if (mimeType?.startsWith('audio/')) return 'audio';
|
||
|
||
const extFromName = name.split('.').pop()?.toLowerCase() ?? '';
|
||
const ext = extFromName || getExtFromPath(url);
|
||
|
||
if (ext === 'pdf') return 'pdf';
|
||
if (ext === 'doc' || ext === 'docx') return 'word';
|
||
if (ext === 'xls' || ext === 'xlsx') return 'excel';
|
||
if (ext === 'ppt' || ext === 'pptx') return 'powerpoint';
|
||
if (['mp4', 'webm', 'mov', 'avi', 'mkv', 'ogv'].includes(ext)) return 'video';
|
||
if (['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(ext)) return 'audio';
|
||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image';
|
||
return 'other';
|
||
}
|
||
|
||
const GOOGLE_DOCS_VIEWER_TYPES: FileViewerType[] = ['pdf', 'word', 'excel', 'powerpoint'];
|
||
|
||
function getFileIcon(viewerType: FileViewerType) {
|
||
if (viewerType === 'video') return <Film className="w-4 h-4 shrink-0 text-muted-foreground" />;
|
||
if (viewerType === 'audio') return <Music className="w-4 h-4 shrink-0 text-muted-foreground" />;
|
||
if (viewerType === 'image') return <Image className="w-4 h-4 shrink-0 text-muted-foreground" />;
|
||
return <FileText className="w-4 h-4 shrink-0 text-muted-foreground" />;
|
||
}
|
||
|
||
export function FileViewerModal({ url, name, mimeType, onClose }: FileViewerModalProps) {
|
||
const viewerType = detectViewerType(url, name, mimeType);
|
||
const [viewerError, setViewerError] = useState(false);
|
||
const [presignedUrl, setPresignedUrl] = useState<string | null>(null);
|
||
const [presignedLoading, setPresignedLoading] = useState(false);
|
||
const [presignedError, setPresignedError] = useState(false);
|
||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const authedUrl = addFileToken(url);
|
||
|
||
// Request a presigned URL for local files so that external viewers (Google Docs, browser PDF)
|
||
// can access the file without JWT auth.
|
||
useEffect(() => {
|
||
const fileKey = extractFileKey(url);
|
||
if (!fileKey) {
|
||
// External URL — use as-is
|
||
setPresignedUrl(url);
|
||
return;
|
||
}
|
||
|
||
setPresignedLoading(true);
|
||
setPresignedError(false);
|
||
|
||
apiRequest('GET', `/api/files/${fileKey}/presigned`)
|
||
.then(async (res) => {
|
||
const data = await res.json();
|
||
// Build absolute URL for third-party viewers
|
||
const absolute = data.presignedUrl.startsWith('http')
|
||
? data.presignedUrl
|
||
: `${window.location.origin}${data.presignedUrl}`;
|
||
setPresignedUrl(absolute);
|
||
})
|
||
.catch(() => {
|
||
setPresignedError(true);
|
||
})
|
||
.finally(() => {
|
||
setPresignedLoading(false);
|
||
});
|
||
}, [url]);
|
||
|
||
// Google Docs viewer works with any URL that is publicly reachable.
|
||
// Presigned URLs are temporary public links (no JWT required).
|
||
const usesGoogleDocs = GOOGLE_DOCS_VIEWER_TYPES.includes(viewerType) && !!presignedUrl;
|
||
const googleDocsUrl = presignedUrl
|
||
? `https://docs.google.com/gview?url=${encodeURIComponent(presignedUrl)}&embedded=true`
|
||
: '';
|
||
|
||
useEffect(() => {
|
||
if (usesGoogleDocs && !viewerError) {
|
||
timeoutRef.current = setTimeout(() => {
|
||
setViewerError(true);
|
||
}, WORD_VIEWER_TIMEOUT_MS);
|
||
}
|
||
return () => {
|
||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||
};
|
||
}, [usesGoogleDocs, viewerError]);
|
||
|
||
const handleGoogleDocsLoad = () => {
|
||
if (timeoutRef.current) {
|
||
clearTimeout(timeoutRef.current);
|
||
timeoutRef.current = null;
|
||
}
|
||
};
|
||
|
||
const isMediaType = viewerType === 'video' || viewerType === 'audio';
|
||
|
||
return (
|
||
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||
<DialogContent className={`w-full flex flex-col p-0 gap-0 ${isMediaType ? 'max-w-2xl' : 'max-w-5xl h-[90vh]'}`}>
|
||
<DialogHeader className="flex flex-row items-center justify-between px-4 py-3 border-b shrink-0">
|
||
<div className="flex items-center gap-2 min-w-0 mr-4">
|
||
{getFileIcon(viewerType)}
|
||
<DialogTitle className="text-sm font-medium truncate">{name}</DialogTitle>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Button variant="outline" size="sm" asChild>
|
||
<a href={authedUrl} download={name} target="_blank" rel="noopener noreferrer">
|
||
<Download className="w-4 h-4 mr-1.5" />
|
||
Скачать
|
||
</a>
|
||
</Button>
|
||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||
<X className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</DialogHeader>
|
||
|
||
<div className={`${isMediaType ? 'p-4' : 'flex-1 overflow-hidden'}`}>
|
||
{presignedLoading && (
|
||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
|
||
<Loader2 className="w-8 h-8 animate-spin" />
|
||
<p className="text-sm">Подготовка предпросмотра…</p>
|
||
</div>
|
||
)}
|
||
|
||
{!presignedLoading && presignedError && (
|
||
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground">
|
||
<AlertCircle className="w-10 h-10" />
|
||
<p className="text-sm text-center">
|
||
Не удалось получить доступ к файлу.<br />
|
||
Возможно, ссылка истекла или доступ запрещён.
|
||
</p>
|
||
<Button variant="outline" asChild>
|
||
<a href={authedUrl} download={name}>
|
||
<Download className="w-4 h-4 mr-1.5" />
|
||
Скачать файл
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{!presignedLoading && !presignedError && usesGoogleDocs && !viewerError && (
|
||
<iframe
|
||
src={googleDocsUrl}
|
||
title={name}
|
||
className="w-full h-full border-0"
|
||
onLoad={handleGoogleDocsLoad}
|
||
onError={() => setViewerError(true)}
|
||
/>
|
||
)}
|
||
|
||
{!presignedLoading && !presignedError && usesGoogleDocs && viewerError && (
|
||
<div className="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground">
|
||
<AlertCircle className="w-10 h-10" />
|
||
<p className="text-sm text-center">
|
||
Не удалось загрузить предпросмотр документа.<br />
|
||
Возможно, файл недоступен через Google Docs Viewer.
|
||
</p>
|
||
<Button variant="outline" asChild>
|
||
<a href={authedUrl} download={name}>
|
||
<Download className="w-4 h-4 mr-1.5" />
|
||
Скачать файл
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{!presignedLoading && !presignedError && viewerType === 'video' && (
|
||
<video
|
||
src={authedUrl}
|
||
controls
|
||
className="w-full rounded-md"
|
||
style={{ maxHeight: '70vh' }}
|
||
>
|
||
Ваш браузер не поддерживает воспроизведение видео.
|
||
</video>
|
||
)}
|
||
|
||
{!presignedLoading && !presignedError && viewerType === 'audio' && (
|
||
<audio
|
||
src={authedUrl}
|
||
controls
|
||
className="w-full"
|
||
>
|
||
Ваш браузер не поддерживает воспроизведение аудио.
|
||
</audio>
|
||
)}
|
||
|
||
{!presignedLoading && !presignedError && viewerType === 'image' && (
|
||
<div className="flex items-center justify-center h-full p-4 bg-black/5">
|
||
<img
|
||
src={presignedUrl || authedUrl}
|
||
alt={name}
|
||
className="max-w-full max-h-full object-contain rounded-md"
|
||
onError={() => setViewerError(true)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export { detectViewerType };
|
||
export type { FileViewerType };
|