// 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 composeName(date:string, desc:string, orig:string){ const d = date || new Date().toISOString().slice(0,10) const [Y,M,D] = d.split('-'); return `${Y}.${M}.${D}.${sanitizeDesc(desc)}.${extOf(orig)}` } 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 () => [], // compatibility: 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