filebrowser ole :)

This commit is contained in:
lovebird 2026-02-20 17:55:07 +01:00
parent 784dc2bafe
commit 044791d496
7 changed files with 614 additions and 173 deletions

View File

@ -167,10 +167,6 @@ export const FileBrowserWidgetSchema = z.object({
viewMode: z.enum(['list', 'thumbs']).default('list'),
sortBy: z.enum(['name', 'ext', 'date', 'type']).default('name'),
showToolbar: z.boolean().default(true),
canChangeMount: z.boolean().default(true),
allowFileViewer: z.boolean().default(true),
allowLightbox: z.boolean().default(true),
allowDownload: z.boolean().default(true),
variables: WidgetVariablesSchema,
});

View File

@ -0,0 +1,192 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Copy, Check, Download, FileCode, FileText } from 'lucide-react';
interface LightboxTextProps {
isOpen: boolean;
onClose: () => void;
/** URL to fetch text content from */
url: string;
/** File name for display and language detection */
fileName: string;
}
// Extension → language label for display
const EXT_LANG: Record<string, string> = {
ts: 'TypeScript', tsx: 'TypeScript (JSX)', js: 'JavaScript', jsx: 'JavaScript (JSX)',
py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust', java: 'Java',
c: 'C', cpp: 'C++', h: 'C Header', hpp: 'C++ Header', cs: 'C#',
swift: 'Swift', kt: 'Kotlin', lua: 'Lua', php: 'PHP', r: 'R',
sh: 'Shell', bash: 'Bash', zsh: 'Zsh', ps1: 'PowerShell', bat: 'Batch', cmd: 'Batch',
sql: 'SQL', html: 'HTML', htm: 'HTML', css: 'CSS',
scss: 'SCSS', sass: 'Sass', less: 'Less',
json: 'JSON', yaml: 'YAML', yml: 'YAML', toml: 'TOML', xml: 'XML',
vue: 'Vue', svelte: 'Svelte',
md: 'Markdown', txt: 'Text', log: 'Log', csv: 'CSV', tsv: 'TSV',
tex: 'LaTeX', ini: 'INI', cfg: 'Config', conf: 'Config',
dockerfile: 'Dockerfile', makefile: 'Makefile',
};
function getLanguage(fileName: string): string {
const lower = fileName.toLowerCase();
// Handle extensionless names
if (lower === 'dockerfile') return 'Dockerfile';
if (lower === 'makefile') return 'Makefile';
const dot = lower.lastIndexOf('.');
if (dot < 0) return 'Text';
const ext = lower.slice(dot + 1);
return EXT_LANG[ext] || 'Text';
}
export default function LightboxText({ isOpen, onClose, url, fileName }: LightboxTextProps) {
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!isOpen || !url) return;
setLoading(true);
setError(null);
setContent(null);
fetch(url)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then(setContent)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [isOpen, url]);
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [isOpen, onClose]);
const handleCopy = async () => {
if (!content) return;
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const lineCount = content ? content.split('\n').length : 0;
const language = getLanguage(fileName);
const isCode = language !== 'Text' && language !== 'Log';
if (!isOpen) return null;
return createPortal(
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 99998,
background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20,
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
background: '#1e1e2e', color: '#cdd6f4', borderRadius: 10,
width: '90vw', maxWidth: 900, maxHeight: '90vh',
display: 'flex', flexDirection: 'column',
boxShadow: '0 16px 48px rgba(0,0,0,0.5)',
border: '1px solid #313244',
overflow: 'hidden',
}}
>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px',
borderBottom: '1px solid #313244', background: '#181825',
flexShrink: 0,
}}>
{isCode ? <FileCode size={16} style={{ color: '#89b4fa' }} /> : <FileText size={16} style={{ color: '#a6adc8' }} />}
<span style={{ fontWeight: 600, fontSize: 14, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fileName}
</span>
<span style={{ fontSize: 11, color: '#6c7086', flexShrink: 0 }}>
{language}{lineCount > 0 ? ` · ${lineCount} lines` : ''}
</span>
<button
onClick={handleCopy}
title="Copy to clipboard"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: copied ? '#a6e3a1' : '#6c7086', padding: 4, borderRadius: 4,
display: 'flex', alignItems: 'center',
}}
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
<a
href={url}
download={fileName}
title="Download"
style={{
color: '#6c7086', padding: 4, borderRadius: 4,
display: 'flex', alignItems: 'center',
}}
>
<Download size={16} />
</a>
<button
onClick={onClose}
title="Close (Esc)"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#6c7086', padding: 4, borderRadius: 4,
display: 'flex', alignItems: 'center',
}}
>
<X size={18} />
</button>
</div>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', position: 'relative' }}>
{loading && (
<div style={{ padding: 40, textAlign: 'center', color: '#6c7086' }}>
Loading
</div>
)}
{error && (
<div style={{ padding: 40, textAlign: 'center', color: '#f38ba8' }}>
Failed to load: {error}
</div>
)}
{content !== null && (
<div style={{ display: 'flex', fontSize: 13, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace", lineHeight: 1.6 }}>
{/* Line numbers */}
<div style={{
padding: '12px 0', textAlign: 'right', userSelect: 'none',
color: '#45475a', borderRight: '1px solid #313244',
flexShrink: 0, minWidth: 48, background: '#181825',
position: 'sticky', left: 0,
}}>
{content.split('\n').map((_, i) => (
<div key={i} style={{ padding: '0 12px' }}>{i + 1}</div>
))}
</div>
{/* Code */}
<pre style={{
flex: 1, margin: 0, padding: 12, overflow: 'auto',
whiteSpace: 'pre', tabSize: 4,
}}>
{content}
</pre>
</div>
)}
</div>
</div>
</div>,
document.body
);
}

View File

@ -7,7 +7,7 @@ import { Separator } from "@/components/ui/separator";
export default function StorageManager() {
// Default to 'root' mount, but we could allow selecting mounts if there are multiple.
// For now assuming 'root' is the main VFS.
const [mount, setMount] = useState("root");
const [mount, setMount] = useState("home");
const [currentPath, setCurrentPath] = useState("/");
const [selectedPath, setSelectedPath] = useState<string | null>(null);
@ -23,14 +23,14 @@ export default function StorageManager() {
<h2 className="font-semibold">File Browser</h2>
</div>
<div className="flex-1 overflow-auto bg-background">
{/*
FileBrowserWidget now accepts path/onPathChange.
We pass variables={{}} to satisfy the interface if validation is strict,
though we made it optional in our fix (or will).
*/}
<FileBrowserWidget
mount={mount}
path={currentPath}
onMountChange={(m) => {
setMount(m);
setCurrentPath('/');
setSelectedPath(null);
}}
onPathChange={(p) => {
setCurrentPath(p);
setSelectedPath(null); // Clear selection when navigating

View File

@ -1,14 +1,13 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { FeedPost } from '@/lib/db';
import React, { useEffect, useRef } from 'react';
import { FeedCard } from './FeedCard';
import { useAuth } from '@/hooks/useAuth';
import * as db from '@/lib/db';
import { Loader2 } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useProfiles } from '@/contexts/ProfilesContext';
import { useFeedData, FeedSortOption } from '@/hooks/useFeedData';
import { useFeedCache } from '@/contexts/FeedCacheContext';
import { useOrganization } from '@/contexts/OrganizationContext';
import { FeedPost } from '@/modules/posts/client-posts';
interface MobileFeedProps {
source?: 'home' | 'collection' | 'tag' | 'user';

View File

@ -10,13 +10,14 @@ import { ImagePickerDialog } from './ImagePickerDialog';
import { PagePickerDialog } from '@/modules/pages/PagePickerDialog';
import { Image as ImageIcon, Maximize2, FileText, Sparkles } from 'lucide-react';
import { Image as ImageIcon, Maximize2, FileText, Sparkles, FolderOpen } from 'lucide-react';
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import MarkdownEditor from '@/components/MarkdownEditorEx';
import { TailwindClassPicker } from './TailwindClassPicker';
import { TabsPropertyEditor } from './TabsPropertyEditor';
import { HtmlGeneratorWizard } from './HtmlGeneratorWizard';
import { FileBrowserWidget } from '@/modules/pages/FileBrowserWidget';
export interface WidgetPropertiesFormProps {
widgetDefinition: WidgetDefinition;
@ -53,6 +54,12 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
const [activeMarkdownField, setActiveMarkdownField] = useState<string | null>(null);
const [htmlWizardOpen, setHtmlWizardOpen] = useState(false);
const [activeHtmlField, setActiveHtmlField] = useState<string | null>(null);
const [vfsPickerOpen, setVfsPickerOpen] = useState(false);
const [vfsPickerField, setVfsPickerField] = useState<string | null>(null);
const [vfsPickerMountKey, setVfsPickerMountKey] = useState('mount');
const [vfsPickerPathKey, setVfsPickerPathKey] = useState('path');
const [vfsBrowseMount, setVfsBrowseMount] = useState('home');
const [vfsBrowsePath, setVfsBrowsePath] = useState('/');
// Sync with prop changes (e.g. selection change)
useEffect(() => {
@ -140,6 +147,52 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
</div>
);
case 'selectWithText': {
const isPreset = config.options?.some((o: any) => o.value === value);
return (
<div key={key} className="space-y-2">
<Label className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<Select
value={isPreset ? value : '__custom__'}
onValueChange={(newValue) => {
if (newValue === '__custom__') updateSetting(key, '');
else updateSetting(key, newValue);
}}
>
<SelectTrigger className="w-full h-8 text-sm">
<SelectValue placeholder={`Select ${config.label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
{config.options.map((option: any) => (
<SelectItem key={option.value} value={option.value} className="text-sm">
{option.label}
</SelectItem>
))}
<SelectItem value="__custom__" className="text-sm">
Custom
</SelectItem>
</SelectContent>
</Select>
{!isPreset && (
<Input
type="text"
value={value || ''}
onChange={(e) => updateSetting(key, e.target.value)}
className="w-full h-8 text-sm font-mono"
placeholder={config.default || 'Enter custom value…'}
/>
)}
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
);
}
case 'boolean':
return (
<div key={key} className="space-y-2">
@ -331,6 +384,60 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
</div>
);
case 'vfsPicker': {
// Compound picker for mount + path — editable text field + browse dialog
const mountKey = config.mountKey || 'mount';
const pathKey = config.pathKey || 'path';
const currentMount = settings[mountKey] || config.defaultMount || 'home';
const currentPickerPath = settings[pathKey] || '/';
const displayValue = `${currentMount}:${currentPickerPath}`;
return (
<div key={key} className="space-y-2">
<Label className="text-xs font-medium text-slate-500 dark:text-slate-400">
<T>{config.label}</T>
</Label>
<div className="flex gap-2">
<Input
type="text"
value={displayValue}
onChange={(e) => {
const val = e.target.value;
const colonIdx = val.indexOf(':');
if (colonIdx > 0) {
updateSetting(mountKey, val.slice(0, colonIdx));
updateSetting(pathKey, val.slice(colonIdx + 1) || '/');
} else {
updateSetting(pathKey, val || '/');
}
}}
className="flex-1 font-mono text-xs h-8"
placeholder="mount:/path"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 px-2"
onClick={() => {
setVfsPickerField(key);
setVfsPickerMountKey(mountKey);
setVfsPickerPathKey(pathKey);
setVfsPickerOpen(true);
}}
>
<FolderOpen className="h-3 w-3 mr-1" />
<span className="text-xs"><T>Browse</T></span>
</Button>
</div>
{config.description && (
<p className="text-[10px] text-slate-400 dark:text-slate-500">
<T>{config.description}</T>
</p>
)}
</div>
);
}
default:
return null;
}
@ -513,6 +620,52 @@ export const WidgetPropertiesForm: React.FC<WidgetPropertiesFormProps> = ({
pageContext={pageContext}
initialPrompt={activeHtmlField ? settings[activeHtmlField] : ''}
/>
{/* VFS Browse Dialog */}
<Dialog open={vfsPickerOpen} onOpenChange={(open) => {
if (!open) setVfsPickerOpen(false);
else {
setVfsBrowseMount(settings[vfsPickerMountKey] || 'home');
setVfsBrowsePath(settings[vfsPickerPathKey] || '/');
}
}}>
<DialogContent className="sm:max-w-2xl max-w-[95vw] p-0 gap-0">
<DialogHeader className="p-4 pb-2">
<DialogTitle>Browse Files</DialogTitle>
<p className="text-xs text-muted-foreground font-mono">
{vfsBrowseMount}:{vfsBrowsePath}
</p>
</DialogHeader>
<div style={{ height: 350, overflow: 'hidden' }}>
<FileBrowserWidget
mount={vfsBrowseMount}
path={vfsBrowsePath}
onMountChange={(m: string) => {
setVfsBrowseMount(m);
setVfsBrowsePath('/');
}}
onPathChange={(p: string) => setVfsBrowsePath(p)}
viewMode="list"
mode="simple"
showToolbar={true}
glob="*.*"
sortBy="name"
/>
</div>
<div className="p-3 border-t flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setVfsPickerOpen(false)}>
<T>Cancel</T>
</Button>
<Button size="sm" onClick={() => {
updateSetting(vfsPickerMountKey, vfsBrowseMount);
updateSetting(vfsPickerPathKey, vfsBrowsePath);
setVfsPickerOpen(false);
}}>
<T>Select</T>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -500,25 +500,31 @@ export function registerAllWidgets() {
viewMode: 'list',
sortBy: 'name',
showToolbar: true,
canChangeMount: true,
allowFileViewer: true,
allowLightbox: true,
allowDownload: true,
variables: {}
},
configSchema: {
mount: {
type: 'text',
label: 'Mount',
description: 'VFS mount name from config/vfs.json',
default: 'test'
},
path: {
type: 'text',
label: 'Initial Path',
description: 'Starting directory path (relative to mount root)',
default: '/'
mountAndPath: {
type: 'vfsPicker',
label: 'Mount & Initial Path',
description: 'Browse to select the mount and starting directory',
mountKey: 'mount',
pathKey: 'path',
defaultMount: 'home',
},
glob: {
type: 'text',
label: 'Glob Pattern',
description: 'Filter files by glob pattern (e.g. *.jpg, **/*.png)',
type: 'selectWithText',
label: 'File Filter',
description: 'Filter which files are shown',
options: [
{ value: '*.*', label: 'All Files' },
{ value: '*.{jpg,jpeg,png,gif,webp,svg,bmp,avif,mp4,webm,mov,avi,mkv}', label: 'Media (Images & Video)' },
{ value: '*.{jpg,jpeg,png,gif,webp,svg,bmp,avif}', label: 'Images Only' },
{ value: '*.{mp4,webm,mov,avi,mkv,flv}', label: 'Videos Only' },
],
default: '*.*'
},
mode: {
@ -558,6 +564,30 @@ export function registerAllWidgets() {
label: 'Show Toolbar',
description: 'Show navigation toolbar with breadcrumbs, sort, and view toggle',
default: true
},
canChangeMount: {
type: 'boolean',
label: 'Allow Mount Switching',
description: 'Let users switch between VFS mounts',
default: true
},
allowLightbox: {
type: 'boolean',
label: 'Allow Image/Video Lightbox',
description: 'Open images and videos in a fullscreen lightbox',
default: true
},
allowFileViewer: {
type: 'boolean',
label: 'Allow Text File Viewer',
description: 'Open text/code files in the built-in viewer',
default: true
},
allowDownload: {
type: 'boolean',
label: 'Allow Download',
description: 'Show download button for files',
default: true
}
},
minSize: { width: 300, height: 300 },

View File

@ -1,20 +1,27 @@
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, Info, Loader2, Download, ExternalLink, X, ChevronLeft, ZoomIn, ZoomOut,
ChevronRight, ChevronDown, Info, Loader2, Download, ExternalLink, ZoomIn, ZoomOut,
Image, Film, Music, FileCode, FileText as FileTextIcon,
Archive, FileSpreadsheet, Presentation
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;
}
@ -125,19 +132,29 @@ function sortNodes(nodes: INode[], sortBy: SortKey, asc: boolean): INode[] {
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, height = 64, tokenParam = '' }: { node: INode; mount: string; height?: number; tokenParam?: string }) {
function ThumbPreview({ node, mount, tokenParam = '' }: { node: INode; mount: string; tokenParam?: string }) {
const cat = getMimeCategory(node);
const baseUrl = `/api/vfs/get/${encodeURIComponent(mount)}/${node.path.replace(/^\/+/, '')}`;
const fileUrl = tokenParam ? `${baseUrl}?${tokenParam}` : baseUrl;
const fileUrl = vfsUrl('get', mount, node.path);
const fullUrl = tokenParam ? `${fileUrl}?${tokenParam}` : fileUrl;
if (cat === 'image') {
return <ResponsiveImage src={fileUrl} alt={node.name} loading="lazy" responsiveSizes={[128, 256]} className="" imgClassName="" style={{ width: '100%', height, objectFit: 'cover', borderRadius: 4 }} />;
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%', height }}>
<video src={fileUrl} muted preload="metadata" style={{ width: '100%', height, objectFit: 'cover', borderRadius: 4 }} />
<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>
@ -162,32 +179,58 @@ const TB_SEP: React.CSSProperties = { width: 1, height: 18, background: 'var(--b
const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
const {
mount = 'root',
path: initialPath = '/',
mount: mountProp = 'home',
path: pathProp = '/',
glob = '*.*',
mode = 'simple',
viewMode: initialViewMode = 'list',
sortBy: initialSort = 'name',
showToolbar = true,
path: controlledPath, // Destructure controlled props
canChangeMount = true,
allowFileViewer = true,
allowLightbox = true,
allowDownload = true,
onPathChange,
onMountChange,
onSelect,
} = props;
const { session } = useAuth();
const accessToken = session?.access_token;
// Internal state for uncontrolled mode
const [internalPath, setInternalPath] = useState(initialPath);
// Controlled mode: parent provides onPathChange
const isControlled = !!onPathChange;
// Derived current path
const currentPath = controlledPath !== undefined ? controlledPath : internalPath;
// 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 (onPathChange) onPathChange(newPath);
if (isControlled) onPathChange!(newPath);
else setInternalPath(newPath);
}, [onPathChange]);
}, [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[]>([]);
@ -200,6 +243,7 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
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) {
@ -210,7 +254,7 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
// ── Zoom (persisted) ─────────────────────────────────────────
const [thumbSize, setThumbSize] = useState(() => {
const v = localStorage.getItem('fb-thumb-size');
return v ? Math.max(60, Math.min(200, Number(v))) : 100;
return v ? Math.max(60, Math.min(200, Number(v))) : 80;
});
const [fontSize, setFontSize] = useState(() => {
const v = localStorage.getItem('fb-font-size');
@ -231,12 +275,10 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
setLoading(true);
setError(null);
setSelected(null);
setFocusIdx(-1);
setFocusIdx(0);
try {
const clean = dirPath.replace(/^\/+/, '');
const base = clean
? `/api/vfs/ls/${encodeURIComponent(mount)}/${clean}`
: `/api/vfs/ls/${encodeURIComponent(mount)}`;
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}`;
@ -255,12 +297,10 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
}, [mount, glob, accessToken]);
useEffect(() => { fetchDir(currentPath); }, [currentPath, fetchDir]);
// Sync initialPath change only if uncontrolled?
// Usually initialPath is only for first render. Logic in original was:
// useEffect(() => { setCurrentPath(initialPath); }, [initialPath]);
useEffect(() => {
if (controlledPath === undefined) setInternalPath(initialPath);
}, [initialPath, controlledPath]);
if (!isControlled) setInternalPath(pathProp);
}, [pathProp, isControlled]);
// Build a URL with optional auth token for <img>/<video> src (can't set headers on HTML elements)
@ -292,13 +332,14 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
const goUp = useCallback(() => {
if (!canGoUp) return;
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
parts.pop();
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 = `/api/vfs/get/${encodeURIComponent(mount)}/${node.path}`;
const base = vfsUrl('get', mount, node.path);
return tokenParam ? `${base}?${tokenParam}` : base;
};
@ -326,12 +367,31 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
const breadcrumbs = useMemo(() => {
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
const crumbs = [{ label: mount, path: '/' }];
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) => {
@ -341,41 +401,60 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
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 '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);
moveFocus(focusIdx < itemCount - 1 ? focusIdx + 1 : 0);
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);
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; }
openNode(node);
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':
@ -386,19 +465,12 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
}
case 'Home': {
e.preventDefault();
setFocusIdx(0);
const node = getNode(0);
if (node) setSelected(node); else setSelected(null);
scrollItemIntoView(0);
moveFocus(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);
moveFocus(itemCount - 1);
break;
}
case ' ': {
@ -416,7 +488,7 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
break;
}
}
}, [focusIdx, itemCount, goUp, scrollItemIntoView, sorted, canGoUp]);
}, [focusIdx, itemCount, goUp, scrollItemIntoView, sorted, canGoUp, getGridCols]);
// Focus container on mount and after nav for keyboard capture
useEffect(() => { containerRef.current?.focus(); }, [currentPath]);
@ -428,7 +500,6 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
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]);
@ -441,6 +512,15 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
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) => {
@ -455,11 +535,10 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
const cat = getMimeCategory(node);
if (cat === 'dir') {
updatePath(node.path || node.name);
} else if (cat === 'image' || cat === 'video') {
} else if ((cat === 'image' || cat === 'video') && allowLightbox) {
setLightboxNode(node);
} else {
window.open(getFileUrl(node), '_blank');
} else if (allowFileViewer) {
openTextLightbox(node);
}
};
@ -487,6 +566,7 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
.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>
@ -503,8 +583,44 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
<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: 1, overflow: 'hidden', padding: '0 4px' }}>
<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 }} />}
@ -513,7 +629,7 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
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,
padding: '2px 3px', borderRadius: 3, whiteSpace: 'nowrap', fontSize: 13,
}}>
{c.label}
</button>
@ -528,9 +644,11 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
<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>
{allowDownload && (
<button onClick={handleDownload} title="Download" className="fb-tb-btn" style={TB_BTN}>
<Download size={18} />
</button>
)}
<div style={TB_SEP} />
</>)}
@ -577,10 +695,10 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
{viewMode === 'list' ? (
<div ref={listRef} style={{ overflowY: 'auto', flex: 1 }}>
{canGoUp && (
<div data-fb-idx={0} onClick={() => { setFocusIdx(0); goUp(); }}
<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, #1e293b)',
cursor: 'pointer', fontSize, borderBottom: '1px solid var(--border, #e5e7eb)',
background: focusIdx === 0 ? focusBg : 'transparent',
}}>
<ArrowUp size={14} style={{ color: CATEGORY_STYLE.dir.color }} />
@ -599,7 +717,7 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
className="fb-row" style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
cursor: 'pointer', fontSize,
borderBottomWidth: 1, borderBottomColor: 'var(--border, #1e293b)', borderBottomStyle: 'solid',
borderBottomWidth: 1, borderBottomColor: 'var(--border, #e5e7eb)', borderBottomStyle: 'solid',
background: isSelected ? selectedBg : isFocused ? focusBg : 'transparent',
borderLeftWidth: 2, borderLeftColor: isSelected ? selectedBorder : 'transparent',
borderLeftStyle: isSelected ? 'outset' : 'solid',
@ -624,14 +742,14 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
/* ── Thumb view ─────────────────────── */
<div ref={listRef} style={{
overflowY: 'auto', flex: 1, display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`, gap: 6, padding: 8,
gridTemplateColumns: `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`, gap: 2, padding: 2,
}}>
{canGoUp && (
<div data-fb-idx={0} onClick={() => { setFocusIdx(0); goUp(); }} className="fb-thumb" style={{
<div data-fb-idx={0} onClick={() => setFocusIdx(0)} onDoubleClick={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',
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>
@ -648,13 +766,13 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
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',
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={28} /> : <ThumbPreview node={node} mount={mount} tokenParam={tokenParam} />}
{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',
@ -712,82 +830,35 @@ const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
{/* ═══ Status bar ════════════════════════════════ */}
<div style={{
padding: '3px 10px', fontSize: 10, borderTop: '1px solid var(--border, #334155)',
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' : ''}{selectedFile ? ` · ${selectedFile.name}` : ''}</span>
<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 ═════════════════════════════════ */}
{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' }}
/>
) : (
<ResponsiveImage
onClick={(e) => e.stopPropagation()}
src={getFileUrl(lightboxNode)}
alt={lightboxNode.name}
loading="eager"
responsiveSizes={[640, 1280, 1920]}
imgClassName=""
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>
)}
<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>
);
};