// frontend/src/Uploader.tsx import React from 'react' import { api, WhoAmI } from './api' import * as tus from 'tus-js-client' type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number } type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string } console.log('[Pegasus] FE bundle activated at', new Date().toISOString()) // ---------- helpers ---------- function sanitizeDesc(s: string) { s = s.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]+/g, '_') if (!s) s = 'upload' return s.slice(0, 64) } function sanitizeFolderName(s: string) { // 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-] s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '') s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_') return s.slice(0, 64) } function extOf(n: string) { const i = n.lastIndexOf('.') return i > -1 ? n.slice(i + 1).toLowerCase() : '' } function stemOf(n: string) { const i = n.lastIndexOf('.') const stem = i > -1 ? n.slice(0, i) : n // keep safe charset and avoid extra dots within segments return sanitizeDesc(stem.replace(/\./g, '_')) || 'file' } function composeName(date: string, desc: string, orig: string) { const d = date || new Date().toISOString().slice(0, 10) const [Y, M, D] = d.split('-') const sDesc = sanitizeDesc(desc) const sStem = stemOf(orig) const ext = extOf(orig) || 'bin' // New pattern: YYYY.MM.DD.Description.OriginalStem.ext return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}` } function clampOneLevel(p: string) { if (!p) return '' return p.replace(/^\/+|\/+$/g, '').split('/')[0] || '' } function normalizeRows(listRaw: any[]): FileRow[] { return (Array.isArray(listRaw) ? listRaw : []).map((r: any) => ({ name: r?.name ?? r?.Name ?? '', path: r?.path ?? r?.Path ?? '', is_dir: Boolean(r?.is_dir ?? r?.IsDir ?? r?.isDir ?? false), size: Number(r?.size ?? r?.Size ?? 0), mtime: Number(r?.mtime ?? r?.Mtime ?? 0), })) } const videoExt = new Set(['mp4', 'mkv', 'mov', 'avi', 'm4v', 'webm', 'mpg', 'mpeg', 'ts', 'm2ts']) const imageExt = new Set(['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'bmp', 'tif', 'tiff']) const extLower = (n: string) => (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '') const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name)) const isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name)) function isDetailedError(e: unknown): e is tus.DetailedError { return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any)) } function isLikelyMobileUA(): boolean { if (typeof window === 'undefined') return false const ua = navigator.userAgent || '' const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches return coarse || /Mobi|Android|iPhone|iPad|iPod/i.test(ua) } function useIsMobile(): boolean { const [mobile, setMobile] = React.useState(false) React.useEffect(() => { setMobile(isLikelyMobileUA()) }, []) return mobile } // Disable tus resume completely (v2 API: addUpload/removeUpload/listUploads) const NoResumeUrlStorage: any = { addUpload: async (_u: any) => {}, removeUpload: async (_u: any) => {}, listUploads: async () => [], findUploadsByFingerprint: async (_fp: string) => [], } const NoResumeFingerprint = async () => `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}` // ---------- thumbnail ---------- function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) { const [url, setUrl] = React.useState() React.useEffect(() => { const u = URL.createObjectURL(file) setUrl(u) return () => { try { URL.revokeObjectURL(u) } catch {} } }, [file]) if (!url) return null const baseStyle: React.CSSProperties = { width: size, height: size, objectFit: 'cover', borderRadius: 12 } if (isImageFile(file)) return if (isVideoFile(file)) return