Files
iistwin/client/src/components/FileViewerModal.tsx
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

244 lines
9.4 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.

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 };