mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
feat(video): upload and play videos in the trip file manager
The file manager (which already attaches files to a place/activity) now accepts video uploads up to the larger video cap — other types stay at the document limit — and the lightbox plays them with the Plyr player over the plain same-origin download URL, so cookie auth and HTTP Range both work. Videos are excluded from the offline blob prefetch so one clip can't evict a trip's documents.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
|
||||
{/* Lightbox */}
|
||||
{lightboxIndex !== null && <ImageLightbox files={imageFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||
{lightboxIndex !== null && <ImageLightbox files={mediaFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
|
||||
|
||||
{/* Assign modal */}
|
||||
{assignFileId && <AssignModal {...S} />}
|
||||
|
||||
@@ -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<number | null>(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
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', minHeight: 0 }}
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
{navBtn('left', goPrev, hasPrev)}
|
||||
{imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />}
|
||||
{fileIsVideo ? (
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<VideoPlayer src={file.url} style={{ maxWidth: '85vw', maxHeight: '80vh', borderRadius: 8 }} />
|
||||
</div>
|
||||
) : (
|
||||
imgSrc && <img src={imgSrc} alt={file.original_name} style={{ maxWidth: '85vw', maxHeight: '80vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} onClick={e => e.stopPropagation()} />
|
||||
)}
|
||||
{navBtn('right', goNext, hasNext)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
/** Cache non-photo file blobs for a trip. Fire-and-forget safe. */
|
||||
async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user