mono/packages/ui/src/modules/pages/FileBrowserWidget.tsx

751 lines
40 KiB
TypeScript

import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
Folder, File, ArrowUp, List, LayoutGrid,
ArrowUpDown, Clock, FileType, Type,
ChevronRight, Info, Loader2, Download, ExternalLink, X, ChevronLeft, ZoomIn, ZoomOut,
Image, Film, Music, FileCode, FileText as FileTextIcon,
Archive, FileSpreadsheet, Presentation
} from 'lucide-react';
import type { FileBrowserWidgetProps } from '@polymech/shared';
import { useAuth } from '@/hooks/useAuth';
// ── 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];
}
// ── Thumbnail helper ─────────────────────────────────────────────
function ThumbPreview({ node, mount, height = 64, tokenParam = '' }: { node: INode; mount: string; height?: number; tokenParam?: string }) {
const cat = getMimeCategory(node);
const baseUrl = `/api/vfs/get/${encodeURIComponent(mount)}/${node.path}`;
const fileUrl = tokenParam ? `${baseUrl}?${tokenParam}` : baseUrl;
if (cat === 'image') {
return <img src={fileUrl} alt={node.name} loading="lazy" style={{ width: '100%', height, objectFit: 'cover', borderRadius: 4 }} />;
}
if (cat === 'video') {
return (
<div style={{ position: 'relative', width: '100%', height }}>
<video src={fileUrl} muted preload="metadata" style={{ width: '100%', height, 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<FileBrowserWidgetProps & { variables?: any }> = (props) => {
const {
mount = 'root',
path: initialPath = '/',
glob = '*.*',
mode = 'simple',
viewMode: initialViewMode = 'list',
sortBy: initialSort = 'name',
showToolbar = true,
} = props;
const { session } = useAuth();
const accessToken = session?.access_token;
const [currentPath, setCurrentPath] = useState(initialPath);
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);
// ── Zoom (persisted) ─────────────────────────────────────────
const [thumbSize, setThumbSize] = useState(() => {
const v = localStorage.getItem('fb-thumb-size');
return v ? Math.max(60, Math.min(200, Number(v))) : 100;
});
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(-1);
try {
const clean = dirPath.replace(/^\/+/, '');
const base = clean
? `/api/vfs/ls/${encodeURIComponent(mount)}/${clean}`
: `/api/vfs/ls/${encodeURIComponent(mount)}`;
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(() => { setCurrentPath(initialPath); }, [initialPath]);
// 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') {
setCurrentPath(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);
parts.pop();
setCurrentPath(parts.length ? parts.join('/') : '/');
}, [currentPath, canGoUp]);
const getFileUrl = (node: INode) => {
const base = `/api/vfs/get/${encodeURIComponent(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: mount, path: '/' }];
let acc = '';
for (const p of parts) { acc += (acc ? '/' : '') + p; crumbs.push({ label: p, path: acc }); }
return crumbs;
}, [currentPath, mount]);
// ── 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 handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (itemCount === 0) return;
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
case 'j': {
e.preventDefault();
const next = focusIdx < itemCount - 1 ? focusIdx + 1 : 0;
setFocusIdx(next);
const node = getNode(next);
if (node) setSelected(node);
else setSelected(null);
scrollItemIntoView(next);
break;
}
case 'ArrowUp':
case 'ArrowLeft':
case 'k': {
e.preventDefault();
const prev = focusIdx > 0 ? focusIdx - 1 : itemCount - 1;
setFocusIdx(prev);
const node = getNode(prev);
if (node) setSelected(node);
else setSelected(null);
scrollItemIntoView(prev);
break;
}
case 'Enter':
case 'l': {
e.preventDefault();
if (focusIdx < 0) break;
const node = getNode(focusIdx);
if (!node) { goUp(); break; }
openNode(node);
break;
}
case 'Backspace':
case 'h': {
e.preventDefault();
goUp();
break;
}
case 'Home': {
e.preventDefault();
setFocusIdx(0);
const node = getNode(0);
if (node) setSelected(node); else setSelected(null);
scrollItemIntoView(0);
break;
}
case 'End': {
e.preventDefault();
const last = itemCount - 1;
setFocusIdx(last);
const node = getNode(last);
if (node) setSelected(node); else setSelected(null);
scrollItemIntoView(last);
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]);
// 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 lightboxIsVideo = lightboxNode ? getMimeCategory(lightboxNode) === 'video' : false;
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);
};
// ── 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') {
setCurrentPath(node.path || node.name);
} else if (cat === 'image' || cat === 'video') {
setLightboxNode(node);
} else {
window.open(getFileUrl(node), '_blank');
}
};
// ── 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; }
@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>
{/* Breadcrumbs */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 1, overflow: 'hidden', padding: '0 4px' }}>
{breadcrumbs.map((c, i) => (
<React.Fragment key={c.path}>
{i > 0 && <ChevronRight size={10} style={{ opacity: 0.3, flexShrink: 0 }} />}
<button onClick={() => setCurrentPath(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: 11,
}}>
{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>
<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); goUp(); }}
className="fb-row" style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
cursor: 'pointer', fontSize, borderBottom: '1px solid var(--border, #1e293b)',
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, #1e293b)', 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: 6, padding: 8,
}}>
{canGoUp && (
<div data-fb-idx={0} onClick={() => { setFocusIdx(0); goUp(); }} className="fb-thumb" style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
padding: 8, borderRadius: 6, cursor: 'pointer', gap: 4,
borderWidth: 1, borderColor: focusIdx === 0 ? selectedBorder : 'var(--border, #334155)',
borderStyle: focusIdx === 0 ? 'outset' : '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',
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={28} /> : <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' && (
<img src={getFileUrl(selectedFile)} alt={selectedFile.name}
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: '3px 10px', fontSize: 10, 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' : ''}{selectedFile ? ` · ${selectedFile.name}` : ''}</span>
<span>{mount}:{currentPath || '/'}</span>
</div>
{/* ═══ Lightbox ═════════════════════════════════ */}
{lightboxNode && (
<div onClick={() => closeLightbox()} onKeyDown={(e) => {
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') lightboxPrev();
if (e.key === 'ArrowRight') lightboxNext();
e.stopPropagation();
}} tabIndex={0} ref={el => el?.focus()} style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'zoom-out',
}}>
{/* Close */}
<button onClick={() => closeLightbox()} style={{
position: 'absolute', top: 16, right: 16, background: 'none', border: 'none',
color: '#fff', cursor: 'pointer', opacity: 0.7,
}}><X size={24} /></button>
{/* Prev */}
{lightboxIdx > 0 && (
<button onClick={(e) => { e.stopPropagation(); lightboxPrev(); }} style={{
position: 'absolute', left: 16, top: '50%', transform: 'translateY(-50%)',
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%',
width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}><ChevronLeft size={22} /></button>
)}
{/* Media */}
{lightboxIsVideo ? (
<video
key={lightboxNode.path}
onClick={(e) => e.stopPropagation()}
src={getFileUrl(lightboxNode)}
controls autoPlay
style={{ maxWidth: '90vw', maxHeight: '90vh', cursor: 'default', borderRadius: 4, background: '#000' }}
/>
) : (
<img
onClick={(e) => e.stopPropagation()}
src={getFileUrl(lightboxNode)}
alt={lightboxNode.name}
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', cursor: 'default', borderRadius: 4 }}
/>
)}
{/* Next */}
{lightboxIdx < mediaNodes.length - 1 && (
<button onClick={(e) => { e.stopPropagation(); lightboxNext(); }} style={{
position: 'absolute', right: 16, top: '50%', transform: 'translateY(-50%)',
background: 'rgba(0,0,0,0.5)', border: 'none', borderRadius: '50%',
width: 40, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}><ChevronRight size={22} /></button>
)}
{/* Counter + filename */}
<div style={{
position: 'absolute', bottom: 16, left: '50%', transform: 'translateX(-50%)',
color: '#fff', fontSize: 14, opacity: 0.7, textAlign: 'center',
}}>
{lightboxNode.name} · {lightboxIdx + 1}/{mediaNodes.length}
</div>
</div>
)}
</div>
);
};
export default FileBrowserWidget;