filemanager 1/2
This commit is contained in:
parent
f6e1f656cf
commit
d9c8ccd433
@ -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) && (
|
||||
|
||||
58
packages/ui/src/apps/filebrowser/FileBrowser.tsx
Normal file
58
packages/ui/src/apps/filebrowser/FileBrowser.tsx
Normal 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;
|
||||
|
||||
27
packages/ui/src/apps/filebrowser/FileBrowserApp.tsx
Normal file
27
packages/ui/src/apps/filebrowser/FileBrowserApp.tsx
Normal 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;
|
||||
138
packages/ui/src/apps/filebrowser/FileBrowserContext.tsx
Normal file
138
packages/ui/src/apps/filebrowser/FileBrowserContext.tsx
Normal 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;
|
||||
};
|
||||
525
packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx
Normal file
525
packages/ui/src/apps/filebrowser/FileBrowserPanel.tsx
Normal 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;
|
||||
78
packages/ui/src/apps/filebrowser/LayoutToolbar.tsx
Normal file
78
packages/ui/src/apps/filebrowser/LayoutToolbar.tsx
Normal 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;
|
||||
|
||||
117
packages/ui/src/apps/filebrowser/PanelSide.tsx
Normal file
117
packages/ui/src/apps/filebrowser/PanelSide.tsx
Normal 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;
|
||||
10
packages/ui/src/apps/filebrowser/main.tsx
Normal file
10
packages/ui/src/apps/filebrowser/main.tsx
Normal 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>
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user