filebrowser ole :)

This commit is contained in:
lovebird 2026-02-20 18:22:55 +01:00
parent 044791d496
commit 8935bc7815
13 changed files with 1046 additions and 870 deletions

View File

@ -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";

View File

@ -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;

View File

@ -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)

View File

@ -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<FileBrowserWidgetProps, 'path' | 'variables'> {
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<MimeCategory, { icon: React.FC<any>; 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 <Icon size={size} style={{ color, flexShrink: 0 }} />;
}
// ── 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 <ResponsiveImage src={fullUrl} alt={node.name} loading="lazy" responsiveSizes={[128, 256]} className="" imgClassName="" style={{ width: '100%', height: '100%', flex: 1, objectFit: 'cover', borderRadius: 4 }} />;
}
if (cat === 'video') {
return (
<div style={{ position: 'relative', width: '100%', flex: 1 }}>
<video src={fullUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 4 }} />
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', borderRadius: 4 }}>
<Film size={20} style={{ color: '#fff', opacity: 0.8 }} />
</div>
</div>
);
}
return <NodeIcon node={node} size={28} />;
}
// ── 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<FileBrowserWidgetExtendedProps> = (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<string[]>([]);
useEffect(() => {
const headers: Record<string, string> = {};
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<INode[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'list' | 'thumbs'>(initialViewMode);
const [sortBy, setSortBy] = useState<SortKey>(initialSort);
const [sortAsc, setSortAsc] = useState(true);
const [focusIdx, setFocusIdx] = useState(-1);
const [selected, setSelected] = useState<INode | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const returnTargetRef = useRef<string | null>(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<string, string> = {};
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 <img>/<video> src (can't set headers on HTML elements)
const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : '';
// ── Sorted items (with optional ".." at index 0) ────────────
const canGoUp = currentPath !== '/' && currentPath !== '';
const sorted = useMemo(() => sortNodes(nodes, sortBy, sortAsc), [nodes, sortBy, sortAsc]);
// Virtual list: ".." occupies index 0 when canGoUp, real items follow
const itemCount = sorted.length + (canGoUp ? 1 : 0);
const getNode = (idx: number): INode | null => {
if (canGoUp && idx === 0) return null; // ".."
return sorted[canGoUp ? idx - 1 : idx] ?? null;
};
// ── Navigation ──────────────────────────────────────────────
const openNode = (node: INode) => {
if (getMimeCategory(node) === 'dir') {
updatePath(node.path || node.name);
} else {
setSelected(prev => prev?.path === node.path ? null : node);
}
};
const goUp = useCallback(() => {
if (!canGoUp) return;
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
const leaving = parts.pop(); // the folder we're leaving
if (leaving) returnTargetRef.current = leaving;
updatePath(parts.length ? parts.join('/') : '/');
}, [currentPath, canGoUp, updatePath]);
const getFileUrl = (node: INode) => {
const base = vfsUrl('get', mount, node.path);
return tokenParam ? `${base}?${tokenParam}` : base;
};
const handleView = () => { if (selected) window.open(getFileUrl(selected), '_blank'); };
const handleDownload = () => {
if (!selected) return;
const a = document.createElement('a');
a.href = getFileUrl(selected);
a.download = selected.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
// ── Sort toggle ─────────────────────────────────────────────
const cycleSort = () => {
const keys: SortKey[] = ['name', 'ext', 'date', 'type'];
const i = keys.indexOf(sortBy);
if (sortAsc) { setSortAsc(false); } else { setSortBy(keys[(i + 1) % keys.length]); setSortAsc(true); }
};
const sortIcons: Record<SortKey, React.ReactNode> = { name: <Type size={16} />, ext: <FileType size={16} />, date: <Clock size={16} />, type: <ArrowUpDown size={16} /> };
// ── Breadcrumbs ─────────────────────────────────────────────
const breadcrumbs = useMemo(() => {
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
const crumbs = [{ label: '/', path: '/' }];
let acc = '';
for (const p of parts) { acc += (acc ? '/' : '') + p; crumbs.push({ label: p, path: acc }); }
return crumbs;
}, [currentPath, mount]);
// Return-to-sender: after loading, focus the folder we came from
useEffect(() => {
const target = returnTargetRef.current;
if (!target || sorted.length === 0) return;
const idx = sorted.findIndex(n => n.name === target);
if (idx >= 0) {
const realIdx = canGoUp ? idx + 1 : idx;
setFocusIdx(realIdx);
setSelected(sorted[idx]);
requestAnimationFrame(() => {
if (!listRef.current) return;
const items = listRef.current.querySelectorAll('[data-fb-idx]');
const el = items[realIdx] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
});
}
returnTargetRef.current = null;
}, [sorted, canGoUp]);
// ── Keyboard navigation ─────────────────────────────────────
const scrollItemIntoView = useCallback((idx: number) => {
if (!listRef.current) return;
const items = listRef.current.querySelectorAll('[data-fb-idx]');
const el = items[idx] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
}, []);
const getGridCols = useCallback((): number => {
if (viewMode !== 'thumbs' || !listRef.current) return 1;
const style = getComputedStyle(listRef.current);
const cols = style.gridTemplateColumns.split(' ').length;
return Math.max(1, cols);
}, [viewMode]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (itemCount === 0) return;
const moveFocus = (next: number) => {
next = Math.max(0, Math.min(itemCount - 1, next));
setFocusIdx(next);
const node = getNode(next);
if (node) setSelected(node); else setSelected(null);
scrollItemIntoView(next);
};
const cols = getGridCols();
switch (e.key) {
case 'ArrowRight':
case 'j': {
e.preventDefault();
moveFocus(focusIdx < itemCount - 1 ? focusIdx + 1 : 0);
break;
}
case 'ArrowLeft':
case 'k': {
e.preventDefault();
moveFocus(focusIdx > 0 ? focusIdx - 1 : itemCount - 1);
break;
}
case 'ArrowDown': {
e.preventDefault();
moveFocus(focusIdx + cols);
break;
}
case 'ArrowUp': {
e.preventDefault();
moveFocus(focusIdx - cols);
break;
}
case 'Enter':
case ' ':
case 'l': {
e.preventDefault();
if (focusIdx < 0) break;
const node = getNode(focusIdx);
if (!node) { goUp(); break; }
const cat = getMimeCategory(node);
if (cat === 'dir') updatePath(node.path || node.name);
else if ((cat === 'image' || cat === 'video') && allowLightbox) setLightboxNode(node);
else if (allowFileViewer) openTextLightbox(node);
break;
}
case 'Backspace':
case 'h': {
e.preventDefault();
goUp();
break;
}
case 'Home': {
e.preventDefault();
moveFocus(0);
break;
}
case 'End': {
e.preventDefault();
moveFocus(itemCount - 1);
break;
}
case ' ': {
e.preventDefault();
if (focusIdx < 0) break;
const node = getNode(focusIdx);
const cat = node ? getMimeCategory(node) : null;
if (node && (cat === 'image' || cat === 'video')) setLightboxNode(node);
break;
}
case 'Escape': {
e.preventDefault();
setSelected(null);
setFocusIdx(-1);
break;
}
}
}, [focusIdx, itemCount, goUp, scrollItemIntoView, sorted, canGoUp, getGridCols]);
// Focus container on mount and after nav for keyboard capture
useEffect(() => { containerRef.current?.focus(); }, [currentPath]);
const selectedFile = selected && getMimeCategory(selected) !== 'dir' ? selected : null;
// ── Lightbox state ──────────────────────────────────────────
const [lightboxNode, setLightboxNode] = useState<INode | null>(null);
const mediaNodes = useMemo(() => sorted.filter(n => { const c = getMimeCategory(n); return c === 'image' || c === 'video'; }), [sorted]);
const lightboxIdx = lightboxNode ? mediaNodes.findIndex(n => n.path === lightboxNode.path) : -1;
const lightboxPrev = () => {
if (lightboxIdx > 0) setLightboxNode(mediaNodes[lightboxIdx - 1]);
};
const lightboxNext = () => {
if (lightboxIdx < mediaNodes.length - 1) setLightboxNode(mediaNodes[lightboxIdx + 1]);
};
const closeLightbox = () => {
setLightboxNode(null);
setTimeout(() => containerRef.current?.focus(), 0);
};
// ── Text lightbox state ──────────────────────────────────────
const [textLightboxNode, setTextLightboxNode] = useState<INode | null>(null);
const openTextLightbox = (node: INode) => setTextLightboxNode(node);
const closeTextLightbox = () => {
setTextLightboxNode(null);
setTimeout(() => containerRef.current?.focus(), 0);
};
// ── Row click handler ───────────────────────────────────────
const onItemClick = (idx: number) => {
setFocusIdx(idx);
const node = getNode(idx);
if (!node) return;
setSelected(prev => prev?.path === node.path ? null : node);
};
const onItemDoubleClick = (idx: number) => {
const node = getNode(idx);
if (!node) { goUp(); return; }
const cat = getMimeCategory(node);
if (cat === 'dir') {
updatePath(node.path || node.name);
} else if ((cat === 'image' || cat === 'video') && allowLightbox) {
setLightboxNode(node);
} else if (allowFileViewer) {
openTextLightbox(node);
}
};
// ── Styles ──────────────────────────────────────────────────
const focusBg = 'var(--accent, #334155)';
const selectedBg = 'rgba(59, 130, 246, 0.15)';
const selectedBorder = 'var(--ring, #3b82f6)';
// ── Render ──────────────────────────────────────────────────
return (
<div
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
style={{
display: 'flex', flexDirection: 'column', height: '100%',
border: '1px solid var(--border, #334155)', borderRadius: 6, overflow: 'hidden',
background: 'var(--background, #0f172a)', color: 'var(--foreground, #e2e8f0)',
fontFamily: 'var(--font-sans, system-ui, sans-serif)', outline: 'none',
}}
>
<style>{`
.fb-row:hover { background: var(--accent, #334155) !important; }
.fb-thumb:hover { border-color: var(--ring, #3b82f6) !important; background: var(--accent, #1e293b) !important; }
.fb-tb-btn:hover { background: var(--accent, #334155) !important; color: var(--foreground, #e2e8f0) !important; }
.fb-mount-item:hover { background: var(--accent, #334155) !important; }
@media (max-width: 767px) { .fb-detail-pane { display: none !important; } }
`}</style>
{/* ═══ Toolbar ═══════════════════════════════════ */}
{showToolbar && (
<div style={{
display: 'flex', alignItems: 'center', gap: 2, padding: '4px 6px',
borderBottom: '1px solid var(--border, #334155)',
background: 'var(--muted, #1e293b)',
}}>
{/* Navigation */}
<button onClick={goUp} disabled={!canGoUp} title="Go up (Backspace)" className="fb-tb-btn"
style={{ ...TB_BTN, opacity: canGoUp ? 1 : 0.3, cursor: canGoUp ? 'pointer' : 'default' }}>
<ArrowUp size={18} />
</button>
{/* Mount picker */}
{canChangeMount && availableMounts.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="fb-tb-btn"
title="Switch mount"
style={{ ...TB_BTN, gap: 4, fontSize: 13, fontWeight: 600 }}
>
<HardDrive size={14} />
<span>{mount}</span>
<ChevronDown size={10} style={{ opacity: 0.5 }} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[140px]">
{availableMounts.map(m => (
<DropdownMenuItem
key={m}
onClick={() => updateMount(m)}
className={m === mount ? 'font-semibold bg-accent' : ''}
>
<HardDrive className="h-3 w-3 mr-2 opacity-60" />
{m}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<div style={TB_SEP} />
{/* Breadcrumbs */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 2, overflow: 'hidden', padding: '0 4px' }}>
<button onClick={() => { updateMount(mountProp); updatePath(pathProp); }} title="Home" className="fb-tb-btn"
style={{ ...TB_BTN, flexShrink: 0 }}>
<Home size={14} />
</button>
<ChevronRight size={10} style={{ opacity: 0.3, flexShrink: 0 }} />
{breadcrumbs.map((c, i) => (
<React.Fragment key={c.path}>
{i > 0 && <ChevronRight size={10} style={{ opacity: 0.3, flexShrink: 0 }} />}
<button onClick={() => updatePath(c.path)} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: i === breadcrumbs.length - 1 ? 'var(--foreground, #e2e8f0)' : 'var(--muted-foreground, #94a3b8)',
fontWeight: i === breadcrumbs.length - 1 ? 600 : 400,
padding: '2px 3px', borderRadius: 3, whiteSpace: 'nowrap', fontSize: 13,
}}>
{c.label}
</button>
</React.Fragment>
))}
</div>
<div style={TB_SEP} />
{/* File actions — shown when a file is selected */}
{selectedFile && (<>
<button onClick={handleView} title="View in browser" className="fb-tb-btn" style={TB_BTN}>
<ExternalLink size={18} />
</button>
{allowDownload && (
<button onClick={handleDownload} title="Download" className="fb-tb-btn" style={TB_BTN}>
<Download size={18} />
</button>
)}
<div style={TB_SEP} />
</>)}
{/* Sort */}
<button onClick={cycleSort} title={`Sort: ${sortBy} (${sortAsc ? 'asc' : 'desc'})`} className="fb-tb-btn"
style={{ ...TB_BTN, gap: 2 }}>
{sortIcons[sortBy]}
<span style={{ fontSize: 9, opacity: 0.6 }}>{sortAsc ? '↑' : '↓'}</span>
</button>
{/* Zoom */}
<button onClick={zoomOut} title="Zoom out" className="fb-tb-btn" style={TB_BTN}>
<ZoomOut size={18} />
</button>
<button onClick={zoomIn} title="Zoom in" className="fb-tb-btn" style={TB_BTN}>
<ZoomIn size={18} />
</button>
{/* View mode */}
<button onClick={() => setViewMode(v => v === 'list' ? 'thumbs' : 'list')}
title={viewMode === 'list' ? 'Thumbnails' : 'List'} className="fb-tb-btn" style={TB_BTN}>
{viewMode === 'list' ? <LayoutGrid size={18} /> : <List size={18} />}
</button>
</div>
)}
{/* ═══ Content ═══════════════════════════════════ */}
{loading ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, opacity: 0.6 }}>
<Loader2 size={16} className="animate-spin" />
<span style={{ fontSize: 14 }}>Loading</span>
</div>
) : error ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, color: '#ef4444' }}>
{error}
</div>
) : itemCount === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, opacity: 0.5 }}>
Empty directory
</div>
) : (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* ── List view ──────────────────────── */}
{viewMode === 'list' ? (
<div ref={listRef} style={{ overflowY: 'auto', flex: 1 }}>
{canGoUp && (
<div data-fb-idx={0} onClick={() => setFocusIdx(0)} onDoubleClick={goUp}
className="fb-row" style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
cursor: 'pointer', fontSize, borderBottom: '1px solid var(--border, #e5e7eb)',
background: focusIdx === 0 ? focusBg : 'transparent',
}}>
<ArrowUp size={14} style={{ color: CATEGORY_STYLE.dir.color }} />
<span style={{ fontWeight: 500 }}>..</span>
</div>
)}
{sorted.map((node, i) => {
const idx = (canGoUp ? i + 1 : i);
const isDir = getMimeCategory(node) === 'dir';
const isFocused = focusIdx === idx;
const isSelected = selected?.path === node.path;
return (
<div key={node.path || node.name} data-fb-idx={idx}
onClick={() => onItemClick(idx)}
onDoubleClick={() => onItemDoubleClick(idx)}
className="fb-row" style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
cursor: 'pointer', fontSize,
borderBottomWidth: 1, borderBottomColor: 'var(--border, #e5e7eb)', borderBottomStyle: 'solid',
background: isSelected ? selectedBg : isFocused ? focusBg : 'transparent',
borderLeftWidth: 2, borderLeftColor: isSelected ? selectedBorder : 'transparent',
borderLeftStyle: isSelected ? 'outset' : 'solid',
}}>
<NodeIcon node={node} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{node.name}</span>
{!isDir && (
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0 }}>
{formatSize(node.size)}
</span>
)}
{mode === 'advanced' && node.mtime && (
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0, width: 120, textAlign: 'right' }}>
{formatDate(node.mtime)}
</span>
)}
</div>
);
})}
</div>
) : (
/* ── Thumb view ─────────────────────── */
<div ref={listRef} style={{
overflowY: 'auto', flex: 1, display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`, gap: 2, padding: 2,
}}>
{canGoUp && (
<div data-fb-idx={0} onClick={() => setFocusIdx(0)} onDoubleClick={goUp} className="fb-thumb" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: 2, borderRadius: 2, cursor: 'pointer', gap: 2, aspectRatio: '1',
borderWidth: 1, borderColor: focusIdx === 0 ? selectedBorder : 'transparent',
borderStyle: 'solid',
}}>
<ArrowUp size={24} style={{ color: CATEGORY_STYLE.dir.color }} />
<span style={{ fontSize: 14 }}>..</span>
</div>
)}
{sorted.map((node, i) => {
const idx = (canGoUp ? i + 1 : i);
const isDir = getMimeCategory(node) === 'dir';
const isFocused = focusIdx === idx;
const isSelected = selected?.path === node.path;
return (
<div key={node.path || node.name} data-fb-idx={idx}
onClick={() => onItemClick(idx)}
onDoubleClick={() => onItemDoubleClick(idx)}
className="fb-thumb" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: 6, borderRadius: 6, cursor: 'pointer', gap: 4, overflow: 'hidden', aspectRatio: '1',
borderWidth: isSelected ? 2 : 1,
borderColor: isSelected ? selectedBorder : isFocused ? selectedBorder : 'transparent',
borderStyle: isSelected ? 'outset' : 'solid',
background: isSelected ? selectedBg : isFocused ? focusBg : 'transparent',
}}>
{isDir ? <NodeIcon node={node} size={48} /> : <ThumbPreview node={node} mount={mount} tokenParam={tokenParam} />}
<span style={{
fontSize: 14, textAlign: 'center', width: '100%',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{node.name}
</span>
</div>
);
})}
</div>
)}
{/* ── Detail panel (advanced, desktop only) ── */}
{mode === 'advanced' && selectedFile && (
<div className="fb-detail-pane" style={{
width: 200, borderLeft: '1px solid var(--border, #334155)',
padding: 10, fontSize: 11, overflowY: 'auto', flexShrink: 0,
background: 'var(--muted, #1e293b)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Info size={14} />
<span style={{ fontWeight: 600 }}>Details</span>
</div>
{getMimeCategory(selectedFile) === 'image' && (
<ResponsiveImage src={getFileUrl(selectedFile)} alt={selectedFile.name}
responsiveSizes={[200, 400]} imgClassName="" style={{ width: '100%', borderRadius: 4, marginBottom: 8, objectFit: 'contain' }} />
)}
{getMimeCategory(selectedFile) === 'video' && (
<video key={selectedFile.path} src={getFileUrl(selectedFile)} controls muted preload="metadata"
style={{ width: '100%', borderRadius: 4, marginBottom: 8 }} />
)}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{([
['Name', selectedFile.name],
['Path', selectedFile.path],
['Size', formatSize(selectedFile.size)],
['Modified', formatDate(selectedFile.mtime)],
['MIME', selectedFile.mime || '—'],
['Type', getMimeCategory(selectedFile)],
] as [string, string][]).map(([label, val]) => (
<tr key={label}>
<td style={{ padding: '3px 4px 3px 0', color: 'var(--muted-foreground, #94a3b8)', whiteSpace: 'nowrap', verticalAlign: 'top' }}>{label}</td>
<td style={{ padding: '3px 0', wordBreak: 'break-all' }}>{val}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* ═══ Status bar ════════════════════════════════ */}
<div style={{
padding: '4px 10px', fontSize: 12, borderTop: '1px solid var(--border, #334155)',
color: 'var(--muted-foreground, #64748b)', display: 'flex', justifyContent: 'space-between',
background: 'var(--muted, #1e293b)',
}}>
<span>
{sorted.length} item{sorted.length !== 1 ? 's' : ''}
{' · '}{formatSize(sorted.reduce((sum, n) => sum + (n.size || 0), 0))}
{selectedFile ? ` · ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''}
</span>
<span>{mount}:{currentPath || '/'}</span>
</div>
{/* ═══ Lightbox ═════════════════════════════════ */}
<ImageLightbox
isOpen={!!lightboxNode}
onClose={closeLightbox}
imageUrl={lightboxNode ? getFileUrl(lightboxNode) : ''}
imageTitle={lightboxNode?.name || ''}
currentIndex={lightboxIdx}
totalCount={mediaNodes.length}
onNavigate={(dir) => dir === 'prev' ? lightboxPrev() : lightboxNext()}
showPrompt={false}
/>
<LightboxText
isOpen={!!textLightboxNode}
onClose={closeTextLightbox}
url={textLightboxNode ? getFileUrl(textLightboxNode) : ''}
fileName={textLightboxNode?.name || ''}
/>
</div>
);
};
export { FileBrowserWidget };
export default FileBrowserWidget;

View File

@ -0,0 +1,162 @@
import React from 'react';
import {
ArrowUp, List, LayoutGrid, ChevronRight, ChevronDown,
Download, ExternalLink, ZoomIn, ZoomOut, HardDrive, Home,
Type, FileType, Clock, ArrowUpDown,
} from 'lucide-react';
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem
} from '@/components/ui/dropdown-menu';
import type { INode, SortKey } from './types';
import { TB_BTN, TB_SEP } from './types';
// ── Props ────────────────────────────────────────────────────────
interface FileBrowserToolbarProps {
canGoUp: boolean;
goUp: () => void;
canChangeMount: boolean;
availableMounts: string[];
mount: string;
updateMount: (m: string) => void;
mountProp: string;
pathProp: string;
updatePath: (p: string) => void;
breadcrumbs: { label: string; path: string }[];
selectedFile: INode | null;
handleView: () => void;
handleDownload: () => void;
allowDownload: boolean;
sortBy: SortKey;
sortAsc: boolean;
cycleSort: () => void;
zoomIn: () => void;
zoomOut: () => void;
viewMode: 'list' | 'thumbs';
setViewMode: (v: 'list' | 'thumbs') => void;
}
// ── Sort icons ───────────────────────────────────────────────────
const sortIcons: Record<SortKey, React.ReactNode> = {
name: <Type size={16} />,
ext: <FileType size={16} />,
date: <Clock size={16} />,
type: <ArrowUpDown size={16} />,
};
// ── Component ────────────────────────────────────────────────────
const FileBrowserToolbar: React.FC<FileBrowserToolbarProps> = ({
canGoUp, goUp,
canChangeMount, availableMounts, mount, updateMount,
mountProp, pathProp, updatePath,
breadcrumbs,
selectedFile, handleView, handleDownload, allowDownload,
sortBy, sortAsc, cycleSort,
zoomIn, zoomOut,
viewMode, setViewMode,
}) => (
<div style={{
display: 'flex', alignItems: 'center', gap: 2, padding: '4px 6px',
borderBottom: '1px solid var(--border, #334155)',
background: 'var(--muted, #1e293b)',
}}>
{/* Navigation */}
<button onClick={goUp} disabled={!canGoUp} title="Go up (Backspace)" className="fb-tb-btn"
style={{ ...TB_BTN, opacity: canGoUp ? 1 : 0.3, cursor: canGoUp ? 'pointer' : 'default' }}>
<ArrowUp size={18} />
</button>
{/* Mount picker */}
{canChangeMount && availableMounts.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="fb-tb-btn"
title="Switch mount"
style={{ ...TB_BTN, gap: 4, fontSize: 13, fontWeight: 600 }}
>
<HardDrive size={14} />
<span>{mount}</span>
<ChevronDown size={10} style={{ opacity: 0.5 }} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[140px]">
{availableMounts.map(m => (
<DropdownMenuItem
key={m}
onClick={() => updateMount(m)}
className={m === mount ? 'font-semibold bg-accent' : ''}
>
<HardDrive className="h-3 w-3 mr-2 opacity-60" />
{m}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<div style={TB_SEP} />
{/* Breadcrumbs */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 2, overflow: 'hidden', padding: '0 4px' }}>
<button onClick={() => { updateMount(mountProp); updatePath(pathProp); }} title="Home" className="fb-tb-btn"
style={{ ...TB_BTN, flexShrink: 0 }}>
<Home size={14} />
</button>
<ChevronRight size={10} style={{ opacity: 0.3, flexShrink: 0 }} />
{breadcrumbs.map((c, i) => (
<React.Fragment key={c.path}>
{i > 0 && <ChevronRight size={10} style={{ opacity: 0.3, flexShrink: 0 }} />}
<button onClick={() => updatePath(c.path)} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: i === breadcrumbs.length - 1 ? 'var(--foreground, #e2e8f0)' : 'var(--muted-foreground, #94a3b8)',
fontWeight: i === breadcrumbs.length - 1 ? 600 : 400,
padding: '2px 3px', borderRadius: 3, whiteSpace: 'nowrap', fontSize: 13,
}}>
{c.label}
</button>
</React.Fragment>
))}
</div>
<div style={TB_SEP} />
{/* File actions */}
{selectedFile && (<>
<button onClick={handleView} title="View in browser" className="fb-tb-btn" style={TB_BTN}>
<ExternalLink size={18} />
</button>
{allowDownload && (
<button onClick={handleDownload} title="Download" className="fb-tb-btn" style={TB_BTN}>
<Download size={18} />
</button>
)}
<div style={TB_SEP} />
</>)}
{/* Sort */}
<button onClick={cycleSort} title={`Sort: ${sortBy} (${sortAsc ? 'asc' : 'desc'})`} className="fb-tb-btn"
style={{ ...TB_BTN, gap: 2 }}>
{sortIcons[sortBy]}
<span style={{ fontSize: 9, opacity: 0.6 }}>{sortAsc ? '↑' : '↓'}</span>
</button>
{/* Zoom */}
<button onClick={zoomOut} title="Zoom out" className="fb-tb-btn" style={TB_BTN}>
<ZoomOut size={18} />
</button>
<button onClick={zoomIn} title="Zoom in" className="fb-tb-btn" style={TB_BTN}>
<ZoomIn size={18} />
</button>
{/* View mode */}
<button onClick={() => setViewMode(viewMode === 'list' ? 'thumbs' : 'list')}
title={viewMode === 'list' ? 'Thumbnails' : 'List'} className="fb-tb-btn" style={TB_BTN}>
{viewMode === 'list' ? <LayoutGrid size={18} /> : <List size={18} />}
</button>
</div>
);
export default FileBrowserToolbar;

View File

@ -0,0 +1,484 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Loader2 } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import ImageLightbox from '@/components/ImageLightbox';
import LightboxText from '@/components/LightboxText';
import type { INode, SortKey, FileBrowserWidgetExtendedProps } from './types';
import { getMimeCategory, sortNodes, vfsUrl, formatSize } from './helpers';
import FileBrowserToolbar from './FileBrowserToolbar';
import FileListView from './FileListView';
import FileGridView from './FileGridView';
import FileDetailPanel from './FileDetailPanel';
// ── Main Component ───────────────────────────────────────────────
const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (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 / uncontrolled mode ────────────────────────────
const isControlled = !!onPathChange;
const [internalPath, setInternalPath] = useState(pathProp);
const [internalMount, setInternalMount] = useState(mountProp);
const mount = onMountChange ? mountProp : internalMount;
const currentPath = isControlled ? pathProp : internalPath;
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]);
// ── Available mounts ─────────────────────────────────────────
const [availableMounts, setAvailableMounts] = useState<string[]>([]);
useEffect(() => {
const headers: Record<string, string> = {};
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]);
// ── Core state ───────────────────────────────────────────────
const [nodes, setNodes] = useState<INode[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'list' | 'thumbs'>(initialViewMode);
const [sortBy, setSortBy] = useState<SortKey>(initialSort);
const [sortAsc, setSortAsc] = useState(true);
const [focusIdx, setFocusIdx] = useState(-1);
const [selected, setSelected] = useState<INode | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const returnTargetRef = useRef<string | null>(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<string, string> = {};
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]);
const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : '';
// ── Sorted items ─────────────────────────────────────────────
const canGoUp = currentPath !== '/' && currentPath !== '';
const sorted = useMemo(() => sortNodes(nodes, sortBy, sortAsc), [nodes, sortBy, sortAsc]);
const itemCount = sorted.length + (canGoUp ? 1 : 0);
const getNode = (idx: number): INode | null => {
if (canGoUp && idx === 0) return null;
return sorted[canGoUp ? idx - 1 : idx] ?? null;
};
// ── Navigation ───────────────────────────────────────────────
const goUp = useCallback(() => {
if (!canGoUp) return;
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
const leaving = parts.pop();
if (leaving) returnTargetRef.current = leaving;
updatePath(parts.length ? parts.join('/') : '/');
}, [currentPath, canGoUp, updatePath]);
const getFileUrl = (node: INode) => {
const base = vfsUrl('get', mount, node.path);
return tokenParam ? `${base}?${tokenParam}` : base;
};
const handleView = () => { if (selected) window.open(getFileUrl(selected), '_blank'); };
const handleDownload = () => {
if (!selected) return;
const a = document.createElement('a');
a.href = getFileUrl(selected);
a.download = selected.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
// ── Sort toggle ──────────────────────────────────────────────
const cycleSort = () => {
const keys: SortKey[] = ['name', 'ext', 'date', 'type'];
const i = keys.indexOf(sortBy);
if (sortAsc) { setSortAsc(false); } else { setSortBy(keys[(i + 1) % keys.length]); setSortAsc(true); }
};
// ── Breadcrumbs ──────────────────────────────────────────────
const breadcrumbs = useMemo(() => {
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
const crumbs = [{ label: '/', path: '/' }];
let acc = '';
for (const p of parts) { acc += (acc ? '/' : '') + p; crumbs.push({ label: p, path: acc }); }
return crumbs;
}, [currentPath, mount]);
// Return-to-sender focus
useEffect(() => {
const target = returnTargetRef.current;
if (!target || sorted.length === 0) return;
const idx = sorted.findIndex(n => n.name === target);
if (idx >= 0) {
const realIdx = canGoUp ? idx + 1 : idx;
setFocusIdx(realIdx);
setSelected(sorted[idx]);
requestAnimationFrame(() => {
if (!listRef.current) return;
const items = listRef.current.querySelectorAll('[data-fb-idx]');
const el = items[realIdx] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
});
}
returnTargetRef.current = null;
}, [sorted, canGoUp]);
// ── Keyboard navigation ──────────────────────────────────────
const scrollItemIntoView = useCallback((idx: number) => {
if (!listRef.current) return;
const items = listRef.current.querySelectorAll('[data-fb-idx]');
const el = items[idx] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
}, []);
const getGridCols = useCallback((): number => {
if (viewMode !== 'thumbs' || !listRef.current) return 1;
const style = getComputedStyle(listRef.current);
const cols = style.gridTemplateColumns.split(' ').length;
return Math.max(1, cols);
}, [viewMode]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (itemCount === 0) return;
const moveFocus = (next: number) => {
next = Math.max(0, Math.min(itemCount - 1, next));
setFocusIdx(next);
const node = getNode(next);
if (node) setSelected(node); else setSelected(null);
scrollItemIntoView(next);
};
const cols = getGridCols();
switch (e.key) {
case 'ArrowRight':
case 'j': {
e.preventDefault();
moveFocus(focusIdx < itemCount - 1 ? focusIdx + 1 : 0);
break;
}
case 'ArrowLeft':
case 'k': {
e.preventDefault();
moveFocus(focusIdx > 0 ? focusIdx - 1 : itemCount - 1);
break;
}
case 'ArrowDown': {
e.preventDefault();
moveFocus(focusIdx + cols);
break;
}
case 'ArrowUp': {
e.preventDefault();
moveFocus(focusIdx - cols);
break;
}
case 'Enter':
case ' ':
case 'l': {
e.preventDefault();
if (focusIdx < 0) break;
const node = getNode(focusIdx);
if (!node) { goUp(); break; }
const cat = getMimeCategory(node);
if (cat === 'dir') updatePath(node.path || node.name);
else if ((cat === 'image' || cat === 'video') && allowLightbox) setLightboxNode(node);
else if (allowFileViewer) openTextLightbox(node);
break;
}
case 'Backspace':
case 'h': {
e.preventDefault();
goUp();
break;
}
case 'Home': {
e.preventDefault();
moveFocus(0);
break;
}
case 'End': {
e.preventDefault();
moveFocus(itemCount - 1);
break;
}
case 'Escape': {
e.preventDefault();
setSelected(null);
setFocusIdx(-1);
break;
}
}
}, [focusIdx, itemCount, goUp, scrollItemIntoView, sorted, canGoUp, getGridCols, allowLightbox, allowFileViewer, updatePath]);
useEffect(() => { containerRef.current?.focus(); }, [currentPath]);
const selectedFile = selected && getMimeCategory(selected) !== 'dir' ? selected : null;
// ── Lightbox state ───────────────────────────────────────────
const [lightboxNode, setLightboxNode] = useState<INode | null>(null);
const mediaNodes = useMemo(() => sorted.filter(n => { const c = getMimeCategory(n); return c === 'image' || c === 'video'; }), [sorted]);
const lightboxIdx = lightboxNode ? mediaNodes.findIndex(n => n.path === lightboxNode.path) : -1;
const lightboxPrev = () => { if (lightboxIdx > 0) setLightboxNode(mediaNodes[lightboxIdx - 1]); };
const lightboxNext = () => { if (lightboxIdx < mediaNodes.length - 1) setLightboxNode(mediaNodes[lightboxIdx + 1]); };
const closeLightbox = () => { setLightboxNode(null); setTimeout(() => containerRef.current?.focus(), 0); };
// ── Text lightbox state ──────────────────────────────────────
const [textLightboxNode, setTextLightboxNode] = useState<INode | null>(null);
const openTextLightbox = (node: INode) => setTextLightboxNode(node);
const closeTextLightbox = () => { setTextLightboxNode(null); setTimeout(() => containerRef.current?.focus(), 0); };
// ── Click handlers ───────────────────────────────────────────
const onItemClick = (idx: number) => {
setFocusIdx(idx);
const node = getNode(idx);
if (!node) return;
setSelected(prev => prev?.path === node.path ? null : node);
};
const onItemDoubleClick = (idx: number) => {
const node = getNode(idx);
if (!node) { goUp(); return; }
const cat = getMimeCategory(node);
if (cat === 'dir') {
updatePath(node.path || node.name);
} else if ((cat === 'image' || cat === 'video') && allowLightbox) {
setLightboxNode(node);
} else if (allowFileViewer) {
openTextLightbox(node);
}
};
// ── Render ───────────────────────────────────────────────────
return (
<div
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
style={{
display: 'flex', flexDirection: 'column', height: '100%',
border: '1px solid var(--border, #334155)', borderRadius: 6, overflow: 'hidden',
background: 'var(--background, #0f172a)', color: 'var(--foreground, #e2e8f0)',
fontFamily: 'var(--font-sans, system-ui, sans-serif)', outline: 'none',
}}
>
<style>{`
.fb-row:hover { background: var(--accent, #334155) !important; }
.fb-thumb:hover { border-color: var(--ring, #3b82f6) !important; background: var(--accent, #1e293b) !important; }
.fb-tb-btn:hover { background: var(--accent, #334155) !important; color: var(--foreground, #e2e8f0) !important; }
.fb-mount-item:hover { background: var(--accent, #334155) !important; }
@media (max-width: 767px) { .fb-detail-pane { display: none !important; } }
`}</style>
{/* ═══ Toolbar ═══════════════════════════════════ */}
{showToolbar && (
<FileBrowserToolbar
canGoUp={canGoUp}
goUp={goUp}
canChangeMount={canChangeMount}
availableMounts={availableMounts}
mount={mount}
updateMount={updateMount}
mountProp={mountProp}
pathProp={pathProp}
updatePath={updatePath}
breadcrumbs={breadcrumbs}
selectedFile={selectedFile}
handleView={handleView}
handleDownload={handleDownload}
allowDownload={allowDownload}
sortBy={sortBy}
sortAsc={sortAsc}
cycleSort={cycleSort}
zoomIn={zoomIn}
zoomOut={zoomOut}
viewMode={viewMode}
setViewMode={setViewMode}
/>
)}
{/* ═══ Content ═══════════════════════════════════ */}
{loading ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, opacity: 0.6 }}>
<Loader2 size={16} className="animate-spin" />
<span style={{ fontSize: 14 }}>Loading</span>
</div>
) : error ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, color: '#ef4444' }}>
{error}
</div>
) : itemCount === 0 ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, opacity: 0.5 }}>
Empty directory
</div>
) : (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{viewMode === 'list' ? (
<FileListView
listRef={listRef}
sorted={sorted}
canGoUp={canGoUp}
goUp={goUp}
focusIdx={focusIdx}
setFocusIdx={setFocusIdx}
selected={selected}
onItemClick={onItemClick}
onItemDoubleClick={onItemDoubleClick}
fontSize={fontSize}
mode={mode}
/>
) : (
<FileGridView
listRef={listRef}
sorted={sorted}
canGoUp={canGoUp}
goUp={goUp}
focusIdx={focusIdx}
setFocusIdx={setFocusIdx}
selected={selected}
onItemClick={onItemClick}
onItemDoubleClick={onItemDoubleClick}
thumbSize={thumbSize}
mount={mount}
tokenParam={tokenParam}
/>
)}
{/* Detail panel (advanced, desktop only) */}
{mode === 'advanced' && selectedFile && (
<FileDetailPanel file={selectedFile} fileUrl={getFileUrl(selectedFile)} />
)}
</div>
)}
{/* ═══ Status bar ════════════════════════════════ */}
<div style={{
padding: '4px 10px', fontSize: 12, borderTop: '1px solid var(--border, #334155)',
color: 'var(--muted-foreground, #64748b)', display: 'flex', justifyContent: 'space-between',
background: 'var(--muted, #1e293b)',
}}>
<span>
{sorted.length} item{sorted.length !== 1 ? 's' : ''}
{' · '}{formatSize(sorted.reduce((sum, n) => sum + (n.size || 0), 0))}
{selectedFile ? ` · ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''}
</span>
<span>{mount}:{currentPath || '/'}</span>
</div>
{/* ═══ Lightboxes ════════════════════════════════ */}
<ImageLightbox
isOpen={!!lightboxNode}
onClose={closeLightbox}
imageUrl={lightboxNode ? getFileUrl(lightboxNode) : ''}
imageTitle={lightboxNode?.name || ''}
currentIndex={lightboxIdx}
totalCount={mediaNodes.length}
onNavigate={(dir) => dir === 'prev' ? lightboxPrev() : lightboxNext()}
showPrompt={false}
/>
<LightboxText
isOpen={!!textLightboxNode}
onClose={closeTextLightbox}
url={textLightboxNode ? getFileUrl(textLightboxNode) : ''}
fileName={textLightboxNode?.name || ''}
/>
</div>
);
};
export { FileBrowserWidget };
export default FileBrowserWidget;

View File

@ -0,0 +1,59 @@
import React from 'react';
import { Info } from 'lucide-react';
import ResponsiveImage from '@/components/ResponsiveImage';
import type { INode } from './types';
import { getMimeCategory, formatSize, formatDate } from './helpers';
// ── Props ────────────────────────────────────────────────────────
interface FileDetailPanelProps {
file: INode;
fileUrl: string;
}
// ── Component ────────────────────────────────────────────────────
const FileDetailPanel: React.FC<FileDetailPanelProps> = ({ file, fileUrl }) => {
const cat = getMimeCategory(file);
return (
<div className="fb-detail-pane" style={{
width: 200, borderLeft: '1px solid var(--border, #334155)',
padding: 10, fontSize: 11, overflowY: 'auto', flexShrink: 0,
background: 'var(--muted, #1e293b)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Info size={14} />
<span style={{ fontWeight: 600 }}>Details</span>
</div>
{cat === 'image' && (
<ResponsiveImage src={fileUrl} alt={file.name}
responsiveSizes={[200, 400]} imgClassName="" style={{ width: '100%', borderRadius: 4, marginBottom: 8, objectFit: 'contain' }} />
)}
{cat === 'video' && (
<video key={file.path} src={fileUrl} controls muted preload="metadata"
style={{ width: '100%', borderRadius: 4, marginBottom: 8 }} />
)}
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{([
['Name', file.name],
['Path', file.path],
['Size', formatSize(file.size)],
['Modified', formatDate(file.mtime)],
['MIME', file.mime || '—'],
['Type', getMimeCategory(file)],
] as [string, string][]).map(([label, val]) => (
<tr key={label}>
<td style={{ padding: '3px 4px 3px 0', color: 'var(--muted-foreground, #94a3b8)', whiteSpace: 'nowrap', verticalAlign: 'top' }}>{label}</td>
<td style={{ padding: '3px 0', wordBreak: 'break-all' }}>{val}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default FileDetailPanel;

View File

@ -0,0 +1,76 @@
import React from 'react';
import { ArrowUp } from 'lucide-react';
import type { INode } from './types';
import { FOCUS_BG, SELECTED_BG, SELECTED_BORDER } from './types';
import { getMimeCategory, CATEGORY_STYLE } from './helpers';
import { NodeIcon, ThumbPreview } from './ThumbPreview';
// ── Props ────────────────────────────────────────────────────────
interface FileGridViewProps {
listRef: React.RefObject<HTMLDivElement>;
sorted: INode[];
canGoUp: boolean;
goUp: () => void;
focusIdx: number;
setFocusIdx: (idx: number) => void;
selected: INode | null;
onItemClick: (idx: number) => void;
onItemDoubleClick: (idx: number) => void;
thumbSize: number;
mount: string;
tokenParam: string;
}
// ── Component ────────────────────────────────────────────────────
const FileGridView: React.FC<FileGridViewProps> = ({
listRef, sorted, canGoUp, goUp, focusIdx, setFocusIdx,
selected, onItemClick, onItemDoubleClick, thumbSize, mount, tokenParam,
}) => (
<div ref={listRef as any} style={{
overflowY: 'auto', flex: 1, display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`, gap: 2, padding: 2,
}}>
{canGoUp && (
<div data-fb-idx={0} onClick={() => setFocusIdx(0)} onDoubleClick={goUp} className="fb-thumb" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: 2, borderRadius: 2, cursor: 'pointer', gap: 2, aspectRatio: '1',
borderWidth: 1, borderColor: focusIdx === 0 ? SELECTED_BORDER : 'transparent',
borderStyle: 'solid',
}}>
<ArrowUp size={24} style={{ color: CATEGORY_STYLE.dir.color }} />
<span style={{ fontSize: 14 }}>..</span>
</div>
)}
{sorted.map((node, i) => {
const idx = (canGoUp ? i + 1 : i);
const isDir = getMimeCategory(node) === 'dir';
const isFocused = focusIdx === idx;
const isSelected = selected?.path === node.path;
return (
<div key={node.path || node.name} data-fb-idx={idx}
onClick={() => onItemClick(idx)}
onDoubleClick={() => onItemDoubleClick(idx)}
className="fb-thumb" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: 6, borderRadius: 6, cursor: 'pointer', gap: 4, overflow: 'hidden', aspectRatio: '1',
borderWidth: isSelected ? 2 : 1,
borderColor: isSelected ? SELECTED_BORDER : isFocused ? SELECTED_BORDER : 'transparent',
borderStyle: isSelected ? 'outset' : 'solid',
background: isSelected ? SELECTED_BG : isFocused ? FOCUS_BG : 'transparent',
}}>
{isDir ? <NodeIcon node={node} size={48} /> : <ThumbPreview node={node} mount={mount} tokenParam={tokenParam} />}
<span style={{
fontSize: 14, textAlign: 'center', width: '100%',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{node.name}
</span>
</div>
);
})}
</div>
);
export default FileGridView;

View File

@ -0,0 +1,77 @@
import React from 'react';
import { ArrowUp } from 'lucide-react';
import type { INode } from './types';
import { FOCUS_BG, SELECTED_BG, SELECTED_BORDER } from './types';
import { getMimeCategory, CATEGORY_STYLE, formatSize, formatDate } from './helpers';
import { NodeIcon } from './ThumbPreview';
// ── Props ────────────────────────────────────────────────────────
interface FileListViewProps {
listRef: React.RefObject<HTMLDivElement>;
sorted: INode[];
canGoUp: boolean;
goUp: () => void;
focusIdx: number;
setFocusIdx: (idx: number) => void;
selected: INode | null;
onItemClick: (idx: number) => void;
onItemDoubleClick: (idx: number) => void;
fontSize: number;
mode: string;
}
// ── Component ────────────────────────────────────────────────────
const FileListView: React.FC<FileListViewProps> = ({
listRef, sorted, canGoUp, goUp, focusIdx, setFocusIdx,
selected, onItemClick, onItemDoubleClick, fontSize, mode,
}) => (
<div ref={listRef as any} style={{ overflowY: 'auto', flex: 1 }}>
{canGoUp && (
<div data-fb-idx={0} onClick={() => setFocusIdx(0)} onDoubleClick={goUp}
className="fb-row" style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
cursor: 'pointer', fontSize, borderBottom: '1px solid var(--border, #e5e7eb)',
background: focusIdx === 0 ? FOCUS_BG : 'transparent',
}}>
<ArrowUp size={14} style={{ color: CATEGORY_STYLE.dir.color }} />
<span style={{ fontWeight: 500 }}>..</span>
</div>
)}
{sorted.map((node, i) => {
const idx = (canGoUp ? i + 1 : i);
const isDir = getMimeCategory(node) === 'dir';
const isFocused = focusIdx === idx;
const isSelected = selected?.path === node.path;
return (
<div key={node.path || node.name} data-fb-idx={idx}
onClick={() => onItemClick(idx)}
onDoubleClick={() => onItemDoubleClick(idx)}
className="fb-row" style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
cursor: 'pointer', fontSize,
borderBottomWidth: 1, borderBottomColor: 'var(--border, #e5e7eb)', borderBottomStyle: 'solid',
background: isSelected ? SELECTED_BG : isFocused ? FOCUS_BG : 'transparent',
borderLeftWidth: 2, borderLeftColor: isSelected ? SELECTED_BORDER : 'transparent',
borderLeftStyle: isSelected ? 'outset' : 'solid',
}}>
<NodeIcon node={node} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{node.name}</span>
{!isDir && (
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0 }}>
{formatSize(node.size)}
</span>
)}
{mode === 'advanced' && node.mtime && (
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0, width: 120, textAlign: 'right' }}>
{formatDate(node.mtime)}
</span>
)}
</div>
);
})}
</div>
);
export default FileListView;

View File

@ -0,0 +1,35 @@
import React from 'react';
import { Film } from 'lucide-react';
import ResponsiveImage from '@/components/ResponsiveImage';
import type { INode } from './types';
import { getMimeCategory, CATEGORY_STYLE, vfsUrl } from './helpers';
// ── NodeIcon ─────────────────────────────────────────────────────
export function NodeIcon({ node, size = 14 }: { node: INode; size?: number }) {
const cat = getMimeCategory(node);
const { icon: Icon, color } = CATEGORY_STYLE[cat];
return <Icon size={size} style={{ color, flexShrink: 0 }} />;
}
// ── ThumbPreview ─────────────────────────────────────────────────
export 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 <ResponsiveImage src={fullUrl} alt={node.name} loading="lazy" responsiveSizes={[128, 256]} className="" imgClassName="" style={{ width: '100%', height: '100%', flex: 1, objectFit: 'cover', borderRadius: 4 }} />;
}
if (cat === 'video') {
return (
<div style={{ position: 'relative', width: '100%', flex: 1 }}>
<video src={fullUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 4 }} />
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', borderRadius: 4 }}>
<Film size={20} style={{ color: '#fff', opacity: 0.8 }} />
</div>
</div>
);
}
return <NodeIcon node={node} size={28} />;
}

View File

@ -0,0 +1,103 @@
import React from 'react';
import {
Folder, File, Image, Film, Music, FileCode, FileText as FileTextIcon,
Archive, FileSpreadsheet, Presentation
} from 'lucide-react';
import type { INode, SortKey, MimeCategory } from './types';
// ── Extension sets ───────────────────────────────────────────────
export const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff', 'tif']);
export const VIDEO_EXTS = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi', 'flv', 'wmv', 'm4v', 'ogv']);
export const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'opus']);
export 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']);
export const DOC_EXTS = new Set(['md', 'txt', 'rtf', 'pdf', 'doc', 'docx', 'odt', 'tex', 'log']);
export const ARCHIVE_EXTS = new Set(['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'zst', 'tgz']);
export const SPREADSHEET_EXTS = new Set(['xls', 'xlsx', 'csv', 'ods', 'tsv']);
export const PRESENTATION_EXTS = new Set(['ppt', 'pptx', 'odp', 'key']);
// ── MIME helpers ─────────────────────────────────────────────────
export function getExt(name: string): string {
const i = name.lastIndexOf('.');
return i > 0 ? name.slice(i + 1).toLowerCase() : '';
}
export 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';
}
export const CATEGORY_STYLE: Record<MimeCategory, { icon: React.FC<any>; 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' },
};
// ── Format helpers ───────────────────────────────────────────────
export 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]}`;
}
export 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 ─────────────────────────────────────────────────────────
export 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. */
export 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)}`;
}

View File

@ -0,0 +1,4 @@
export { FileBrowserWidget } from './FileBrowserWidget';
export type { FileBrowserWidgetExtendedProps, INode, SortKey, MimeCategory } from './types';
export { getMimeCategory, formatSize, formatDate, vfsUrl, sortNodes } from './helpers';
export { NodeIcon, ThumbPreview } from './ThumbPreview';

View File

@ -0,0 +1,43 @@
import type { FileBrowserWidgetProps } from '@polymech/shared';
import React from 'react';
// ── Data types ───────────────────────────────────────────────────
export interface INode {
name: string;
path: string;
size: number;
mtime?: number;
mime?: string;
parent: string;
type: string;
}
export type SortKey = 'name' | 'ext' | 'date' | 'type';
export type MimeCategory = 'dir' | 'image' | 'video' | 'audio' | 'code' | 'document' | 'archive' | 'spreadsheet' | 'presentation' | 'other';
// ── Extended props (adds controlled-mode callbacks) ─────────────
export interface FileBrowserWidgetExtendedProps extends Omit<FileBrowserWidgetProps, 'path' | 'variables'> {
variables?: any;
path?: string;
onPathChange?: (path: string) => void;
onMountChange?: (mount: string) => void;
onSelect?: (path: string | null) => void;
}
// ── Toolbar style constants ─────────────────────────────────────
export 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,
};
export const TB_BTN_ACTIVE: React.CSSProperties = { ...TB_BTN, color: 'var(--foreground, #e2e8f0)' };
export const TB_SEP: React.CSSProperties = { width: 1, height: 18, background: 'var(--border, #334155)', flexShrink: 0 };
// ── Selection style constants ───────────────────────────────────
export const FOCUS_BG = 'var(--accent, #334155)';
export const SELECTED_BG = 'rgba(59, 130, 246, 0.15)';
export const SELECTED_BORDER = 'var(--ring, #3b82f6)';