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 &&

e.stopPropagation()} />}
+ {fileIsVideo ? (
+
e.stopPropagation()}>
+
+
+ ) : (
+ imgSrc &&

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,