filemanager 1/2

This commit is contained in:
lovebird 2026-02-22 20:25:20 +01:00
parent f6e1f656cf
commit d9c8ccd433
8 changed files with 955 additions and 0 deletions

View File

@ -58,6 +58,7 @@ const PlaygroundCanvas = React.lazy(() => import("./modules/layout/PlaygroundCan
const TypesPlayground = React.lazy(() => import("@/modules/types/TypesPlayground"));
const VariablePlayground = React.lazy(() => import("./components/variables/VariablesEditor").then(module => ({ default: module.VariablesEditor })));
const Tetris = React.lazy(() => import("./apps/tetris/Tetris"));
const FileBrowser = React.lazy(() => import("./apps/filebrowser/FileBrowser"));
const I18nPlayground = React.lazy(() => import("./components/playground/I18nPlayground"));
const VersionMap = React.lazy(() => import("./pages/VersionMap"));
@ -133,6 +134,7 @@ const AppWrapper = () => {
{/* Apps */}
<Route path="/app/tetris" element={<React.Suspense fallback={<div>Loading...</div>}><Tetris /></React.Suspense>} />
<Route path="/app/filebrowser" element={<React.Suspense fallback={<div>Loading...</div>}><FileBrowser /></React.Suspense>} />
{/* Ecommerce Routes */}
{(ecommerce) && (

View File

@ -0,0 +1,58 @@
import React from 'react';
import { useAuth } from '@/hooks/useAuth';
import { FileBrowserProvider, useFileBrowser } from './FileBrowserContext';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import LayoutToolbar from './LayoutToolbar';
import PanelSide from './PanelSide';
/**
* Standalone FileBrowser page Krusader-style dual pane.
* Inner component that requires FileBrowserProvider context.
*/
const FileBrowserInner: React.FC = () => {
const { loading } = useAuth();
const { layout } = useFileBrowser();
if (loading) {
return (
<div className="flex items-center justify-center flex-1 text-muted-foreground">
Loading
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 56px)', overflow: 'hidden' }}>
{/* ═══ Layout Toolbar ═══════════════════════════ */}
<LayoutToolbar />
{/* ═══ Resizable Panes ══════════════════════════ */}
<ResizablePanelGroup direction="horizontal" style={{ flex: 1, overflow: 'hidden' }}>
<ResizablePanel defaultSize={layout === 'dual' ? 50 : 100} minSize={20} order={1} id="fb-left">
<PanelSide side="left" />
</ResizablePanel>
{layout === 'dual' && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50} minSize={20} order={2} id="fb-right">
<PanelSide side="right" />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
);
};
/**
* Exported wrapper provides FileBrowserProvider so this component
* works both from the standalone app AND when lazy-loaded by App.tsx.
*/
const FileBrowser: React.FC = () => (
<FileBrowserProvider>
<FileBrowserInner />
</FileBrowserProvider>
);
export default FileBrowser;

View File

@ -0,0 +1,27 @@
import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/lib/queryClient';
import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from '@/hooks/useAuth';
import { Toaster } from '@/components/ui/sonner';
import { FileBrowserProvider } from './FileBrowserContext';
import FileBrowser from './FileBrowser';
const FileBrowserApp: React.FC = () => {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<MemoryRouter>
<FileBrowserProvider>
<div className="flex flex-col h-full w-full bg-background text-foreground">
<FileBrowser />
</div>
</FileBrowserProvider>
<Toaster />
</MemoryRouter>
</AuthProvider>
</QueryClientProvider>
);
};
export default FileBrowserApp;

View File

@ -0,0 +1,138 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import type { INode } from '@/modules/storage/types';
// ── Panel State ──────────────────────────────────────────────────
export interface PanelState {
id: string;
mount: string;
path: string;
glob: string;
selected: INode | null;
}
export type Side = 'left' | 'right';
export type LayoutMode = 'single' | 'dual';
// ── Context Shape ────────────────────────────────────────────────
interface FileBrowserContextType {
layout: LayoutMode;
setLayout: (mode: LayoutMode) => void;
/** Link mode: left folder navigation mirrors to right */
linked: boolean;
setLinked: (v: boolean) => void;
/** Panels per side */
leftPanels: PanelState[];
rightPanels: PanelState[];
/** Which side + panel index is active */
activeSide: Side;
activePanelIdx: number;
setActivePanel: (side: Side, idx: number) => void;
/** Get the currently active panel state */
activePanel: PanelState;
/** Update a panel's state */
updatePanel: (side: Side, idx: number, patch: Partial<PanelState>) => void;
/** Add a new tab/panel to a side */
addPanel: (side: Side, initial?: Partial<PanelState>) => void;
/** Remove a panel (tab) from a side — won't remove last panel */
removePanel: (side: Side, idx: number) => void;
}
const FileBrowserContext = createContext<FileBrowserContextType | undefined>(undefined);
// ── Helpers ──────────────────────────────────────────────────────
let _panelIdCounter = 0;
function nextPanelId(): string {
return `panel-${++_panelIdCounter}`;
}
function createPanel(overrides?: Partial<PanelState>): PanelState {
return {
id: nextPanelId(),
mount: 'machines',
path: '/',
glob: '*.*',
selected: null,
...overrides,
};
}
// ── Provider ─────────────────────────────────────────────────────
export const FileBrowserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [layout, setLayout] = useState<LayoutMode>('single');
const [linked, setLinked] = useState(false);
const [leftPanels, setLeftPanels] = useState<PanelState[]>([createPanel()]);
const [rightPanels, setRightPanels] = useState<PanelState[]>([createPanel()]);
const [activeSide, setActiveSide] = useState<Side>('left');
const [activePanelIdx, setActivePanelIdx] = useState(0);
const setActivePanel = useCallback((side: Side, idx: number) => {
setActiveSide(side);
setActivePanelIdx(idx);
}, []);
const updatePanel = useCallback((side: Side, idx: number, patch: Partial<PanelState>) => {
const setter = side === 'left' ? setLeftPanels : setRightPanels;
setter(prev => prev.map((p, i) => i === idx ? { ...p, ...patch } : p));
// Link mode: left path changes mirror to right's active panel
if (linked && side === 'left' && patch.path !== undefined) {
setRightPanels(prev => {
const rIdx = Math.min(activePanelIdx, prev.length - 1);
return prev.map((p, i) => i === rIdx ? { ...p, path: patch.path! } : p);
});
}
}, [linked, activePanelIdx]);
const addPanel = useCallback((side: Side, initial?: Partial<PanelState>) => {
const setter = side === 'left' ? setLeftPanels : setRightPanels;
setter(prev => [...prev, createPanel(initial)]);
}, []);
const removePanel = useCallback((side: Side, idx: number) => {
const setter = side === 'left' ? setLeftPanels : setRightPanels;
setter(prev => {
if (prev.length <= 1) return prev; // Keep at least one
const next = prev.filter((_, i) => i !== idx);
// Adjust active index if needed
if (side === activeSide && activePanelIdx >= next.length) {
setActivePanelIdx(next.length - 1);
}
return next;
});
}, [activeSide, activePanelIdx]);
const panels = activeSide === 'left' ? leftPanels : rightPanels;
const activePanel = panels[Math.min(activePanelIdx, panels.length - 1)];
return (
<FileBrowserContext.Provider value={{
layout, setLayout,
linked, setLinked,
leftPanels, rightPanels,
activeSide, activePanelIdx, setActivePanel,
activePanel,
updatePanel, addPanel, removePanel,
}}>
{children}
</FileBrowserContext.Provider>
);
};
// ── Hook ─────────────────────────────────────────────────────────
export const useFileBrowser = () => {
const ctx = useContext(FileBrowserContext);
if (!ctx) throw new Error('useFileBrowser must be used within FileBrowserProvider');
return ctx;
};

View File

@ -0,0 +1,525 @@
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 } from '@/modules/storage/types';
import { getMimeCategory, sortNodes, vfsUrl, formatSize } from '@/modules/storage/helpers';
import FileBrowserToolbar from '@/modules/storage/FileBrowserToolbar';
import FileListView from '@/modules/storage/FileListView';
import FileGridView from '@/modules/storage/FileGridView';
import FileDetailPanel from '@/modules/storage/FileDetailPanel';
// ── Props ────────────────────────────────────────────────────────
export interface FileBrowserPanelProps {
mount?: string;
path?: string;
glob?: string;
mode?: 'simple' | 'advanced';
viewMode?: 'list' | 'thumbs';
sortBy?: SortKey;
showToolbar?: boolean;
canChangeMount?: boolean;
allowFileViewer?: boolean;
allowLightbox?: boolean;
allowDownload?: boolean;
jail?: boolean;
onPathChange?: (path: string) => void;
onMountChange?: (mount: string) => void;
onSelect?: (node: INode | null) => void;
}
// ── Main Component ───────────────────────────────────────────────
const FileBrowserPanel: React.FC<FileBrowserPanelProps> = (props) => {
const {
mount: mountProp = 'machines',
path: pathProp = '/',
glob = '*.*',
mode = 'advanced',
viewMode: initialViewMode = 'list',
sortBy: initialSort = 'name',
showToolbar = true,
canChangeMount = true,
allowFileViewer = true,
allowLightbox = true,
allowDownload = true,
jail = false,
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;
// Jail: normalize the root path for comparison
const jailRoot = pathProp.replace(/\/+$/, '') || '/';
const updatePath = useCallback((newPath: string) => {
if (jail) {
const norm = newPath.replace(/\/+$/, '') || '/';
const root = pathProp.replace(/\/+$/, '') || '/';
if (root !== '/' && !norm.startsWith(root) && norm !== root) return;
}
if (isControlled) onPathChange!(newPath);
else setInternalPath(newPath);
}, [isControlled, onPathChange, jail, pathProp]);
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, 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 = (() => {
if (currentPath === '/' || currentPath === '') return false;
if (jail) {
const normalized = currentPath.replace(/\/+$/, '') || '/';
return normalized !== jailRoot && normalized !== jailRoot.replace(/\/+$/, '');
}
return true;
})();
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 }); }
if (jail) {
const root = jailRoot === '/' ? '/' : jailRoot;
const rootParts = root === '/' ? 0 : root.split('/').filter(Boolean).length;
return crumbs.slice(rootParts);
}
return crumbs;
}, [currentPath, mount, jail, jailRoot]);
// 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={!jail && 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 { FileBrowserPanel };
export default FileBrowserPanel;

View File

@ -0,0 +1,78 @@
import React from 'react';
import { Columns2, Square, Plus, Link, Unlink } from 'lucide-react';
import { useFileBrowser, type LayoutMode } from './FileBrowserContext';
const LayoutToolbar: React.FC = () => {
const { layout, setLayout, linked, setLinked, activeSide, addPanel } = useFileBrowser();
const btnBase: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 10px', borderRadius: 4,
borderWidth: 1, borderStyle: 'solid', borderColor: 'var(--border)',
background: 'var(--background)', color: 'var(--muted-foreground)',
cursor: 'pointer', fontSize: 13, fontWeight: 500,
};
const btnActive: React.CSSProperties = {
...btnBase,
background: 'var(--accent)', color: 'var(--foreground)',
borderColor: 'var(--ring)',
};
const modes: { key: LayoutMode; label: string; icon: React.ReactNode }[] = [
{ key: 'single', label: 'Single', icon: <Square size={14} /> },
{ key: 'dual', label: 'Dual', icon: <Columns2 size={14} /> },
];
return (
<div style={{
padding: '6px 12px',
borderBottom: '1px solid var(--border)',
background: 'var(--muted)',
display: 'flex',
alignItems: 'center',
gap: 6,
}}>
<span style={{ fontSize: 12, color: 'var(--muted-foreground)', fontWeight: 600, marginRight: 4 }}>
Layout
</span>
{modes.map(m => (
<button
key={m.key}
onClick={() => setLayout(m.key)}
style={layout === m.key ? btnActive : btnBase}
title={`${m.label} pane`}
>
{m.icon}
<span>{m.label}</span>
</button>
))}
{/* Link toggle — only in dual mode */}
{layout === 'dual' && (
<button
onClick={() => setLinked(!linked)}
style={linked ? btnActive : btnBase}
title={linked ? 'Unlink panes (left stops mirroring to right)' : 'Link panes (left folder nav mirrors to right)'}
>
{linked ? <Link size={14} /> : <Unlink size={14} />}
<span>{linked ? 'Linked' : 'Link'}</span>
</button>
)}
<div style={{ flex: 1 }} />
{/* Add tab to active side */}
<button
onClick={() => addPanel(activeSide)}
style={btnBase}
title={`Add tab to ${activeSide} side`}
>
<Plus size={14} />
<span className="hidden sm:inline">New Tab</span>
</button>
</div>
);
};
export default LayoutToolbar;

View File

@ -0,0 +1,117 @@
import React, { useCallback } from 'react';
import { X } from 'lucide-react';
import { useFileBrowser, type Side } from './FileBrowserContext';
import FileBrowserPanel from './FileBrowserPanel';
interface PanelSideProps {
side: Side;
}
const PanelSide: React.FC<PanelSideProps> = ({ side }) => {
const {
leftPanels, rightPanels,
activeSide, activePanelIdx,
setActivePanel, updatePanel, removePanel,
} = useFileBrowser();
const panels = side === 'left' ? leftPanels : rightPanels;
const isActiveSide = activeSide === side;
const activeIdx = isActiveSide ? activePanelIdx : 0;
const currentIdx = Math.min(activeIdx, panels.length - 1);
const panel = panels[currentIdx];
const handlePathChange = useCallback((path: string) => {
updatePanel(side, currentIdx, { path });
}, [side, currentIdx, updatePanel]);
const handleMountChange = useCallback((mount: string) => {
updatePanel(side, currentIdx, { mount, path: '/' });
}, [side, currentIdx, updatePanel]);
const handleSelect = useCallback((node: any) => {
updatePanel(side, currentIdx, { selected: node });
}, [side, currentIdx, updatePanel]);
return (
<div
onClick={() => { if (!isActiveSide) setActivePanel(side, currentIdx); }}
style={{
height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden',
minHeight: 0,
border: isActiveSide ? '2px solid var(--ring, #3b82f6)' : '2px solid transparent',
borderRadius: 6,
minWidth: 0,
}}
>
{/* ── Tabs ──────────────────────────────────── */}
{panels.length > 1 && (
<div style={{
display: 'flex', alignItems: 'stretch',
borderBottom: '1px solid var(--border)',
background: 'var(--muted)',
overflow: 'hidden',
}}>
{panels.map((p, i) => {
const isActive = i === currentIdx;
return (
<div
key={p.id}
onClick={(e) => { e.stopPropagation(); setActivePanel(side, i); }}
style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '4px 10px', fontSize: 12, cursor: 'pointer',
borderRight: '1px solid var(--border)',
background: isActive ? 'var(--background)' : 'transparent',
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)',
fontWeight: isActive ? 600 : 400,
whiteSpace: 'nowrap',
minWidth: 0,
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{p.mount}:{p.path || '/'}
</span>
{panels.length > 1 && (
<button
onClick={(e) => { e.stopPropagation(); removePanel(side, i); }}
title="Close tab"
style={{
background: 'none', border: 'none', cursor: 'pointer',
padding: 2, display: 'flex', color: 'inherit', opacity: 0.5,
borderRadius: 2,
}}
>
<X size={10} />
</button>
)}
</div>
);
})}
</div>
)}
{/* ── Active Panel ─────────────────────────── */}
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<FileBrowserPanel
key={panel.id}
mount={panel.mount}
path={panel.path}
glob={panel.glob}
mode="advanced"
viewMode="list"
showToolbar={true}
canChangeMount={true}
allowFileViewer={true}
allowLightbox={true}
allowDownload={true}
onPathChange={handlePathChange}
onMountChange={handleMountChange}
onSelect={handleSelect}
/>
</div>
</div>
);
};
export default PanelSide;

View File

@ -0,0 +1,10 @@
import { createRoot } from "react-dom/client";
import FileBrowserApp from "./FileBrowserApp";
import "@/index.css";
import { ThemeProvider } from "@/components/ThemeProvider";
createRoot(document.getElementById("root")!).render(
<ThemeProvider defaultTheme="dark">
<FileBrowserApp />
</ThemeProvider>
);