filebrowser ole :)
This commit is contained in:
parent
784dc2bafe
commit
044791d496
@ -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,
|
||||
});
|
||||
|
||||
|
||||
192
packages/ui/src/components/LightboxText.tsx
Normal file
192
packages/ui/src/components/LightboxText.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user