filebrowser ole :)
This commit is contained in:
parent
044791d496
commit
8935bc7815
@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { FileBrowserWidget } from "@/modules/pages/FileBrowserWidget";
|
||||
import { FileBrowserWidget } from "@/modules/storage";
|
||||
import { AclEditor } from "@/components/admin/AclEditor";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
@ -17,7 +17,7 @@ import MarkdownEditor from '@/components/MarkdownEditorEx';
|
||||
import { TailwindClassPicker } from './TailwindClassPicker';
|
||||
import { TabsPropertyEditor } from './TabsPropertyEditor';
|
||||
import { HtmlGeneratorWizard } from './HtmlGeneratorWizard';
|
||||
import { FileBrowserWidget } from '@/modules/pages/FileBrowserWidget';
|
||||
import { FileBrowserWidget } from '@/modules/storage';
|
||||
|
||||
export interface WidgetPropertiesFormProps {
|
||||
widgetDefinition: WidgetDefinition;
|
||||
|
||||
@ -28,7 +28,7 @@ import MarkdownTextWidget from '@/components/widgets/MarkdownTextWidget';
|
||||
import GalleryWidget from '@/components/widgets/GalleryWidget';
|
||||
import TabsWidget from '@/components/widgets/TabsWidget';
|
||||
import { HtmlWidget } from '@/components/widgets/HtmlWidget';
|
||||
import { FileBrowserWidget } from '@/modules/pages/FileBrowserWidget';
|
||||
import { FileBrowserWidget } from '@/modules/storage';
|
||||
|
||||
export function registerAllWidgets() {
|
||||
// Clear existing registrations (useful for HMR)
|
||||
|
||||
@ -1,867 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Folder, File, ArrowUp, List, LayoutGrid,
|
||||
ArrowUpDown, Clock, FileType, Type,
|
||||
ChevronRight, ChevronDown, Info, Loader2, Download, ExternalLink, ZoomIn, ZoomOut,
|
||||
Image, Film, Music, FileCode, FileText as FileTextIcon,
|
||||
Archive, FileSpreadsheet, Presentation, HardDrive, Home
|
||||
} from 'lucide-react';
|
||||
import type { FileBrowserWidgetProps } from '@polymech/shared';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import ResponsiveImage from '@/components/ResponsiveImage';
|
||||
import ImageLightbox from '@/components/ImageLightbox';
|
||||
import LightboxText from '@/components/LightboxText';
|
||||
|
||||
export interface FileBrowserWidgetExtendedProps extends Omit<FileBrowserWidgetProps, 'path' | 'variables'> {
|
||||
variables?: any;
|
||||
// Controlled mode (optional)
|
||||
path?: string;
|
||||
onPathChange?: (path: string) => void;
|
||||
onMountChange?: (mount: string) => void;
|
||||
onSelect?: (path: string | null) => void;
|
||||
}
|
||||
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
interface INode {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mtime?: number;
|
||||
mime?: string;
|
||||
parent: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
type SortKey = 'name' | 'ext' | 'date' | 'type';
|
||||
type MimeCategory = 'dir' | 'image' | 'video' | 'audio' | 'code' | 'document' | 'archive' | 'spreadsheet' | 'presentation' | 'other';
|
||||
|
||||
// ── MIME helpers ─────────────────────────────────────────────────
|
||||
|
||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff', 'tif']);
|
||||
const VIDEO_EXTS = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi', 'flv', 'wmv', 'm4v', 'ogv']);
|
||||
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'opus']);
|
||||
const CODE_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'swift', 'kt', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', 'lua', 'php', 'sql', 'r', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'json', 'yaml', 'yml', 'toml', 'xml', 'vue', 'svelte']);
|
||||
const DOC_EXTS = new Set(['md', 'txt', 'rtf', 'pdf', 'doc', 'docx', 'odt', 'tex', 'log']);
|
||||
const ARCHIVE_EXTS = new Set(['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'zst', 'tgz']);
|
||||
const SPREADSHEET_EXTS = new Set(['xls', 'xlsx', 'csv', 'ods', 'tsv']);
|
||||
const PRESENTATION_EXTS = new Set(['ppt', 'pptx', 'odp', 'key']);
|
||||
|
||||
function getExt(name: string): string {
|
||||
const i = name.lastIndexOf('.');
|
||||
return i > 0 ? name.slice(i + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
function getMimeCategory(node: INode): MimeCategory {
|
||||
if (node.mime === 'inode/directory' || node.type === 'dir') return 'dir';
|
||||
const mime = node.mime || '';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime.startsWith('text/x-') || mime.includes('javascript') || mime.includes('typescript') || mime.includes('json') || mime.includes('xml') || mime.includes('yaml')) return 'code';
|
||||
const ext = getExt(node.name);
|
||||
if (IMAGE_EXTS.has(ext)) return 'image';
|
||||
if (VIDEO_EXTS.has(ext)) return 'video';
|
||||
if (AUDIO_EXTS.has(ext)) return 'audio';
|
||||
if (CODE_EXTS.has(ext)) return 'code';
|
||||
if (SPREADSHEET_EXTS.has(ext)) return 'spreadsheet';
|
||||
if (PRESENTATION_EXTS.has(ext)) return 'presentation';
|
||||
if (ARCHIVE_EXTS.has(ext)) return 'archive';
|
||||
if (DOC_EXTS.has(ext)) return 'document';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
const CATEGORY_STYLE: Record<MimeCategory, { icon: React.FC<any>; color: string }> = {
|
||||
dir: { icon: Folder, color: '#60a5fa' },
|
||||
image: { icon: Image, color: '#22c55e' },
|
||||
video: { icon: Film, color: '#ef4444' },
|
||||
audio: { icon: Music, color: '#8b5cf6' },
|
||||
code: { icon: FileCode, color: '#3b82f6' },
|
||||
document: { icon: FileTextIcon, color: '#f59e0b' },
|
||||
archive: { icon: Archive, color: '#eab308' },
|
||||
spreadsheet: { icon: FileSpreadsheet, color: '#16a34a' },
|
||||
presentation: { icon: Presentation, color: '#ec4899' },
|
||||
other: { icon: File, color: '#94a3b8' },
|
||||
};
|
||||
|
||||
function NodeIcon({ node, size = 14 }: { node: INode; size?: number }) {
|
||||
const cat = getMimeCategory(node);
|
||||
const { icon: Icon, color } = CATEGORY_STYLE[cat];
|
||||
return <Icon size={size} style={{ color, flexShrink: 0 }} />;
|
||||
}
|
||||
|
||||
// ── Format helpers ───────────────────────────────────────────────
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const v = bytes / Math.pow(1024, i);
|
||||
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatDate(ts?: number): string {
|
||||
if (!ts) return '—';
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
+ ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// ── Sort ─────────────────────────────────────────────────────────
|
||||
|
||||
function sortNodes(nodes: INode[], sortBy: SortKey, asc: boolean): INode[] {
|
||||
const dirs = nodes.filter(n => n.mime === 'inode/directory' || n.type === 'dir');
|
||||
const files = nodes.filter(n => n.mime !== 'inode/directory' && n.type !== 'dir');
|
||||
const cmp = (a: INode, b: INode): number => {
|
||||
let r = 0;
|
||||
switch (sortBy) {
|
||||
case 'name': r = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); break;
|
||||
case 'ext': r = getExt(a.name).localeCompare(getExt(b.name)) || a.name.localeCompare(b.name); break;
|
||||
case 'date': r = (a.mtime ?? 0) - (b.mtime ?? 0); break;
|
||||
case 'type': r = getMimeCategory(a).localeCompare(getMimeCategory(b)) || a.name.localeCompare(b.name); break;
|
||||
}
|
||||
return asc ? r : -r;
|
||||
};
|
||||
dirs.sort(cmp);
|
||||
files.sort(cmp);
|
||||
return [...dirs, ...files];
|
||||
}
|
||||
|
||||
// ── URL helper ───────────────────────────────────────────────────
|
||||
|
||||
/** Build a VFS API URL. Always includes mount segment — resolveMount handles 'home'. */
|
||||
function vfsUrl(op: string, mount: string, subpath?: string): string {
|
||||
const clean = subpath?.replace(/^\/+/, '');
|
||||
return clean
|
||||
? `/api/vfs/${op}/${encodeURIComponent(mount)}/${clean}`
|
||||
: `/api/vfs/${op}/${encodeURIComponent(mount)}`;
|
||||
}
|
||||
|
||||
// ── Thumbnail helper ─────────────────────────────────────────────
|
||||
|
||||
function ThumbPreview({ node, mount, tokenParam = '' }: { node: INode; mount: string; tokenParam?: string }) {
|
||||
const cat = getMimeCategory(node);
|
||||
const fileUrl = vfsUrl('get', mount, node.path);
|
||||
const fullUrl = tokenParam ? `${fileUrl}?${tokenParam}` : fileUrl;
|
||||
if (cat === 'image') {
|
||||
return <ResponsiveImage src={fullUrl} alt={node.name} loading="lazy" responsiveSizes={[128, 256]} className="" imgClassName="" style={{ width: '100%', height: '100%', flex: 1, objectFit: 'cover', borderRadius: 4 }} />;
|
||||
}
|
||||
if (cat === 'video') {
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', flex: 1 }}>
|
||||
<video src={fullUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 4 }} />
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', borderRadius: 4 }}>
|
||||
<Film size={20} style={{ color: '#fff', opacity: 0.8 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <NodeIcon node={node} size={28} />;
|
||||
}
|
||||
|
||||
// ── Toolbar button ───────────────────────────────────────────────
|
||||
|
||||
const TB_BTN: React.CSSProperties = {
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: 4, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--muted-foreground, #94a3b8)', borderRadius: 4,
|
||||
};
|
||||
const TB_BTN_ACTIVE: React.CSSProperties = { ...TB_BTN, color: 'var(--foreground, #e2e8f0)' };
|
||||
const TB_SEP: React.CSSProperties = { width: 1, height: 18, background: 'var(--border, #334155)', flexShrink: 0 };
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────
|
||||
|
||||
const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
|
||||
|
||||
const {
|
||||
mount: mountProp = 'home',
|
||||
path: pathProp = '/',
|
||||
glob = '*.*',
|
||||
mode = 'simple',
|
||||
viewMode: initialViewMode = 'list',
|
||||
sortBy: initialSort = 'name',
|
||||
showToolbar = true,
|
||||
canChangeMount = true,
|
||||
allowFileViewer = true,
|
||||
allowLightbox = true,
|
||||
allowDownload = true,
|
||||
onPathChange,
|
||||
onMountChange,
|
||||
onSelect,
|
||||
} = props;
|
||||
|
||||
const { session } = useAuth();
|
||||
const accessToken = session?.access_token;
|
||||
|
||||
// Controlled mode: parent provides onPathChange
|
||||
const isControlled = !!onPathChange;
|
||||
|
||||
// Internal state for uncontrolled mode
|
||||
const [internalPath, setInternalPath] = useState(pathProp);
|
||||
const [internalMount, setInternalMount] = useState(mountProp);
|
||||
|
||||
// Derived current values
|
||||
const mount = onMountChange ? mountProp : internalMount;
|
||||
const currentPath = isControlled ? pathProp : internalPath;
|
||||
|
||||
// Helper to update path
|
||||
const updatePath = useCallback((newPath: string) => {
|
||||
if (isControlled) onPathChange!(newPath);
|
||||
else setInternalPath(newPath);
|
||||
}, [isControlled, onPathChange]);
|
||||
|
||||
const updateMount = useCallback((newMount: string) => {
|
||||
if (onMountChange) onMountChange(newMount);
|
||||
else setInternalMount(newMount);
|
||||
updatePath('/');
|
||||
}, [onMountChange, updatePath]);
|
||||
|
||||
// Fetch available mounts
|
||||
const [availableMounts, setAvailableMounts] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
fetch('/api/vfs/mounts', { headers })
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((mounts: { name: string }[]) => setAvailableMounts(mounts.map(m => m.name)))
|
||||
.catch(() => { });
|
||||
}, [accessToken]);
|
||||
|
||||
const [nodes, setNodes] = useState<INode[]>([]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'thumbs'>(initialViewMode);
|
||||
const [sortBy, setSortBy] = useState<SortKey>(initialSort);
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
const [focusIdx, setFocusIdx] = useState(-1);
|
||||
const [selected, setSelected] = useState<INode | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const returnTargetRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (onSelect) {
|
||||
onSelect(selected ? selected.path : null);
|
||||
}
|
||||
}, [selected, onSelect]);
|
||||
|
||||
// ── Zoom (persisted) ─────────────────────────────────────────
|
||||
const [thumbSize, setThumbSize] = useState(() => {
|
||||
const v = localStorage.getItem('fb-thumb-size');
|
||||
return v ? Math.max(60, Math.min(200, Number(v))) : 80;
|
||||
});
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
const v = localStorage.getItem('fb-font-size');
|
||||
return v ? Math.max(10, Math.min(18, Number(v))) : 14;
|
||||
});
|
||||
const zoomIn = () => {
|
||||
if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.min(200, s + 20); localStorage.setItem('fb-thumb-size', String(n)); return n; });
|
||||
else setFontSize(s => { const n = Math.min(18, s + 1); localStorage.setItem('fb-font-size', String(n)); return n; });
|
||||
};
|
||||
const zoomOut = () => {
|
||||
if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.max(60, s - 20); localStorage.setItem('fb-thumb-size', String(n)); return n; });
|
||||
else setFontSize(s => { const n = Math.max(10, s - 1); localStorage.setItem('fb-font-size', String(n)); return n; });
|
||||
};
|
||||
|
||||
// ── Fetch ───────────────────────────────────────────────────
|
||||
|
||||
const fetchDir = useCallback(async (dirPath: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSelected(null);
|
||||
setFocusIdx(0);
|
||||
try {
|
||||
const clean = dirPath.replace(/^\/+/, '');
|
||||
const base = vfsUrl('ls', mount, clean);
|
||||
const url = glob && glob !== '*.*' ? `${base}?glob=${encodeURIComponent(glob)}` : base;
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setNodes(await res.json());
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to load directory');
|
||||
setNodes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mount, glob, accessToken]);
|
||||
|
||||
useEffect(() => { fetchDir(currentPath); }, [currentPath, fetchDir]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlled) setInternalPath(pathProp);
|
||||
}, [pathProp, isControlled]);
|
||||
|
||||
|
||||
// Build a URL with optional auth token for <img>/<video> src (can't set headers on HTML elements)
|
||||
const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : '';
|
||||
|
||||
// ── Sorted items (with optional ".." at index 0) ────────────
|
||||
|
||||
const canGoUp = currentPath !== '/' && currentPath !== '';
|
||||
const sorted = useMemo(() => sortNodes(nodes, sortBy, sortAsc), [nodes, sortBy, sortAsc]);
|
||||
|
||||
// Virtual list: ".." occupies index 0 when canGoUp, real items follow
|
||||
const itemCount = sorted.length + (canGoUp ? 1 : 0);
|
||||
const getNode = (idx: number): INode | null => {
|
||||
if (canGoUp && idx === 0) return null; // ".."
|
||||
return sorted[canGoUp ? idx - 1 : idx] ?? null;
|
||||
};
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────
|
||||
|
||||
const openNode = (node: INode) => {
|
||||
if (getMimeCategory(node) === 'dir') {
|
||||
updatePath(node.path || node.name);
|
||||
} else {
|
||||
setSelected(prev => prev?.path === node.path ? null : node);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
if (!canGoUp) return;
|
||||
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
|
||||
const leaving = parts.pop(); // the folder we're leaving
|
||||
if (leaving) returnTargetRef.current = leaving;
|
||||
updatePath(parts.length ? parts.join('/') : '/');
|
||||
}, [currentPath, canGoUp, updatePath]);
|
||||
|
||||
|
||||
const getFileUrl = (node: INode) => {
|
||||
const base = vfsUrl('get', mount, node.path);
|
||||
return tokenParam ? `${base}?${tokenParam}` : base;
|
||||
};
|
||||
|
||||
const handleView = () => { if (selected) window.open(getFileUrl(selected), '_blank'); };
|
||||
const handleDownload = () => {
|
||||
if (!selected) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = getFileUrl(selected);
|
||||
a.download = selected.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
// ── Sort toggle ─────────────────────────────────────────────
|
||||
|
||||
const cycleSort = () => {
|
||||
const keys: SortKey[] = ['name', 'ext', 'date', 'type'];
|
||||
const i = keys.indexOf(sortBy);
|
||||
if (sortAsc) { setSortAsc(false); } else { setSortBy(keys[(i + 1) % keys.length]); setSortAsc(true); }
|
||||
};
|
||||
const sortIcons: Record<SortKey, React.ReactNode> = { name: <Type size={16} />, ext: <FileType size={16} />, date: <Clock size={16} />, type: <ArrowUpDown size={16} /> };
|
||||
|
||||
// ── Breadcrumbs ─────────────────────────────────────────────
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
|
||||
const crumbs = [{ label: '/', path: '/' }];
|
||||
let acc = '';
|
||||
for (const p of parts) { acc += (acc ? '/' : '') + p; crumbs.push({ label: p, path: acc }); }
|
||||
return crumbs;
|
||||
}, [currentPath, mount]);
|
||||
|
||||
// Return-to-sender: after loading, focus the folder we came from
|
||||
useEffect(() => {
|
||||
const target = returnTargetRef.current;
|
||||
if (!target || sorted.length === 0) return;
|
||||
const idx = sorted.findIndex(n => n.name === target);
|
||||
if (idx >= 0) {
|
||||
const realIdx = canGoUp ? idx + 1 : idx;
|
||||
setFocusIdx(realIdx);
|
||||
setSelected(sorted[idx]);
|
||||
requestAnimationFrame(() => {
|
||||
if (!listRef.current) return;
|
||||
const items = listRef.current.querySelectorAll('[data-fb-idx]');
|
||||
const el = items[realIdx] as HTMLElement | undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
returnTargetRef.current = null;
|
||||
}, [sorted, canGoUp]);
|
||||
|
||||
// ── Keyboard navigation ─────────────────────────────────────
|
||||
|
||||
const scrollItemIntoView = useCallback((idx: number) => {
|
||||
if (!listRef.current) return;
|
||||
const items = listRef.current.querySelectorAll('[data-fb-idx]');
|
||||
const el = items[idx] as HTMLElement | undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, []);
|
||||
|
||||
const getGridCols = useCallback((): number => {
|
||||
if (viewMode !== 'thumbs' || !listRef.current) return 1;
|
||||
const style = getComputedStyle(listRef.current);
|
||||
const cols = style.gridTemplateColumns.split(' ').length;
|
||||
return Math.max(1, cols);
|
||||
}, [viewMode]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (itemCount === 0) return;
|
||||
|
||||
const moveFocus = (next: number) => {
|
||||
next = Math.max(0, Math.min(itemCount - 1, next));
|
||||
setFocusIdx(next);
|
||||
const node = getNode(next);
|
||||
if (node) setSelected(node); else setSelected(null);
|
||||
scrollItemIntoView(next);
|
||||
};
|
||||
|
||||
const cols = getGridCols();
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'j': {
|
||||
e.preventDefault();
|
||||
moveFocus(focusIdx < itemCount - 1 ? focusIdx + 1 : 0);
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft':
|
||||
case 'k': {
|
||||
e.preventDefault();
|
||||
moveFocus(focusIdx > 0 ? focusIdx - 1 : itemCount - 1);
|
||||
break;
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault();
|
||||
moveFocus(focusIdx + cols);
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault();
|
||||
moveFocus(focusIdx - cols);
|
||||
break;
|
||||
}
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
case 'l': {
|
||||
e.preventDefault();
|
||||
if (focusIdx < 0) break;
|
||||
const node = getNode(focusIdx);
|
||||
if (!node) { goUp(); break; }
|
||||
const cat = getMimeCategory(node);
|
||||
if (cat === 'dir') updatePath(node.path || node.name);
|
||||
else if ((cat === 'image' || cat === 'video') && allowLightbox) setLightboxNode(node);
|
||||
else if (allowFileViewer) openTextLightbox(node);
|
||||
break;
|
||||
}
|
||||
case 'Backspace':
|
||||
case 'h': {
|
||||
e.preventDefault();
|
||||
goUp();
|
||||
break;
|
||||
}
|
||||
case 'Home': {
|
||||
e.preventDefault();
|
||||
moveFocus(0);
|
||||
break;
|
||||
}
|
||||
case 'End': {
|
||||
e.preventDefault();
|
||||
moveFocus(itemCount - 1);
|
||||
break;
|
||||
}
|
||||
case ' ': {
|
||||
e.preventDefault();
|
||||
if (focusIdx < 0) break;
|
||||
const node = getNode(focusIdx);
|
||||
const cat = node ? getMimeCategory(node) : null;
|
||||
if (node && (cat === 'image' || cat === 'video')) setLightboxNode(node);
|
||||
break;
|
||||
}
|
||||
case 'Escape': {
|
||||
e.preventDefault();
|
||||
setSelected(null);
|
||||
setFocusIdx(-1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [focusIdx, itemCount, goUp, scrollItemIntoView, sorted, canGoUp, getGridCols]);
|
||||
|
||||
// Focus container on mount and after nav for keyboard capture
|
||||
useEffect(() => { containerRef.current?.focus(); }, [currentPath]);
|
||||
|
||||
const selectedFile = selected && getMimeCategory(selected) !== 'dir' ? selected : null;
|
||||
|
||||
// ── Lightbox state ──────────────────────────────────────────
|
||||
|
||||
const [lightboxNode, setLightboxNode] = useState<INode | null>(null);
|
||||
const mediaNodes = useMemo(() => sorted.filter(n => { const c = getMimeCategory(n); return c === 'image' || c === 'video'; }), [sorted]);
|
||||
const lightboxIdx = lightboxNode ? mediaNodes.findIndex(n => n.path === lightboxNode.path) : -1;
|
||||
|
||||
const lightboxPrev = () => {
|
||||
if (lightboxIdx > 0) setLightboxNode(mediaNodes[lightboxIdx - 1]);
|
||||
};
|
||||
const lightboxNext = () => {
|
||||
if (lightboxIdx < mediaNodes.length - 1) setLightboxNode(mediaNodes[lightboxIdx + 1]);
|
||||
};
|
||||
const closeLightbox = () => {
|
||||
setLightboxNode(null);
|
||||
setTimeout(() => containerRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
// ── Text lightbox state ──────────────────────────────────────
|
||||
|
||||
const [textLightboxNode, setTextLightboxNode] = useState<INode | null>(null);
|
||||
const openTextLightbox = (node: INode) => setTextLightboxNode(node);
|
||||
const closeTextLightbox = () => {
|
||||
setTextLightboxNode(null);
|
||||
setTimeout(() => containerRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
// ── Row click handler ───────────────────────────────────────
|
||||
|
||||
const onItemClick = (idx: number) => {
|
||||
setFocusIdx(idx);
|
||||
const node = getNode(idx);
|
||||
if (!node) return;
|
||||
setSelected(prev => prev?.path === node.path ? null : node);
|
||||
};
|
||||
const onItemDoubleClick = (idx: number) => {
|
||||
const node = getNode(idx);
|
||||
if (!node) { goUp(); return; }
|
||||
const cat = getMimeCategory(node);
|
||||
if (cat === 'dir') {
|
||||
updatePath(node.path || node.name);
|
||||
} else if ((cat === 'image' || cat === 'video') && allowLightbox) {
|
||||
setLightboxNode(node);
|
||||
} else if (allowFileViewer) {
|
||||
openTextLightbox(node);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Styles ──────────────────────────────────────────────────
|
||||
|
||||
const focusBg = 'var(--accent, #334155)';
|
||||
const selectedBg = 'rgba(59, 130, 246, 0.15)';
|
||||
const selectedBorder = 'var(--ring, #3b82f6)';
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', height: '100%',
|
||||
border: '1px solid var(--border, #334155)', borderRadius: 6, overflow: 'hidden',
|
||||
background: 'var(--background, #0f172a)', color: 'var(--foreground, #e2e8f0)',
|
||||
fontFamily: 'var(--font-sans, system-ui, sans-serif)', outline: 'none',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
.fb-row:hover { background: var(--accent, #334155) !important; }
|
||||
.fb-thumb:hover { border-color: var(--ring, #3b82f6) !important; background: var(--accent, #1e293b) !important; }
|
||||
.fb-tb-btn:hover { background: var(--accent, #334155) !important; color: var(--foreground, #e2e8f0) !important; }
|
||||
.fb-mount-item:hover { background: var(--accent, #334155) !important; }
|
||||
@media (max-width: 767px) { .fb-detail-pane { display: none !important; } }
|
||||
`}</style>
|
||||
|
||||
{/* ═══ Toolbar ═══════════════════════════════════ */}
|
||||
{showToolbar && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 2, padding: '4px 6px',
|
||||
borderBottom: '1px solid var(--border, #334155)',
|
||||
background: 'var(--muted, #1e293b)',
|
||||
}}>
|
||||
{/* Navigation */}
|
||||
<button onClick={goUp} disabled={!canGoUp} title="Go up (Backspace)" className="fb-tb-btn"
|
||||
style={{ ...TB_BTN, opacity: canGoUp ? 1 : 0.3, cursor: canGoUp ? 'pointer' : 'default' }}>
|
||||
<ArrowUp size={18} />
|
||||
</button>
|
||||
|
||||
{/* Mount picker */}
|
||||
{canChangeMount && availableMounts.length > 1 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="fb-tb-btn"
|
||||
title="Switch mount"
|
||||
style={{ ...TB_BTN, gap: 4, fontSize: 13, fontWeight: 600 }}
|
||||
>
|
||||
<HardDrive size={14} />
|
||||
<span>{mount}</span>
|
||||
<ChevronDown size={10} style={{ opacity: 0.5 }} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[140px]">
|
||||
{availableMounts.map(m => (
|
||||
<DropdownMenuItem
|
||||
key={m}
|
||||
onClick={() => updateMount(m)}
|
||||
className={m === mount ? 'font-semibold bg-accent' : ''}
|
||||
>
|
||||
<HardDrive className="h-3 w-3 mr-2 opacity-60" />
|
||||
{m}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<div style={TB_SEP} />
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 2, overflow: 'hidden', padding: '0 4px' }}>
|
||||
<button onClick={() => { updateMount(mountProp); updatePath(pathProp); }} title="Home" className="fb-tb-btn"
|
||||
style={{ ...TB_BTN, flexShrink: 0 }}>
|
||||
<Home size={14} />
|
||||
</button>
|
||||
<ChevronRight size={10} style={{ opacity: 0.3, flexShrink: 0 }} />
|
||||
{breadcrumbs.map((c, i) => (
|
||||
<React.Fragment key={c.path}>
|
||||
{i > 0 && <ChevronRight size={10} style={{ opacity: 0.3, flexShrink: 0 }} />}
|
||||
<button onClick={() => updatePath(c.path)} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
|
||||
color: i === breadcrumbs.length - 1 ? 'var(--foreground, #e2e8f0)' : 'var(--muted-foreground, #94a3b8)',
|
||||
fontWeight: i === breadcrumbs.length - 1 ? 600 : 400,
|
||||
padding: '2px 3px', borderRadius: 3, whiteSpace: 'nowrap', fontSize: 13,
|
||||
}}>
|
||||
{c.label}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={TB_SEP} />
|
||||
|
||||
{/* File actions — shown when a file is selected */}
|
||||
{selectedFile && (<>
|
||||
<button onClick={handleView} title="View in browser" className="fb-tb-btn" style={TB_BTN}>
|
||||
<ExternalLink size={18} />
|
||||
</button>
|
||||
{allowDownload && (
|
||||
<button onClick={handleDownload} title="Download" className="fb-tb-btn" style={TB_BTN}>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div style={TB_SEP} />
|
||||
</>)}
|
||||
|
||||
{/* Sort */}
|
||||
<button onClick={cycleSort} title={`Sort: ${sortBy} (${sortAsc ? 'asc' : 'desc'})`} className="fb-tb-btn"
|
||||
style={{ ...TB_BTN, gap: 2 }}>
|
||||
{sortIcons[sortBy]}
|
||||
<span style={{ fontSize: 9, opacity: 0.6 }}>{sortAsc ? '↑' : '↓'}</span>
|
||||
</button>
|
||||
|
||||
{/* Zoom */}
|
||||
<button onClick={zoomOut} title="Zoom out" className="fb-tb-btn" style={TB_BTN}>
|
||||
<ZoomOut size={18} />
|
||||
</button>
|
||||
<button onClick={zoomIn} title="Zoom in" className="fb-tb-btn" style={TB_BTN}>
|
||||
<ZoomIn size={18} />
|
||||
</button>
|
||||
|
||||
{/* View mode */}
|
||||
<button onClick={() => setViewMode(v => v === 'list' ? 'thumbs' : 'list')}
|
||||
title={viewMode === 'list' ? 'Thumbnails' : 'List'} className="fb-tb-btn" style={TB_BTN}>
|
||||
{viewMode === 'list' ? <LayoutGrid size={18} /> : <List size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ Content ═══════════════════════════════════ */}
|
||||
{loading ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, opacity: 0.6 }}>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span style={{ fontSize: 14 }}>Loading…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, color: '#ef4444' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : itemCount === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, opacity: 0.5 }}>
|
||||
Empty directory
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* ── List view ──────────────────────── */}
|
||||
{viewMode === 'list' ? (
|
||||
<div ref={listRef} style={{ overflowY: 'auto', flex: 1 }}>
|
||||
{canGoUp && (
|
||||
<div data-fb-idx={0} onClick={() => setFocusIdx(0)} onDoubleClick={goUp}
|
||||
className="fb-row" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
|
||||
cursor: 'pointer', fontSize, borderBottom: '1px solid var(--border, #e5e7eb)',
|
||||
background: focusIdx === 0 ? focusBg : 'transparent',
|
||||
}}>
|
||||
<ArrowUp size={14} style={{ color: CATEGORY_STYLE.dir.color }} />
|
||||
<span style={{ fontWeight: 500 }}>..</span>
|
||||
</div>
|
||||
)}
|
||||
{sorted.map((node, i) => {
|
||||
const idx = (canGoUp ? i + 1 : i);
|
||||
const isDir = getMimeCategory(node) === 'dir';
|
||||
const isFocused = focusIdx === idx;
|
||||
const isSelected = selected?.path === node.path;
|
||||
return (
|
||||
<div key={node.path || node.name} data-fb-idx={idx}
|
||||
onClick={() => onItemClick(idx)}
|
||||
onDoubleClick={() => onItemDoubleClick(idx)}
|
||||
className="fb-row" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
|
||||
cursor: 'pointer', fontSize,
|
||||
borderBottomWidth: 1, borderBottomColor: 'var(--border, #e5e7eb)', borderBottomStyle: 'solid',
|
||||
background: isSelected ? selectedBg : isFocused ? focusBg : 'transparent',
|
||||
borderLeftWidth: 2, borderLeftColor: isSelected ? selectedBorder : 'transparent',
|
||||
borderLeftStyle: isSelected ? 'outset' : 'solid',
|
||||
}}>
|
||||
<NodeIcon node={node} />
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{node.name}</span>
|
||||
{!isDir && (
|
||||
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0 }}>
|
||||
{formatSize(node.size)}
|
||||
</span>
|
||||
)}
|
||||
{mode === 'advanced' && node.mtime && (
|
||||
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0, width: 120, textAlign: 'right' }}>
|
||||
{formatDate(node.mtime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* ── Thumb view ─────────────────────── */
|
||||
<div ref={listRef} style={{
|
||||
overflowY: 'auto', flex: 1, display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`, gap: 2, padding: 2,
|
||||
}}>
|
||||
{canGoUp && (
|
||||
<div data-fb-idx={0} onClick={() => setFocusIdx(0)} onDoubleClick={goUp} className="fb-thumb" style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 2, borderRadius: 2, cursor: 'pointer', gap: 2, aspectRatio: '1',
|
||||
borderWidth: 1, borderColor: focusIdx === 0 ? selectedBorder : 'transparent',
|
||||
borderStyle: 'solid',
|
||||
}}>
|
||||
<ArrowUp size={24} style={{ color: CATEGORY_STYLE.dir.color }} />
|
||||
<span style={{ fontSize: 14 }}>..</span>
|
||||
</div>
|
||||
)}
|
||||
{sorted.map((node, i) => {
|
||||
const idx = (canGoUp ? i + 1 : i);
|
||||
const isDir = getMimeCategory(node) === 'dir';
|
||||
const isFocused = focusIdx === idx;
|
||||
const isSelected = selected?.path === node.path;
|
||||
return (
|
||||
<div key={node.path || node.name} data-fb-idx={idx}
|
||||
onClick={() => onItemClick(idx)}
|
||||
onDoubleClick={() => onItemDoubleClick(idx)}
|
||||
className="fb-thumb" style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 6, borderRadius: 6, cursor: 'pointer', gap: 4, overflow: 'hidden', aspectRatio: '1',
|
||||
borderWidth: isSelected ? 2 : 1,
|
||||
borderColor: isSelected ? selectedBorder : isFocused ? selectedBorder : 'transparent',
|
||||
borderStyle: isSelected ? 'outset' : 'solid',
|
||||
background: isSelected ? selectedBg : isFocused ? focusBg : 'transparent',
|
||||
}}>
|
||||
{isDir ? <NodeIcon node={node} size={48} /> : <ThumbPreview node={node} mount={mount} tokenParam={tokenParam} />}
|
||||
<span style={{
|
||||
fontSize: 14, textAlign: 'center', width: '100%',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Detail panel (advanced, desktop only) ── */}
|
||||
{mode === 'advanced' && selectedFile && (
|
||||
<div className="fb-detail-pane" style={{
|
||||
width: 200, borderLeft: '1px solid var(--border, #334155)',
|
||||
padding: 10, fontSize: 11, overflowY: 'auto', flexShrink: 0,
|
||||
background: 'var(--muted, #1e293b)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Info size={14} />
|
||||
<span style={{ fontWeight: 600 }}>Details</span>
|
||||
</div>
|
||||
|
||||
{getMimeCategory(selectedFile) === 'image' && (
|
||||
<ResponsiveImage src={getFileUrl(selectedFile)} alt={selectedFile.name}
|
||||
responsiveSizes={[200, 400]} imgClassName="" style={{ width: '100%', borderRadius: 4, marginBottom: 8, objectFit: 'contain' }} />
|
||||
)}
|
||||
{getMimeCategory(selectedFile) === 'video' && (
|
||||
<video key={selectedFile.path} src={getFileUrl(selectedFile)} controls muted preload="metadata"
|
||||
style={{ width: '100%', borderRadius: 4, marginBottom: 8 }} />
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{([
|
||||
['Name', selectedFile.name],
|
||||
['Path', selectedFile.path],
|
||||
['Size', formatSize(selectedFile.size)],
|
||||
['Modified', formatDate(selectedFile.mtime)],
|
||||
['MIME', selectedFile.mime || '—'],
|
||||
['Type', getMimeCategory(selectedFile)],
|
||||
] as [string, string][]).map(([label, val]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ padding: '3px 4px 3px 0', color: 'var(--muted-foreground, #94a3b8)', whiteSpace: 'nowrap', verticalAlign: 'top' }}>{label}</td>
|
||||
<td style={{ padding: '3px 0', wordBreak: 'break-all' }}>{val}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ Status bar ════════════════════════════════ */}
|
||||
<div style={{
|
||||
padding: '4px 10px', fontSize: 12, borderTop: '1px solid var(--border, #334155)',
|
||||
color: 'var(--muted-foreground, #64748b)', display: 'flex', justifyContent: 'space-between',
|
||||
background: 'var(--muted, #1e293b)',
|
||||
}}>
|
||||
<span>
|
||||
{sorted.length} item{sorted.length !== 1 ? 's' : ''}
|
||||
{' · '}{formatSize(sorted.reduce((sum, n) => sum + (n.size || 0), 0))}
|
||||
{selectedFile ? ` · ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''}
|
||||
</span>
|
||||
<span>{mount}:{currentPath || '/'}</span>
|
||||
</div>
|
||||
|
||||
{/* ═══ Lightbox ═════════════════════════════════ */}
|
||||
<ImageLightbox
|
||||
isOpen={!!lightboxNode}
|
||||
onClose={closeLightbox}
|
||||
imageUrl={lightboxNode ? getFileUrl(lightboxNode) : ''}
|
||||
imageTitle={lightboxNode?.name || ''}
|
||||
currentIndex={lightboxIdx}
|
||||
totalCount={mediaNodes.length}
|
||||
onNavigate={(dir) => dir === 'prev' ? lightboxPrev() : lightboxNext()}
|
||||
showPrompt={false}
|
||||
/>
|
||||
<LightboxText
|
||||
isOpen={!!textLightboxNode}
|
||||
onClose={closeTextLightbox}
|
||||
url={textLightboxNode ? getFileUrl(textLightboxNode) : ''}
|
||||
fileName={textLightboxNode?.name || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { FileBrowserWidget };
|
||||
export default FileBrowserWidget;
|
||||
162
packages/ui/src/modules/storage/FileBrowserToolbar.tsx
Normal file
162
packages/ui/src/modules/storage/FileBrowserToolbar.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ArrowUp, List, LayoutGrid, ChevronRight, ChevronDown,
|
||||
Download, ExternalLink, ZoomIn, ZoomOut, HardDrive, Home,
|
||||
Type, FileType, Clock, ArrowUpDown,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { INode, SortKey } from './types';
|
||||
import { TB_BTN, TB_SEP } from './types';
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────
|
||||
|
||||
interface FileBrowserToolbarProps {
|
||||
canGoUp: boolean;
|
||||
goUp: () => void;
|
||||
canChangeMount: boolean;
|
||||
availableMounts: string[];
|
||||
mount: string;
|
||||
updateMount: (m: string) => void;
|
||||
mountProp: string;
|
||||
pathProp: string;
|
||||
updatePath: (p: string) => void;
|
||||
breadcrumbs: { label: string; path: string }[];
|
||||
selectedFile: INode | null;
|
||||
handleView: () => void;
|
||||
handleDownload: () => void;
|
||||
allowDownload: boolean;
|
||||
sortBy: SortKey;
|
||||
sortAsc: boolean;
|
||||
cycleSort: () => void;
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
viewMode: 'list' | 'thumbs';
|
||||
setViewMode: (v: 'list' | 'thumbs') => void;
|
||||
}
|
||||
|
||||
// ── Sort icons ───────────────────────────────────────────────────
|
||||
|
||||
const sortIcons: Record<SortKey, React.ReactNode> = {
|
||||
name: <Type size={16} />,
|
||||
ext: <FileType size={16} />,
|
||||
date: <Clock size={16} />,
|
||||
type: <ArrowUpDown size={16} />,
|
||||
};
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────
|
||||
|
||||
const FileBrowserToolbar: React.FC<FileBrowserToolbarProps> = ({
|
||||
canGoUp, goUp,
|
||||
canChangeMount, availableMounts, mount, updateMount,
|
||||
mountProp, pathProp, updatePath,
|
||||
breadcrumbs,
|
||||
selectedFile, handleView, handleDownload, allowDownload,
|
||||
sortBy, sortAsc, cycleSort,
|
||||
zoomIn, zoomOut,
|
||||
viewMode, setViewMode,
|
||||
}) => (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 2, padding: '4px 6px',
|
||||
borderBottom: '1px solid var(--border, #334155)',
|
||||
background: 'var(--muted, #1e293b)',
|
||||
}}>
|
||||
{/* Navigation */}
|
||||
<button onClick={goUp} disabled={!canGoUp} title="Go up (Backspace)" className="fb-tb-btn"
|
||||
style={{ ...TB_BTN, opacity: canGoUp ? 1 : 0.3, cursor: canGoUp ? 'pointer' : 'default' }}>
|
||||
<ArrowUp size={18} />
|
||||
</button>
|
||||
|
||||
{/* Mount picker */}
|
||||
{canChangeMount && availableMounts.length > 1 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="fb-tb-btn"
|
||||
title="Switch mount"
|
||||
style={{ ...TB_BTN, gap: 4, fontSize: 13, fontWeight: 600 }}
|
||||
>
|
||||
<HardDrive size={14} />
|
||||
<span>{mount}</span>
|
||||
<ChevronDown size={10} style={{ opacity: 0.5 }} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[140px]">
|
||||
{availableMounts.map(m => (
|
||||
<DropdownMenuItem
|
||||
key={m}
|
||||
onClick={() => updateMount(m)}
|
||||
className={m === mount ? 'font-semibold bg-accent' : ''}
|
||||
>
|
||||
<HardDrive className="h-3 w-3 mr-2 opacity-60" />
|
||||
{m}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<div style={TB_SEP} />
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 2, overflow: 'hidden', padding: '0 4px' }}>
|
||||
<button onClick={() => { updateMount(mountProp); updatePath(pathProp); }} title="Home" className="fb-tb-btn"
|
||||
style={{ ...TB_BTN, flexShrink: 0 }}>
|
||||
<Home size={14} />
|
||||
</button>
|
||||
<ChevronRight size={10} style={{ opacity: 0.3, flexShrink: 0 }} />
|
||||
{breadcrumbs.map((c, i) => (
|
||||
<React.Fragment key={c.path}>
|
||||
{i > 0 && <ChevronRight size={10} style={{ opacity: 0.3, flexShrink: 0 }} />}
|
||||
<button onClick={() => updatePath(c.path)} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: i === breadcrumbs.length - 1 ? 'var(--foreground, #e2e8f0)' : 'var(--muted-foreground, #94a3b8)',
|
||||
fontWeight: i === breadcrumbs.length - 1 ? 600 : 400,
|
||||
padding: '2px 3px', borderRadius: 3, whiteSpace: 'nowrap', fontSize: 13,
|
||||
}}>
|
||||
{c.label}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={TB_SEP} />
|
||||
|
||||
{/* File actions */}
|
||||
{selectedFile && (<>
|
||||
<button onClick={handleView} title="View in browser" className="fb-tb-btn" style={TB_BTN}>
|
||||
<ExternalLink size={18} />
|
||||
</button>
|
||||
{allowDownload && (
|
||||
<button onClick={handleDownload} title="Download" className="fb-tb-btn" style={TB_BTN}>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div style={TB_SEP} />
|
||||
</>)}
|
||||
|
||||
{/* Sort */}
|
||||
<button onClick={cycleSort} title={`Sort: ${sortBy} (${sortAsc ? 'asc' : 'desc'})`} className="fb-tb-btn"
|
||||
style={{ ...TB_BTN, gap: 2 }}>
|
||||
{sortIcons[sortBy]}
|
||||
<span style={{ fontSize: 9, opacity: 0.6 }}>{sortAsc ? '↑' : '↓'}</span>
|
||||
</button>
|
||||
|
||||
{/* Zoom */}
|
||||
<button onClick={zoomOut} title="Zoom out" className="fb-tb-btn" style={TB_BTN}>
|
||||
<ZoomOut size={18} />
|
||||
</button>
|
||||
<button onClick={zoomIn} title="Zoom in" className="fb-tb-btn" style={TB_BTN}>
|
||||
<ZoomIn size={18} />
|
||||
</button>
|
||||
|
||||
{/* View mode */}
|
||||
<button onClick={() => setViewMode(viewMode === 'list' ? 'thumbs' : 'list')}
|
||||
title={viewMode === 'list' ? 'Thumbnails' : 'List'} className="fb-tb-btn" style={TB_BTN}>
|
||||
{viewMode === 'list' ? <LayoutGrid size={18} /> : <List size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default FileBrowserToolbar;
|
||||
484
packages/ui/src/modules/storage/FileBrowserWidget.tsx
Normal file
484
packages/ui/src/modules/storage/FileBrowserWidget.tsx
Normal file
@ -0,0 +1,484 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import ImageLightbox from '@/components/ImageLightbox';
|
||||
import LightboxText from '@/components/LightboxText';
|
||||
|
||||
import type { INode, SortKey, FileBrowserWidgetExtendedProps } from './types';
|
||||
import { getMimeCategory, sortNodes, vfsUrl, formatSize } from './helpers';
|
||||
import FileBrowserToolbar from './FileBrowserToolbar';
|
||||
import FileListView from './FileListView';
|
||||
import FileGridView from './FileGridView';
|
||||
import FileDetailPanel from './FileDetailPanel';
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────
|
||||
|
||||
const FileBrowserWidget: React.FC<FileBrowserWidgetExtendedProps> = (props) => {
|
||||
|
||||
const {
|
||||
mount: mountProp = 'home',
|
||||
path: pathProp = '/',
|
||||
glob = '*.*',
|
||||
mode = 'simple',
|
||||
viewMode: initialViewMode = 'list',
|
||||
sortBy: initialSort = 'name',
|
||||
showToolbar = true,
|
||||
canChangeMount = true,
|
||||
allowFileViewer = true,
|
||||
allowLightbox = true,
|
||||
allowDownload = true,
|
||||
onPathChange,
|
||||
onMountChange,
|
||||
onSelect,
|
||||
} = props;
|
||||
|
||||
const { session } = useAuth();
|
||||
const accessToken = session?.access_token;
|
||||
|
||||
// ── Controlled / uncontrolled mode ────────────────────────────
|
||||
|
||||
const isControlled = !!onPathChange;
|
||||
const [internalPath, setInternalPath] = useState(pathProp);
|
||||
const [internalMount, setInternalMount] = useState(mountProp);
|
||||
const mount = onMountChange ? mountProp : internalMount;
|
||||
const currentPath = isControlled ? pathProp : internalPath;
|
||||
|
||||
const updatePath = useCallback((newPath: string) => {
|
||||
if (isControlled) onPathChange!(newPath);
|
||||
else setInternalPath(newPath);
|
||||
}, [isControlled, onPathChange]);
|
||||
|
||||
const updateMount = useCallback((newMount: string) => {
|
||||
if (onMountChange) onMountChange(newMount);
|
||||
else setInternalMount(newMount);
|
||||
updatePath('/');
|
||||
}, [onMountChange, updatePath]);
|
||||
|
||||
// ── Available mounts ─────────────────────────────────────────
|
||||
|
||||
const [availableMounts, setAvailableMounts] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
fetch('/api/vfs/mounts', { headers })
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((mounts: { name: string }[]) => setAvailableMounts(mounts.map(m => m.name)))
|
||||
.catch(() => { });
|
||||
}, [accessToken]);
|
||||
|
||||
// ── Core state ───────────────────────────────────────────────
|
||||
|
||||
const [nodes, setNodes] = useState<INode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'thumbs'>(initialViewMode);
|
||||
const [sortBy, setSortBy] = useState<SortKey>(initialSort);
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
const [focusIdx, setFocusIdx] = useState(-1);
|
||||
const [selected, setSelected] = useState<INode | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const returnTargetRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (onSelect) onSelect(selected ? selected.path : null);
|
||||
}, [selected, onSelect]);
|
||||
|
||||
// ── Zoom (persisted) ─────────────────────────────────────────
|
||||
|
||||
const [thumbSize, setThumbSize] = useState(() => {
|
||||
const v = localStorage.getItem('fb-thumb-size');
|
||||
return v ? Math.max(60, Math.min(200, Number(v))) : 80;
|
||||
});
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
const v = localStorage.getItem('fb-font-size');
|
||||
return v ? Math.max(10, Math.min(18, Number(v))) : 14;
|
||||
});
|
||||
const zoomIn = () => {
|
||||
if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.min(200, s + 20); localStorage.setItem('fb-thumb-size', String(n)); return n; });
|
||||
else setFontSize(s => { const n = Math.min(18, s + 1); localStorage.setItem('fb-font-size', String(n)); return n; });
|
||||
};
|
||||
const zoomOut = () => {
|
||||
if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.max(60, s - 20); localStorage.setItem('fb-thumb-size', String(n)); return n; });
|
||||
else setFontSize(s => { const n = Math.max(10, s - 1); localStorage.setItem('fb-font-size', String(n)); return n; });
|
||||
};
|
||||
|
||||
// ── Fetch ────────────────────────────────────────────────────
|
||||
|
||||
const fetchDir = useCallback(async (dirPath: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSelected(null);
|
||||
setFocusIdx(0);
|
||||
try {
|
||||
const clean = dirPath.replace(/^\/+/, '');
|
||||
const base = vfsUrl('ls', mount, clean);
|
||||
const url = glob && glob !== '*.*' ? `${base}?glob=${encodeURIComponent(glob)}` : base;
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setNodes(await res.json());
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Failed to load directory');
|
||||
setNodes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mount, glob, accessToken]);
|
||||
|
||||
useEffect(() => { fetchDir(currentPath); }, [currentPath, fetchDir]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlled) setInternalPath(pathProp);
|
||||
}, [pathProp, isControlled]);
|
||||
|
||||
const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : '';
|
||||
|
||||
// ── Sorted items ─────────────────────────────────────────────
|
||||
|
||||
const canGoUp = currentPath !== '/' && currentPath !== '';
|
||||
const sorted = useMemo(() => sortNodes(nodes, sortBy, sortAsc), [nodes, sortBy, sortAsc]);
|
||||
const itemCount = sorted.length + (canGoUp ? 1 : 0);
|
||||
const getNode = (idx: number): INode | null => {
|
||||
if (canGoUp && idx === 0) return null;
|
||||
return sorted[canGoUp ? idx - 1 : idx] ?? null;
|
||||
};
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
if (!canGoUp) return;
|
||||
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
|
||||
const leaving = parts.pop();
|
||||
if (leaving) returnTargetRef.current = leaving;
|
||||
updatePath(parts.length ? parts.join('/') : '/');
|
||||
}, [currentPath, canGoUp, updatePath]);
|
||||
|
||||
const getFileUrl = (node: INode) => {
|
||||
const base = vfsUrl('get', mount, node.path);
|
||||
return tokenParam ? `${base}?${tokenParam}` : base;
|
||||
};
|
||||
|
||||
const handleView = () => { if (selected) window.open(getFileUrl(selected), '_blank'); };
|
||||
const handleDownload = () => {
|
||||
if (!selected) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = getFileUrl(selected);
|
||||
a.download = selected.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
// ── Sort toggle ──────────────────────────────────────────────
|
||||
|
||||
const cycleSort = () => {
|
||||
const keys: SortKey[] = ['name', 'ext', 'date', 'type'];
|
||||
const i = keys.indexOf(sortBy);
|
||||
if (sortAsc) { setSortAsc(false); } else { setSortBy(keys[(i + 1) % keys.length]); setSortAsc(true); }
|
||||
};
|
||||
|
||||
// ── Breadcrumbs ──────────────────────────────────────────────
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
const parts = currentPath.replace(/^\/+/, '').split('/').filter(Boolean);
|
||||
const crumbs = [{ label: '/', path: '/' }];
|
||||
let acc = '';
|
||||
for (const p of parts) { acc += (acc ? '/' : '') + p; crumbs.push({ label: p, path: acc }); }
|
||||
return crumbs;
|
||||
}, [currentPath, mount]);
|
||||
|
||||
// Return-to-sender focus
|
||||
useEffect(() => {
|
||||
const target = returnTargetRef.current;
|
||||
if (!target || sorted.length === 0) return;
|
||||
const idx = sorted.findIndex(n => n.name === target);
|
||||
if (idx >= 0) {
|
||||
const realIdx = canGoUp ? idx + 1 : idx;
|
||||
setFocusIdx(realIdx);
|
||||
setSelected(sorted[idx]);
|
||||
requestAnimationFrame(() => {
|
||||
if (!listRef.current) return;
|
||||
const items = listRef.current.querySelectorAll('[data-fb-idx]');
|
||||
const el = items[realIdx] as HTMLElement | undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
returnTargetRef.current = null;
|
||||
}, [sorted, canGoUp]);
|
||||
|
||||
// ── Keyboard navigation ──────────────────────────────────────
|
||||
|
||||
const scrollItemIntoView = useCallback((idx: number) => {
|
||||
if (!listRef.current) return;
|
||||
const items = listRef.current.querySelectorAll('[data-fb-idx]');
|
||||
const el = items[idx] as HTMLElement | undefined;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, []);
|
||||
|
||||
const getGridCols = useCallback((): number => {
|
||||
if (viewMode !== 'thumbs' || !listRef.current) return 1;
|
||||
const style = getComputedStyle(listRef.current);
|
||||
const cols = style.gridTemplateColumns.split(' ').length;
|
||||
return Math.max(1, cols);
|
||||
}, [viewMode]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (itemCount === 0) return;
|
||||
|
||||
const moveFocus = (next: number) => {
|
||||
next = Math.max(0, Math.min(itemCount - 1, next));
|
||||
setFocusIdx(next);
|
||||
const node = getNode(next);
|
||||
if (node) setSelected(node); else setSelected(null);
|
||||
scrollItemIntoView(next);
|
||||
};
|
||||
|
||||
const cols = getGridCols();
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'j': {
|
||||
e.preventDefault();
|
||||
moveFocus(focusIdx < itemCount - 1 ? focusIdx + 1 : 0);
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft':
|
||||
case 'k': {
|
||||
e.preventDefault();
|
||||
moveFocus(focusIdx > 0 ? focusIdx - 1 : itemCount - 1);
|
||||
break;
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault();
|
||||
moveFocus(focusIdx + cols);
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault();
|
||||
moveFocus(focusIdx - cols);
|
||||
break;
|
||||
}
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
case 'l': {
|
||||
e.preventDefault();
|
||||
if (focusIdx < 0) break;
|
||||
const node = getNode(focusIdx);
|
||||
if (!node) { goUp(); break; }
|
||||
const cat = getMimeCategory(node);
|
||||
if (cat === 'dir') updatePath(node.path || node.name);
|
||||
else if ((cat === 'image' || cat === 'video') && allowLightbox) setLightboxNode(node);
|
||||
else if (allowFileViewer) openTextLightbox(node);
|
||||
break;
|
||||
}
|
||||
case 'Backspace':
|
||||
case 'h': {
|
||||
e.preventDefault();
|
||||
goUp();
|
||||
break;
|
||||
}
|
||||
case 'Home': {
|
||||
e.preventDefault();
|
||||
moveFocus(0);
|
||||
break;
|
||||
}
|
||||
case 'End': {
|
||||
e.preventDefault();
|
||||
moveFocus(itemCount - 1);
|
||||
break;
|
||||
}
|
||||
case 'Escape': {
|
||||
e.preventDefault();
|
||||
setSelected(null);
|
||||
setFocusIdx(-1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [focusIdx, itemCount, goUp, scrollItemIntoView, sorted, canGoUp, getGridCols, allowLightbox, allowFileViewer, updatePath]);
|
||||
|
||||
useEffect(() => { containerRef.current?.focus(); }, [currentPath]);
|
||||
|
||||
const selectedFile = selected && getMimeCategory(selected) !== 'dir' ? selected : null;
|
||||
|
||||
// ── Lightbox state ───────────────────────────────────────────
|
||||
|
||||
const [lightboxNode, setLightboxNode] = useState<INode | null>(null);
|
||||
const mediaNodes = useMemo(() => sorted.filter(n => { const c = getMimeCategory(n); return c === 'image' || c === 'video'; }), [sorted]);
|
||||
const lightboxIdx = lightboxNode ? mediaNodes.findIndex(n => n.path === lightboxNode.path) : -1;
|
||||
|
||||
const lightboxPrev = () => { if (lightboxIdx > 0) setLightboxNode(mediaNodes[lightboxIdx - 1]); };
|
||||
const lightboxNext = () => { if (lightboxIdx < mediaNodes.length - 1) setLightboxNode(mediaNodes[lightboxIdx + 1]); };
|
||||
const closeLightbox = () => { setLightboxNode(null); setTimeout(() => containerRef.current?.focus(), 0); };
|
||||
|
||||
// ── Text lightbox state ──────────────────────────────────────
|
||||
|
||||
const [textLightboxNode, setTextLightboxNode] = useState<INode | null>(null);
|
||||
const openTextLightbox = (node: INode) => setTextLightboxNode(node);
|
||||
const closeTextLightbox = () => { setTextLightboxNode(null); setTimeout(() => containerRef.current?.focus(), 0); };
|
||||
|
||||
// ── Click handlers ───────────────────────────────────────────
|
||||
|
||||
const onItemClick = (idx: number) => {
|
||||
setFocusIdx(idx);
|
||||
const node = getNode(idx);
|
||||
if (!node) return;
|
||||
setSelected(prev => prev?.path === node.path ? null : node);
|
||||
};
|
||||
const onItemDoubleClick = (idx: number) => {
|
||||
const node = getNode(idx);
|
||||
if (!node) { goUp(); return; }
|
||||
const cat = getMimeCategory(node);
|
||||
if (cat === 'dir') {
|
||||
updatePath(node.path || node.name);
|
||||
} else if ((cat === 'image' || cat === 'video') && allowLightbox) {
|
||||
setLightboxNode(node);
|
||||
} else if (allowFileViewer) {
|
||||
openTextLightbox(node);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', height: '100%',
|
||||
border: '1px solid var(--border, #334155)', borderRadius: 6, overflow: 'hidden',
|
||||
background: 'var(--background, #0f172a)', color: 'var(--foreground, #e2e8f0)',
|
||||
fontFamily: 'var(--font-sans, system-ui, sans-serif)', outline: 'none',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
.fb-row:hover { background: var(--accent, #334155) !important; }
|
||||
.fb-thumb:hover { border-color: var(--ring, #3b82f6) !important; background: var(--accent, #1e293b) !important; }
|
||||
.fb-tb-btn:hover { background: var(--accent, #334155) !important; color: var(--foreground, #e2e8f0) !important; }
|
||||
.fb-mount-item:hover { background: var(--accent, #334155) !important; }
|
||||
@media (max-width: 767px) { .fb-detail-pane { display: none !important; } }
|
||||
`}</style>
|
||||
|
||||
{/* ═══ Toolbar ═══════════════════════════════════ */}
|
||||
{showToolbar && (
|
||||
<FileBrowserToolbar
|
||||
canGoUp={canGoUp}
|
||||
goUp={goUp}
|
||||
canChangeMount={canChangeMount}
|
||||
availableMounts={availableMounts}
|
||||
mount={mount}
|
||||
updateMount={updateMount}
|
||||
mountProp={mountProp}
|
||||
pathProp={pathProp}
|
||||
updatePath={updatePath}
|
||||
breadcrumbs={breadcrumbs}
|
||||
selectedFile={selectedFile}
|
||||
handleView={handleView}
|
||||
handleDownload={handleDownload}
|
||||
allowDownload={allowDownload}
|
||||
sortBy={sortBy}
|
||||
sortAsc={sortAsc}
|
||||
cycleSort={cycleSort}
|
||||
zoomIn={zoomIn}
|
||||
zoomOut={zoomOut}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ Content ═══════════════════════════════════ */}
|
||||
{loading ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, opacity: 0.6 }}>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
<span style={{ fontSize: 14 }}>Loading…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, color: '#ef4444' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : itemCount === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, fontSize: 14, opacity: 0.5 }}>
|
||||
Empty directory
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{viewMode === 'list' ? (
|
||||
<FileListView
|
||||
listRef={listRef}
|
||||
sorted={sorted}
|
||||
canGoUp={canGoUp}
|
||||
goUp={goUp}
|
||||
focusIdx={focusIdx}
|
||||
setFocusIdx={setFocusIdx}
|
||||
selected={selected}
|
||||
onItemClick={onItemClick}
|
||||
onItemDoubleClick={onItemDoubleClick}
|
||||
fontSize={fontSize}
|
||||
mode={mode}
|
||||
/>
|
||||
) : (
|
||||
<FileGridView
|
||||
listRef={listRef}
|
||||
sorted={sorted}
|
||||
canGoUp={canGoUp}
|
||||
goUp={goUp}
|
||||
focusIdx={focusIdx}
|
||||
setFocusIdx={setFocusIdx}
|
||||
selected={selected}
|
||||
onItemClick={onItemClick}
|
||||
onItemDoubleClick={onItemDoubleClick}
|
||||
thumbSize={thumbSize}
|
||||
mount={mount}
|
||||
tokenParam={tokenParam}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detail panel (advanced, desktop only) */}
|
||||
{mode === 'advanced' && selectedFile && (
|
||||
<FileDetailPanel file={selectedFile} fileUrl={getFileUrl(selectedFile)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ Status bar ════════════════════════════════ */}
|
||||
<div style={{
|
||||
padding: '4px 10px', fontSize: 12, borderTop: '1px solid var(--border, #334155)',
|
||||
color: 'var(--muted-foreground, #64748b)', display: 'flex', justifyContent: 'space-between',
|
||||
background: 'var(--muted, #1e293b)',
|
||||
}}>
|
||||
<span>
|
||||
{sorted.length} item{sorted.length !== 1 ? 's' : ''}
|
||||
{' · '}{formatSize(sorted.reduce((sum, n) => sum + (n.size || 0), 0))}
|
||||
{selectedFile ? ` · ${selectedFile.name} (${formatSize(selectedFile.size)})` : ''}
|
||||
</span>
|
||||
<span>{mount}:{currentPath || '/'}</span>
|
||||
</div>
|
||||
|
||||
{/* ═══ Lightboxes ════════════════════════════════ */}
|
||||
<ImageLightbox
|
||||
isOpen={!!lightboxNode}
|
||||
onClose={closeLightbox}
|
||||
imageUrl={lightboxNode ? getFileUrl(lightboxNode) : ''}
|
||||
imageTitle={lightboxNode?.name || ''}
|
||||
currentIndex={lightboxIdx}
|
||||
totalCount={mediaNodes.length}
|
||||
onNavigate={(dir) => dir === 'prev' ? lightboxPrev() : lightboxNext()}
|
||||
showPrompt={false}
|
||||
/>
|
||||
<LightboxText
|
||||
isOpen={!!textLightboxNode}
|
||||
onClose={closeTextLightbox}
|
||||
url={textLightboxNode ? getFileUrl(textLightboxNode) : ''}
|
||||
fileName={textLightboxNode?.name || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { FileBrowserWidget };
|
||||
export default FileBrowserWidget;
|
||||
59
packages/ui/src/modules/storage/FileDetailPanel.tsx
Normal file
59
packages/ui/src/modules/storage/FileDetailPanel.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import ResponsiveImage from '@/components/ResponsiveImage';
|
||||
import type { INode } from './types';
|
||||
import { getMimeCategory, formatSize, formatDate } from './helpers';
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────
|
||||
|
||||
interface FileDetailPanelProps {
|
||||
file: INode;
|
||||
fileUrl: string;
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────
|
||||
|
||||
const FileDetailPanel: React.FC<FileDetailPanelProps> = ({ file, fileUrl }) => {
|
||||
const cat = getMimeCategory(file);
|
||||
return (
|
||||
<div className="fb-detail-pane" style={{
|
||||
width: 200, borderLeft: '1px solid var(--border, #334155)',
|
||||
padding: 10, fontSize: 11, overflowY: 'auto', flexShrink: 0,
|
||||
background: 'var(--muted, #1e293b)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Info size={14} />
|
||||
<span style={{ fontWeight: 600 }}>Details</span>
|
||||
</div>
|
||||
|
||||
{cat === 'image' && (
|
||||
<ResponsiveImage src={fileUrl} alt={file.name}
|
||||
responsiveSizes={[200, 400]} imgClassName="" style={{ width: '100%', borderRadius: 4, marginBottom: 8, objectFit: 'contain' }} />
|
||||
)}
|
||||
{cat === 'video' && (
|
||||
<video key={file.path} src={fileUrl} controls muted preload="metadata"
|
||||
style={{ width: '100%', borderRadius: 4, marginBottom: 8 }} />
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{([
|
||||
['Name', file.name],
|
||||
['Path', file.path],
|
||||
['Size', formatSize(file.size)],
|
||||
['Modified', formatDate(file.mtime)],
|
||||
['MIME', file.mime || '—'],
|
||||
['Type', getMimeCategory(file)],
|
||||
] as [string, string][]).map(([label, val]) => (
|
||||
<tr key={label}>
|
||||
<td style={{ padding: '3px 4px 3px 0', color: 'var(--muted-foreground, #94a3b8)', whiteSpace: 'nowrap', verticalAlign: 'top' }}>{label}</td>
|
||||
<td style={{ padding: '3px 0', wordBreak: 'break-all' }}>{val}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileDetailPanel;
|
||||
76
packages/ui/src/modules/storage/FileGridView.tsx
Normal file
76
packages/ui/src/modules/storage/FileGridView.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import type { INode } from './types';
|
||||
import { FOCUS_BG, SELECTED_BG, SELECTED_BORDER } from './types';
|
||||
import { getMimeCategory, CATEGORY_STYLE } from './helpers';
|
||||
import { NodeIcon, ThumbPreview } from './ThumbPreview';
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────
|
||||
|
||||
interface FileGridViewProps {
|
||||
listRef: React.RefObject<HTMLDivElement>;
|
||||
sorted: INode[];
|
||||
canGoUp: boolean;
|
||||
goUp: () => void;
|
||||
focusIdx: number;
|
||||
setFocusIdx: (idx: number) => void;
|
||||
selected: INode | null;
|
||||
onItemClick: (idx: number) => void;
|
||||
onItemDoubleClick: (idx: number) => void;
|
||||
thumbSize: number;
|
||||
mount: string;
|
||||
tokenParam: string;
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────
|
||||
|
||||
const FileGridView: React.FC<FileGridViewProps> = ({
|
||||
listRef, sorted, canGoUp, goUp, focusIdx, setFocusIdx,
|
||||
selected, onItemClick, onItemDoubleClick, thumbSize, mount, tokenParam,
|
||||
}) => (
|
||||
<div ref={listRef as any} style={{
|
||||
overflowY: 'auto', flex: 1, display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`, gap: 2, padding: 2,
|
||||
}}>
|
||||
{canGoUp && (
|
||||
<div data-fb-idx={0} onClick={() => setFocusIdx(0)} onDoubleClick={goUp} className="fb-thumb" style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 2, borderRadius: 2, cursor: 'pointer', gap: 2, aspectRatio: '1',
|
||||
borderWidth: 1, borderColor: focusIdx === 0 ? SELECTED_BORDER : 'transparent',
|
||||
borderStyle: 'solid',
|
||||
}}>
|
||||
<ArrowUp size={24} style={{ color: CATEGORY_STYLE.dir.color }} />
|
||||
<span style={{ fontSize: 14 }}>..</span>
|
||||
</div>
|
||||
)}
|
||||
{sorted.map((node, i) => {
|
||||
const idx = (canGoUp ? i + 1 : i);
|
||||
const isDir = getMimeCategory(node) === 'dir';
|
||||
const isFocused = focusIdx === idx;
|
||||
const isSelected = selected?.path === node.path;
|
||||
return (
|
||||
<div key={node.path || node.name} data-fb-idx={idx}
|
||||
onClick={() => onItemClick(idx)}
|
||||
onDoubleClick={() => onItemDoubleClick(idx)}
|
||||
className="fb-thumb" style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 6, borderRadius: 6, cursor: 'pointer', gap: 4, overflow: 'hidden', aspectRatio: '1',
|
||||
borderWidth: isSelected ? 2 : 1,
|
||||
borderColor: isSelected ? SELECTED_BORDER : isFocused ? SELECTED_BORDER : 'transparent',
|
||||
borderStyle: isSelected ? 'outset' : 'solid',
|
||||
background: isSelected ? SELECTED_BG : isFocused ? FOCUS_BG : 'transparent',
|
||||
}}>
|
||||
{isDir ? <NodeIcon node={node} size={48} /> : <ThumbPreview node={node} mount={mount} tokenParam={tokenParam} />}
|
||||
<span style={{
|
||||
fontSize: 14, textAlign: 'center', width: '100%',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default FileGridView;
|
||||
77
packages/ui/src/modules/storage/FileListView.tsx
Normal file
77
packages/ui/src/modules/storage/FileListView.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import type { INode } from './types';
|
||||
import { FOCUS_BG, SELECTED_BG, SELECTED_BORDER } from './types';
|
||||
import { getMimeCategory, CATEGORY_STYLE, formatSize, formatDate } from './helpers';
|
||||
import { NodeIcon } from './ThumbPreview';
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────
|
||||
|
||||
interface FileListViewProps {
|
||||
listRef: React.RefObject<HTMLDivElement>;
|
||||
sorted: INode[];
|
||||
canGoUp: boolean;
|
||||
goUp: () => void;
|
||||
focusIdx: number;
|
||||
setFocusIdx: (idx: number) => void;
|
||||
selected: INode | null;
|
||||
onItemClick: (idx: number) => void;
|
||||
onItemDoubleClick: (idx: number) => void;
|
||||
fontSize: number;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────
|
||||
|
||||
const FileListView: React.FC<FileListViewProps> = ({
|
||||
listRef, sorted, canGoUp, goUp, focusIdx, setFocusIdx,
|
||||
selected, onItemClick, onItemDoubleClick, fontSize, mode,
|
||||
}) => (
|
||||
<div ref={listRef as any} style={{ overflowY: 'auto', flex: 1 }}>
|
||||
{canGoUp && (
|
||||
<div data-fb-idx={0} onClick={() => setFocusIdx(0)} onDoubleClick={goUp}
|
||||
className="fb-row" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
|
||||
cursor: 'pointer', fontSize, borderBottom: '1px solid var(--border, #e5e7eb)',
|
||||
background: focusIdx === 0 ? FOCUS_BG : 'transparent',
|
||||
}}>
|
||||
<ArrowUp size={14} style={{ color: CATEGORY_STYLE.dir.color }} />
|
||||
<span style={{ fontWeight: 500 }}>..</span>
|
||||
</div>
|
||||
)}
|
||||
{sorted.map((node, i) => {
|
||||
const idx = (canGoUp ? i + 1 : i);
|
||||
const isDir = getMimeCategory(node) === 'dir';
|
||||
const isFocused = focusIdx === idx;
|
||||
const isSelected = selected?.path === node.path;
|
||||
return (
|
||||
<div key={node.path || node.name} data-fb-idx={idx}
|
||||
onClick={() => onItemClick(idx)}
|
||||
onDoubleClick={() => onItemDoubleClick(idx)}
|
||||
className="fb-row" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
|
||||
cursor: 'pointer', fontSize,
|
||||
borderBottomWidth: 1, borderBottomColor: 'var(--border, #e5e7eb)', borderBottomStyle: 'solid',
|
||||
background: isSelected ? SELECTED_BG : isFocused ? FOCUS_BG : 'transparent',
|
||||
borderLeftWidth: 2, borderLeftColor: isSelected ? SELECTED_BORDER : 'transparent',
|
||||
borderLeftStyle: isSelected ? 'outset' : 'solid',
|
||||
}}>
|
||||
<NodeIcon node={node} />
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{node.name}</span>
|
||||
{!isDir && (
|
||||
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0 }}>
|
||||
{formatSize(node.size)}
|
||||
</span>
|
||||
)}
|
||||
{mode === 'advanced' && node.mtime && (
|
||||
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0, width: 120, textAlign: 'right' }}>
|
||||
{formatDate(node.mtime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default FileListView;
|
||||
35
packages/ui/src/modules/storage/ThumbPreview.tsx
Normal file
35
packages/ui/src/modules/storage/ThumbPreview.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Film } from 'lucide-react';
|
||||
import ResponsiveImage from '@/components/ResponsiveImage';
|
||||
import type { INode } from './types';
|
||||
import { getMimeCategory, CATEGORY_STYLE, vfsUrl } from './helpers';
|
||||
|
||||
// ── NodeIcon ─────────────────────────────────────────────────────
|
||||
|
||||
export function NodeIcon({ node, size = 14 }: { node: INode; size?: number }) {
|
||||
const cat = getMimeCategory(node);
|
||||
const { icon: Icon, color } = CATEGORY_STYLE[cat];
|
||||
return <Icon size={size} style={{ color, flexShrink: 0 }} />;
|
||||
}
|
||||
|
||||
// ── ThumbPreview ─────────────────────────────────────────────────
|
||||
|
||||
export function ThumbPreview({ node, mount, tokenParam = '' }: { node: INode; mount: string; tokenParam?: string }) {
|
||||
const cat = getMimeCategory(node);
|
||||
const fileUrl = vfsUrl('get', mount, node.path);
|
||||
const fullUrl = tokenParam ? `${fileUrl}?${tokenParam}` : fileUrl;
|
||||
if (cat === 'image') {
|
||||
return <ResponsiveImage src={fullUrl} alt={node.name} loading="lazy" responsiveSizes={[128, 256]} className="" imgClassName="" style={{ width: '100%', height: '100%', flex: 1, objectFit: 'cover', borderRadius: 4 }} />;
|
||||
}
|
||||
if (cat === 'video') {
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', flex: 1 }}>
|
||||
<video src={fullUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 4 }} />
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', borderRadius: 4 }}>
|
||||
<Film size={20} style={{ color: '#fff', opacity: 0.8 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <NodeIcon node={node} size={28} />;
|
||||
}
|
||||
103
packages/ui/src/modules/storage/helpers.ts
Normal file
103
packages/ui/src/modules/storage/helpers.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Folder, File, Image, Film, Music, FileCode, FileText as FileTextIcon,
|
||||
Archive, FileSpreadsheet, Presentation
|
||||
} from 'lucide-react';
|
||||
import type { INode, SortKey, MimeCategory } from './types';
|
||||
|
||||
// ── Extension sets ───────────────────────────────────────────────
|
||||
|
||||
export const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff', 'tif']);
|
||||
export const VIDEO_EXTS = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi', 'flv', 'wmv', 'm4v', 'ogv']);
|
||||
export const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'opus']);
|
||||
export const CODE_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'swift', 'kt', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', 'lua', 'php', 'sql', 'r', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'json', 'yaml', 'yml', 'toml', 'xml', 'vue', 'svelte']);
|
||||
export const DOC_EXTS = new Set(['md', 'txt', 'rtf', 'pdf', 'doc', 'docx', 'odt', 'tex', 'log']);
|
||||
export const ARCHIVE_EXTS = new Set(['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'zst', 'tgz']);
|
||||
export const SPREADSHEET_EXTS = new Set(['xls', 'xlsx', 'csv', 'ods', 'tsv']);
|
||||
export const PRESENTATION_EXTS = new Set(['ppt', 'pptx', 'odp', 'key']);
|
||||
|
||||
// ── MIME helpers ─────────────────────────────────────────────────
|
||||
|
||||
export function getExt(name: string): string {
|
||||
const i = name.lastIndexOf('.');
|
||||
return i > 0 ? name.slice(i + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function getMimeCategory(node: INode): MimeCategory {
|
||||
if (node.mime === 'inode/directory' || node.type === 'dir') return 'dir';
|
||||
const mime = node.mime || '';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime.startsWith('text/x-') || mime.includes('javascript') || mime.includes('typescript') || mime.includes('json') || mime.includes('xml') || mime.includes('yaml')) return 'code';
|
||||
const ext = getExt(node.name);
|
||||
if (IMAGE_EXTS.has(ext)) return 'image';
|
||||
if (VIDEO_EXTS.has(ext)) return 'video';
|
||||
if (AUDIO_EXTS.has(ext)) return 'audio';
|
||||
if (CODE_EXTS.has(ext)) return 'code';
|
||||
if (SPREADSHEET_EXTS.has(ext)) return 'spreadsheet';
|
||||
if (PRESENTATION_EXTS.has(ext)) return 'presentation';
|
||||
if (ARCHIVE_EXTS.has(ext)) return 'archive';
|
||||
if (DOC_EXTS.has(ext)) return 'document';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export const CATEGORY_STYLE: Record<MimeCategory, { icon: React.FC<any>; color: string }> = {
|
||||
dir: { icon: Folder, color: '#60a5fa' },
|
||||
image: { icon: Image, color: '#22c55e' },
|
||||
video: { icon: Film, color: '#ef4444' },
|
||||
audio: { icon: Music, color: '#8b5cf6' },
|
||||
code: { icon: FileCode, color: '#3b82f6' },
|
||||
document: { icon: FileTextIcon, color: '#f59e0b' },
|
||||
archive: { icon: Archive, color: '#eab308' },
|
||||
spreadsheet: { icon: FileSpreadsheet, color: '#16a34a' },
|
||||
presentation: { icon: Presentation, color: '#ec4899' },
|
||||
other: { icon: File, color: '#94a3b8' },
|
||||
};
|
||||
|
||||
// ── Format helpers ───────────────────────────────────────────────
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const v = bytes / Math.pow(1024, i);
|
||||
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatDate(ts?: number): string {
|
||||
if (!ts) return '—';
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
+ ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// ── Sort ─────────────────────────────────────────────────────────
|
||||
|
||||
export function sortNodes(nodes: INode[], sortBy: SortKey, asc: boolean): INode[] {
|
||||
const dirs = nodes.filter(n => n.mime === 'inode/directory' || n.type === 'dir');
|
||||
const files = nodes.filter(n => n.mime !== 'inode/directory' && n.type !== 'dir');
|
||||
const cmp = (a: INode, b: INode): number => {
|
||||
let r = 0;
|
||||
switch (sortBy) {
|
||||
case 'name': r = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); break;
|
||||
case 'ext': r = getExt(a.name).localeCompare(getExt(b.name)) || a.name.localeCompare(b.name); break;
|
||||
case 'date': r = (a.mtime ?? 0) - (b.mtime ?? 0); break;
|
||||
case 'type': r = getMimeCategory(a).localeCompare(getMimeCategory(b)) || a.name.localeCompare(b.name); break;
|
||||
}
|
||||
return asc ? r : -r;
|
||||
};
|
||||
dirs.sort(cmp);
|
||||
files.sort(cmp);
|
||||
return [...dirs, ...files];
|
||||
}
|
||||
|
||||
// ── URL helper ───────────────────────────────────────────────────
|
||||
|
||||
/** Build a VFS API URL. Always includes mount segment. */
|
||||
export function vfsUrl(op: string, mount: string, subpath?: string): string {
|
||||
const clean = subpath?.replace(/^\/+/, '');
|
||||
return clean
|
||||
? `/api/vfs/${op}/${encodeURIComponent(mount)}/${clean}`
|
||||
: `/api/vfs/${op}/${encodeURIComponent(mount)}`;
|
||||
}
|
||||
4
packages/ui/src/modules/storage/index.ts
Normal file
4
packages/ui/src/modules/storage/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { FileBrowserWidget } from './FileBrowserWidget';
|
||||
export type { FileBrowserWidgetExtendedProps, INode, SortKey, MimeCategory } from './types';
|
||||
export { getMimeCategory, formatSize, formatDate, vfsUrl, sortNodes } from './helpers';
|
||||
export { NodeIcon, ThumbPreview } from './ThumbPreview';
|
||||
43
packages/ui/src/modules/storage/types.ts
Normal file
43
packages/ui/src/modules/storage/types.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import type { FileBrowserWidgetProps } from '@polymech/shared';
|
||||
import React from 'react';
|
||||
|
||||
// ── Data types ───────────────────────────────────────────────────
|
||||
|
||||
export interface INode {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mtime?: number;
|
||||
mime?: string;
|
||||
parent: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export type SortKey = 'name' | 'ext' | 'date' | 'type';
|
||||
export type MimeCategory = 'dir' | 'image' | 'video' | 'audio' | 'code' | 'document' | 'archive' | 'spreadsheet' | 'presentation' | 'other';
|
||||
|
||||
// ── Extended props (adds controlled-mode callbacks) ─────────────
|
||||
|
||||
export interface FileBrowserWidgetExtendedProps extends Omit<FileBrowserWidgetProps, 'path' | 'variables'> {
|
||||
variables?: any;
|
||||
path?: string;
|
||||
onPathChange?: (path: string) => void;
|
||||
onMountChange?: (mount: string) => void;
|
||||
onSelect?: (path: string | null) => void;
|
||||
}
|
||||
|
||||
// ── Toolbar style constants ─────────────────────────────────────
|
||||
|
||||
export const TB_BTN: React.CSSProperties = {
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: 4, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--muted-foreground, #94a3b8)', borderRadius: 4,
|
||||
};
|
||||
export const TB_BTN_ACTIVE: React.CSSProperties = { ...TB_BTN, color: 'var(--foreground, #e2e8f0)' };
|
||||
export const TB_SEP: React.CSSProperties = { width: 1, height: 18, background: 'var(--border, #334155)', flexShrink: 0 };
|
||||
|
||||
// ── Selection style constants ───────────────────────────────────
|
||||
|
||||
export const FOCUS_BG = 'var(--accent, #334155)';
|
||||
export const SELECTED_BG = 'rgba(59, 130, 246, 0.15)';
|
||||
export const SELECTED_BORDER = 'var(--ring, #3b82f6)';
|
||||
Loading…
Reference in New Issue
Block a user