diff --git a/client/src/components/Files/FileManager.helpers.ts b/client/src/components/Files/FileManager.helpers.ts index d238045e..2d9b91b6 100644 --- a/client/src/components/Files/FileManager.helpers.ts +++ b/client/src/components/Files/FileManager.helpers.ts @@ -1,4 +1,4 @@ -import { FileText, FileImage, File, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react' +import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react' import { downloadFile } from '../../utils/fileDownload' export function isImage(mimeType?: string | null) { @@ -6,9 +6,19 @@ export function isImage(mimeType?: string | null) { return mimeType.startsWith('image/') } +export function isVideo(mimeType?: string | null) { + return !!mimeType && mimeType.startsWith('video/') +} + +/** Image or video — the file types that open in the media lightbox (#823). */ +export function isMedia(mimeType?: string | null) { + return isImage(mimeType) || isVideo(mimeType) +} + export function getFileIcon(mimeType?: string | null) { if (!mimeType) return File if (mimeType === 'application/pdf') return FileText + if (isVideo(mimeType)) return FileVideo if (isImage(mimeType)) return FileImage return File } diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 2c6cf556..10f142b0 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -8,11 +8,11 @@ import { FilesView } from './FileManagerFilesView' export default function FileManager(props: FileManagerProps) { const S = useFileManager(props) - const { lightboxIndex, setLightboxIndex, imageFiles, assignFileId, previewFile, handlePaste, showTrash } = S + const { lightboxIndex, setLightboxIndex, mediaFiles, assignFileId, previewFile, handlePaste, showTrash } = S return (
{/* Lightbox */} - {lightboxIndex !== null && setLightboxIndex(null)} />} + {lightboxIndex !== null && setLightboxIndex(null)} />} {/* Assign modal */} {assignFileId && } diff --git a/client/src/components/Files/FileManagerImageLightbox.tsx b/client/src/components/Files/FileManagerImageLightbox.tsx index c9d7f698..f349bf33 100644 --- a/client/src/components/Files/FileManagerImageLightbox.tsx +++ b/client/src/components/Files/FileManagerImageLightbox.tsx @@ -4,7 +4,8 @@ import { useTranslation } from '../../i18n' import type { TripFile } from '../../types' import { getAuthUrl } from '../../api/authUrl' import { openFile as openFileUrl } from '../../utils/fileDownload' -import { triggerDownload } from './FileManager.helpers' +import { triggerDownload, isVideo } from './FileManager.helpers' +import VideoPlayer from '../Journey/VideoPlayer' // Image lightbox with gallery navigation interface ImageLightboxProps { @@ -20,10 +21,14 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro const [touchStart, setTouchStart] = useState(null) const file = files[index] + const fileIsVideo = isVideo(file?.mime_type) + useEffect(() => { setImgSrc('') - if (file) getAuthUrl(file.url, 'download').then(setImgSrc) - }, [file?.url]) + // Images use a one-shot signed URL; a video must use the plain same-origin + // URL (cookie auth) so its many Range requests all authenticate (#823). + if (file && !isVideo(file.mime_type)) getAuthUrl(file.url, 'download').then(setImgSrc) + }, [file?.url, file?.mime_type]) const goPrev = () => setIndex(i => Math.max(0, i - 1)) const goNext = () => setIndex(i => Math.min(files.length - 1, i + 1)) @@ -98,7 +103,13 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
{ if (e.target === e.currentTarget) onClose() }}> {navBtn('left', goPrev, hasPrev)} - {imgSrc && {file.original_name} e.stopPropagation()} />} + {fileIsVideo ? ( +
e.stopPropagation()}> + +
+ ) : ( + imgSrc && {file.original_name} e.stopPropagation()} /> + )} {navBtn('right', goNext, hasNext)}
diff --git a/client/src/components/Files/useFileManager.ts b/client/src/components/Files/useFileManager.ts index ab415361..400c142c 100644 --- a/client/src/components/Files/useFileManager.ts +++ b/client/src/components/Files/useFileManager.ts @@ -7,7 +7,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { getAuthUrl } from '../../api/authUrl' -import { isImage } from './FileManager.helpers' +import { isImage, isMedia } from './FileManager.helpers' export interface FileManagerProps { files?: TripFile[] @@ -184,11 +184,12 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place } } - const imageFiles = filteredFiles.filter(f => isImage(f.mime_type)) + // Image OR video — both open in the lightbox; videos play there (#823). + const mediaFiles = filteredFiles.filter(f => isMedia(f.mime_type)) const openFile = (file) => { - if (isImage(file.mime_type)) { - const idx = imageFiles.findIndex(f => f.id === file.id) + if (isMedia(file.mime_type)) { + const idx = mediaFiles.findIndex(f => f.id === file.id) setLightboxIndex(idx >= 0 ? idx : 0) } else { setPreviewFile(file) @@ -202,7 +203,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place toggleTrash, refreshFiles, handleStar, handleRestore, handlePermanentDelete, handleEmptyTrash, previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId, getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete, - handleAssign, imageFiles, openFile, + handleAssign, mediaFiles, openFile, } } diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index f14b4b1d..5a9c6d71 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -72,6 +72,12 @@ function isPhoto(file: TripFile): boolean { return file.mime_type.startsWith('image/') } +// Videos can be hundreds of MB — never prefetch them into the bounded offline +// blob cache, or a single clip would evict the trip's real documents (#823). +function isVideo(file: TripFile): boolean { + return file.mime_type.startsWith('video/') +} + // ── Core logic ──────────────────────────────────────────────────────────────── /** Fetch bundle + write all entities for one trip into Dexie. */ @@ -99,7 +105,7 @@ async function syncTrip(tripId: number): Promise { /** Cache non-photo file blobs for a trip. Fire-and-forget safe. */ async function cacheFilesForTrip(files: TripFile[]): Promise { - const nonPhotos = files.filter(f => f.url && !isPhoto(f)) + const nonPhotos = files.filter(f => f.url && !isPhoto(f) && !isVideo(f)) let cached = 0 for (const file of nonPhotos) { diff --git a/server/src/nest/files/files.controller.ts b/server/src/nest/files/files.controller.ts index 50a83ba1..922dbb4f 100644 --- a/server/src/nest/files/files.controller.ts +++ b/server/src/nest/files/files.controller.ts @@ -24,7 +24,7 @@ import type { User } from '../../types'; import { FilesService } from './files.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { MAX_FILE_SIZE, BLOCKED_EXTENSIONS, filesDir, getAllowedExtensions } from '../../services/fileService'; +import { MAX_FILE_SIZE, MAX_VIDEO_SIZE, BLOCKED_EXTENSIONS, filesDir, getAllowedExtensions, isVideoExtension, isVideoMime } from '../../services/fileService'; import { isDemoEmail } from '../../services/demo'; const UPLOAD = { @@ -32,7 +32,9 @@ const UPLOAD = { destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir); }, filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`), }), - limits: { fileSize: MAX_FILE_SIZE }, + // Allow up to the video cap; non-video files are still held to MAX_FILE_SIZE by + // the per-type guard in the upload handler (#823). + limits: { fileSize: MAX_VIDEO_SIZE }, defParamCharset: 'utf8', // parity with legacy routes/files.ts — preserve non-ASCII original filenames fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { const ext = path.extname(file.originalname).toLowerCase(); @@ -44,7 +46,8 @@ const UPLOAD = { if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject(); const allowed = getAllowedExtensions().split(',').map((e) => e.trim().toLowerCase()); const fileExt = ext.replace('.', ''); - if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true); + // Video is accepted as media regardless of the admin doc-types allowlist (#823). + if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true); reject(); }, }; @@ -106,6 +109,11 @@ export class FilesController { if (!file) { throw new HttpException({ error: 'No file uploaded' }, 400); } + // Only video may use the larger cap; other types stay at MAX_FILE_SIZE (#823). + if (!isVideoMime(file.mimetype) && file.size > MAX_FILE_SIZE) { + try { fs.unlinkSync(file.path); } catch { /* best-effort cleanup */ } + throw new HttpException({ error: 'File is too large' }, 400); + } this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id }); const created = this.files.createFile(tripId, file, user.id, { place_id: body.place_id,