From 8935bc7815bdddfad707294a363f968db087b2a0 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Fri, 20 Feb 2026 18:22:55 +0100 Subject: [PATCH] filebrowser ole :) --- .../src/components/admin/StorageManager.tsx | 2 +- .../widgets/WidgetPropertiesForm.tsx | 2 +- packages/ui/src/lib/registerWidgets.ts | 2 +- .../src/modules/pages/FileBrowserWidget.tsx | 867 ------------------ .../modules/storage/FileBrowserToolbar.tsx | 162 ++++ .../src/modules/storage/FileBrowserWidget.tsx | 484 ++++++++++ .../src/modules/storage/FileDetailPanel.tsx | 59 ++ .../ui/src/modules/storage/FileGridView.tsx | 76 ++ .../ui/src/modules/storage/FileListView.tsx | 77 ++ .../ui/src/modules/storage/ThumbPreview.tsx | 35 + packages/ui/src/modules/storage/helpers.ts | 103 +++ packages/ui/src/modules/storage/index.ts | 4 + packages/ui/src/modules/storage/types.ts | 43 + 13 files changed, 1046 insertions(+), 870 deletions(-) delete mode 100644 packages/ui/src/modules/pages/FileBrowserWidget.tsx create mode 100644 packages/ui/src/modules/storage/FileBrowserToolbar.tsx create mode 100644 packages/ui/src/modules/storage/FileBrowserWidget.tsx create mode 100644 packages/ui/src/modules/storage/FileDetailPanel.tsx create mode 100644 packages/ui/src/modules/storage/FileGridView.tsx create mode 100644 packages/ui/src/modules/storage/FileListView.tsx create mode 100644 packages/ui/src/modules/storage/ThumbPreview.tsx create mode 100644 packages/ui/src/modules/storage/helpers.ts create mode 100644 packages/ui/src/modules/storage/index.ts create mode 100644 packages/ui/src/modules/storage/types.ts diff --git a/packages/ui/src/components/admin/StorageManager.tsx b/packages/ui/src/components/admin/StorageManager.tsx index 53cbcee7..f6e57ee3 100644 --- a/packages/ui/src/components/admin/StorageManager.tsx +++ b/packages/ui/src/components/admin/StorageManager.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { FileBrowserWidget } from "@/modules/pages/FileBrowserWidget"; +import { FileBrowserWidget } from "@/modules/storage"; import { AclEditor } from "@/components/admin/AclEditor"; import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; diff --git a/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx b/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx index 920d6433..96fd8c7e 100644 --- a/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx +++ b/packages/ui/src/components/widgets/WidgetPropertiesForm.tsx @@ -17,7 +17,7 @@ import MarkdownEditor from '@/components/MarkdownEditorEx'; import { TailwindClassPicker } from './TailwindClassPicker'; import { TabsPropertyEditor } from './TabsPropertyEditor'; import { HtmlGeneratorWizard } from './HtmlGeneratorWizard'; -import { FileBrowserWidget } from '@/modules/pages/FileBrowserWidget'; +import { FileBrowserWidget } from '@/modules/storage'; export interface WidgetPropertiesFormProps { widgetDefinition: WidgetDefinition; diff --git a/packages/ui/src/lib/registerWidgets.ts b/packages/ui/src/lib/registerWidgets.ts index e0d49c14..83b07e1c 100644 --- a/packages/ui/src/lib/registerWidgets.ts +++ b/packages/ui/src/lib/registerWidgets.ts @@ -28,7 +28,7 @@ import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget'; import GalleryWidget from '@/components/widgets/GalleryWidget'; import TabsWidget from '@/components/widgets/TabsWidget'; import { HtmlWidget } from '@/components/widgets/HtmlWidget'; -import { FileBrowserWidget } from '@/modules/pages/FileBrowserWidget'; +import { FileBrowserWidget } from '@/modules/storage'; export function registerAllWidgets() { // Clear existing registrations (useful for HMR) diff --git a/packages/ui/src/modules/pages/FileBrowserWidget.tsx b/packages/ui/src/modules/pages/FileBrowserWidget.tsx deleted file mode 100644 index 7b35f673..00000000 --- a/packages/ui/src/modules/pages/FileBrowserWidget.tsx +++ /dev/null @@ -1,867 +0,0 @@ -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { - DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem -} from '@/components/ui/dropdown-menu'; -import { - Folder, File, ArrowUp, List, LayoutGrid, - ArrowUpDown, Clock, FileType, Type, - ChevronRight, ChevronDown, Info, Loader2, Download, ExternalLink, ZoomIn, ZoomOut, - Image, Film, Music, FileCode, FileText as FileTextIcon, - Archive, FileSpreadsheet, Presentation, HardDrive, Home -} from 'lucide-react'; -import type { FileBrowserWidgetProps } from '@polymech/shared'; -import { useAuth } from '@/hooks/useAuth'; -import ResponsiveImage from '@/components/ResponsiveImage'; -import ImageLightbox from '@/components/ImageLightbox'; -import LightboxText from '@/components/LightboxText'; - -export interface FileBrowserWidgetExtendedProps extends Omit { - variables?: any; - // Controlled mode (optional) - path?: string; - onPathChange?: (path: string) => void; - onMountChange?: (mount: string) => void; - onSelect?: (path: string | null) => void; -} - - -// ── Types ──────────────────────────────────────────────────────── - -interface INode { - name: string; - path: string; - size: number; - mtime?: number; - mime?: string; - parent: string; - type: string; -} - -type SortKey = 'name' | 'ext' | 'date' | 'type'; -type MimeCategory = 'dir' | 'image' | 'video' | 'audio' | 'code' | 'document' | 'archive' | 'spreadsheet' | 'presentation' | 'other'; - -// ── MIME helpers ───────────────────────────────────────────────── - -const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff', 'tif']); -const VIDEO_EXTS = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi', 'flv', 'wmv', 'm4v', 'ogv']); -const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'opus']); -const CODE_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'swift', 'kt', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', 'lua', 'php', 'sql', 'r', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'json', 'yaml', 'yml', 'toml', 'xml', 'vue', 'svelte']); -const DOC_EXTS = new Set(['md', 'txt', 'rtf', 'pdf', 'doc', 'docx', 'odt', 'tex', 'log']); -const ARCHIVE_EXTS = new Set(['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'zst', 'tgz']); -const SPREADSHEET_EXTS = new Set(['xls', 'xlsx', 'csv', 'ods', 'tsv']); -const PRESENTATION_EXTS = new Set(['ppt', 'pptx', 'odp', 'key']); - -function getExt(name: string): string { - const i = name.lastIndexOf('.'); - return i > 0 ? name.slice(i + 1).toLowerCase() : ''; -} - -function getMimeCategory(node: INode): MimeCategory { - if (node.mime === 'inode/directory' || node.type === 'dir') return 'dir'; - const mime = node.mime || ''; - if (mime.startsWith('image/')) return 'image'; - if (mime.startsWith('video/')) return 'video'; - if (mime.startsWith('audio/')) return 'audio'; - if (mime.startsWith('text/x-') || mime.includes('javascript') || mime.includes('typescript') || mime.includes('json') || mime.includes('xml') || mime.includes('yaml')) return 'code'; - const ext = getExt(node.name); - if (IMAGE_EXTS.has(ext)) return 'image'; - if (VIDEO_EXTS.has(ext)) return 'video'; - if (AUDIO_EXTS.has(ext)) return 'audio'; - if (CODE_EXTS.has(ext)) return 'code'; - if (SPREADSHEET_EXTS.has(ext)) return 'spreadsheet'; - if (PRESENTATION_EXTS.has(ext)) return 'presentation'; - if (ARCHIVE_EXTS.has(ext)) return 'archive'; - if (DOC_EXTS.has(ext)) return 'document'; - return 'other'; -} - -const CATEGORY_STYLE: Record; color: string }> = { - dir: { icon: Folder, color: '#60a5fa' }, - image: { icon: Image, color: '#22c55e' }, - video: { icon: Film, color: '#ef4444' }, - audio: { icon: Music, color: '#8b5cf6' }, - code: { icon: FileCode, color: '#3b82f6' }, - document: { icon: FileTextIcon, color: '#f59e0b' }, - archive: { icon: Archive, color: '#eab308' }, - spreadsheet: { icon: FileSpreadsheet, color: '#16a34a' }, - presentation: { icon: Presentation, color: '#ec4899' }, - other: { icon: File, color: '#94a3b8' }, -}; - -function NodeIcon({ node, size = 14 }: { node: INode; size?: number }) { - const cat = getMimeCategory(node); - const { icon: Icon, color } = CATEGORY_STYLE[cat]; - return ; -} - -// ── Format helpers ─────────────────────────────────────────────── - -function formatSize(bytes: number): string { - if (bytes === 0) return '—'; - const units = ['B', 'KB', 'MB', 'GB']; - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - const v = bytes / Math.pow(1024, i); - return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; -} - -function formatDate(ts?: number): string { - if (!ts) return '—'; - const d = new Date(ts); - return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) - + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); -} - -// ── Sort ───────────────────────────────────────────────────────── - -function sortNodes(nodes: INode[], sortBy: SortKey, asc: boolean): INode[] { - const dirs = nodes.filter(n => n.mime === 'inode/directory' || n.type === 'dir'); - const files = nodes.filter(n => n.mime !== 'inode/directory' && n.type !== 'dir'); - const cmp = (a: INode, b: INode): number => { - let r = 0; - switch (sortBy) { - case 'name': r = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); break; - case 'ext': r = getExt(a.name).localeCompare(getExt(b.name)) || a.name.localeCompare(b.name); break; - case 'date': r = (a.mtime ?? 0) - (b.mtime ?? 0); break; - case 'type': r = getMimeCategory(a).localeCompare(getMimeCategory(b)) || a.name.localeCompare(b.name); break; - } - return asc ? r : -r; - }; - dirs.sort(cmp); - files.sort(cmp); - return [...dirs, ...files]; -} - -// ── URL helper ─────────────────────────────────────────────────── - -/** Build a VFS API URL. Always includes mount segment — resolveMount handles 'home'. */ -function vfsUrl(op: string, mount: string, subpath?: string): string { - const clean = subpath?.replace(/^\/+/, ''); - return clean - ? `/api/vfs/${op}/${encodeURIComponent(mount)}/${clean}` - : `/api/vfs/${op}/${encodeURIComponent(mount)}`; -} - -// ── Thumbnail helper ───────────────────────────────────────────── - -function ThumbPreview({ node, mount, tokenParam = '' }: { node: INode; mount: string; tokenParam?: string }) { - const cat = getMimeCategory(node); - const fileUrl = vfsUrl('get', mount, node.path); - const fullUrl = tokenParam ? `${fileUrl}?${tokenParam}` : fileUrl; - if (cat === 'image') { - return ; - } - if (cat === 'video') { - return ( -
-
- ); - } - return ; -} - -// ── Toolbar button ─────────────────────────────────────────────── - -const TB_BTN: React.CSSProperties = { - background: 'none', border: 'none', cursor: 'pointer', - padding: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', - color: 'var(--muted-foreground, #94a3b8)', borderRadius: 4, -}; -const TB_BTN_ACTIVE: React.CSSProperties = { ...TB_BTN, color: 'var(--foreground, #e2e8f0)' }; -const TB_SEP: React.CSSProperties = { width: 1, height: 18, background: 'var(--border, #334155)', flexShrink: 0 }; - -// ── Main Component ─────────────────────────────────────────────── - -const FileBrowserWidget: React.FC = (props) => { - - const { - mount: mountProp = 'home', - path: pathProp = '/', - glob = '*.*', - mode = 'simple', - viewMode: initialViewMode = 'list', - sortBy: initialSort = 'name', - showToolbar = true, - canChangeMount = true, - allowFileViewer = true, - allowLightbox = true, - allowDownload = true, - onPathChange, - onMountChange, - onSelect, - } = props; - - const { session } = useAuth(); - const accessToken = session?.access_token; - - // Controlled mode: parent provides onPathChange - const isControlled = !!onPathChange; - - // Internal state for uncontrolled mode - const [internalPath, setInternalPath] = useState(pathProp); - const [internalMount, setInternalMount] = useState(mountProp); - - // Derived current values - const mount = onMountChange ? mountProp : internalMount; - const currentPath = isControlled ? pathProp : internalPath; - - // Helper to update path - const updatePath = useCallback((newPath: string) => { - if (isControlled) onPathChange!(newPath); - else setInternalPath(newPath); - }, [isControlled, onPathChange]); - - const updateMount = useCallback((newMount: string) => { - if (onMountChange) onMountChange(newMount); - else setInternalMount(newMount); - updatePath('/'); - }, [onMountChange, updatePath]); - - // Fetch available mounts - const [availableMounts, setAvailableMounts] = useState([]); - useEffect(() => { - const headers: Record = {}; - if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; - fetch('/api/vfs/mounts', { headers }) - .then(r => r.ok ? r.json() : []) - .then((mounts: { name: string }[]) => setAvailableMounts(mounts.map(m => m.name))) - .catch(() => { }); - }, [accessToken]); - - const [nodes, setNodes] = useState([]); - - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [viewMode, setViewMode] = useState<'list' | 'thumbs'>(initialViewMode); - const [sortBy, setSortBy] = useState(initialSort); - const [sortAsc, setSortAsc] = useState(true); - const [focusIdx, setFocusIdx] = useState(-1); - const [selected, setSelected] = useState(null); - const listRef = useRef(null); - const containerRef = useRef(null); - const returnTargetRef = useRef(null); - - useEffect(() => { - if (onSelect) { - onSelect(selected ? selected.path : null); - } - }, [selected, onSelect]); - - // ── Zoom (persisted) ───────────────────────────────────────── - const [thumbSize, setThumbSize] = useState(() => { - const v = localStorage.getItem('fb-thumb-size'); - return v ? Math.max(60, Math.min(200, Number(v))) : 80; - }); - const [fontSize, setFontSize] = useState(() => { - const v = localStorage.getItem('fb-font-size'); - return v ? Math.max(10, Math.min(18, Number(v))) : 14; - }); - const zoomIn = () => { - if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.min(200, s + 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); - else setFontSize(s => { const n = Math.min(18, s + 1); localStorage.setItem('fb-font-size', String(n)); return n; }); - }; - const zoomOut = () => { - if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.max(60, s - 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); - else setFontSize(s => { const n = Math.max(10, s - 1); localStorage.setItem('fb-font-size', String(n)); return n; }); - }; - - // ── Fetch ─────────────────────────────────────────────────── - - const fetchDir = useCallback(async (dirPath: string) => { - setLoading(true); - setError(null); - setSelected(null); - setFocusIdx(0); - try { - const clean = dirPath.replace(/^\/+/, ''); - const base = vfsUrl('ls', mount, clean); - const url = glob && glob !== '*.*' ? `${base}?glob=${encodeURIComponent(glob)}` : base; - const headers: Record = {}; - if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; - const res = await fetch(url, { headers }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `HTTP ${res.status}`); - } - setNodes(await res.json()); - } catch (e: any) { - setError(e.message || 'Failed to load directory'); - setNodes([]); - } finally { - setLoading(false); - } - }, [mount, glob, accessToken]); - - useEffect(() => { fetchDir(currentPath); }, [currentPath, fetchDir]); - - useEffect(() => { - if (!isControlled) setInternalPath(pathProp); - }, [pathProp, isControlled]); - - - // Build a URL with optional auth token for /