Add resizable sidebar and text wrap option to File Manager (#160)

- Add draggable resize handle to folder sidebar (200-500px range)
- Persist sidebar width in localStorage
- Double-click resize handle to reset to default width
- Add "Wrap" toggle button in Folders header for text wrapping
- When wrap enabled: folder names break across lines, menu always visible
- When wrap disabled: folder names truncate with tooltip on hover
- Persist wrap preference in localStorage

Closes #160
This commit is contained in:
maziggy
2026-01-27 16:13:04 +01:00
parent cc4dd3a0e8
commit 3a03cfe928
5 changed files with 149 additions and 56 deletions
+102 -9
View File
@@ -693,9 +693,10 @@ interface FolderTreeItemProps {
onLink: (folder: LibraryFolderTree) => void;
onRename: (folder: LibraryFolderTree) => void;
depth?: number;
wrapNames?: boolean;
}
function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0 }: FolderTreeItemProps) {
function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false }: FolderTreeItemProps) {
const [expanded, setExpanded] = useState(true);
const [showActions, setShowActions] = useState(false);
const hasChildren = folder.children.length > 0;
@@ -726,12 +727,12 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
<div className="w-4.5" />
)}
<FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
<span className="text-sm truncate flex-1" title={folder.name}>{folder.name}</span>
<span className={`text-sm flex-1 min-w-0 ${wrapNames ? 'break-all' : 'truncate'}`} title={folder.name}>{folder.name}</span>
{/* Link indicator - clickable to change link */}
{isLinked && (
<button
onClick={(e) => { e.stopPropagation(); onLink(folder); }}
className="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
className="flex-shrink-0 flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
title={`${folder.project_name ? `Project: ${folder.project_name}` : `Archive: ${folder.archive_name}`} (click to change)`}
>
<Link2 className="w-3 h-3" />
@@ -743,19 +744,19 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
</button>
)}
{folder.file_count > 0 && (
<span className="text-xs text-bambu-gray">{folder.file_count}</span>
<span className="flex-shrink-0 text-xs text-bambu-gray">{folder.file_count}</span>
)}
{/* Quick link button - always visible for unlinked folders */}
{!isLinked && (
<button
onClick={(e) => { e.stopPropagation(); onLink(folder); }}
className="p-1 rounded hover:bg-bambu-dark-tertiary"
className="flex-shrink-0 p-1 rounded hover:bg-bambu-dark-tertiary"
title="Link to project or archive"
>
<Link2 className="w-3.5 h-3.5 text-bambu-gray hover:text-bambu-green" />
</button>
)}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
<div className={`flex-shrink-0 flex items-center gap-0.5 transition-opacity ${wrapNames ? '' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
<div className="relative">
<button
onClick={() => setShowActions(!showActions)}
@@ -806,6 +807,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
onLink={onLink}
onRename={onRename}
depth={depth + 1}
wrapNames={wrapNames}
/>
))}
</div>
@@ -980,6 +982,55 @@ export function FileManagerPage() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
});
const [wrapFolderNames, setWrapFolderNames] = useState(() => {
return localStorage.getItem('library-wrap-folders') === 'true';
});
// Resizable sidebar state
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem('library-sidebar-width');
return saved ? parseInt(saved, 10) : 256; // Default w-64 = 256px
});
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
// Handle sidebar resize
useEffect(() => {
if (!isResizing) return;
// Prevent text selection during resize
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
const handleMouseMove = (e: MouseEvent) => {
if (!sidebarRef.current) return;
const containerRect = sidebarRef.current.parentElement?.getBoundingClientRect();
if (!containerRect) return;
// Calculate new width based on mouse position relative to container
const newWidth = e.clientX - containerRect.left;
// Clamp between 200px and 500px
const clampedWidth = Math.min(500, Math.max(200, newWidth));
setSidebarWidth(clampedWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
document.body.style.userSelect = '';
document.body.style.cursor = '';
// Save to localStorage
localStorage.setItem('library-sidebar-width', String(sidebarWidth));
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
};
}, [isResizing, sidebarWidth]);
// Filter and sort state
const [searchQuery, setSearchQuery] = useState('');
@@ -1372,10 +1423,51 @@ export function FileManagerPage() {
{/* Main content */}
<div className="flex-1 flex gap-6 min-h-0">
{/* Folder sidebar */}
<div className="w-64 flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex flex-col">
<div className="p-3 border-b border-bambu-dark-tertiary">
{/* Folder sidebar - resizable */}
<div
ref={sidebarRef}
className="flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex flex-col relative"
style={{ width: `${sidebarWidth}px` }}
>
{/* Resize handle - drag to resize, double-click to reset */}
<div
className={`absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize z-10 group/resize flex items-center justify-center transition-colors ${
isResizing ? 'bg-bambu-green' : 'hover:bg-bambu-green/50'
}`}
onMouseDown={(e) => {
e.preventDefault();
setIsResizing(true);
}}
onDoubleClick={() => {
setSidebarWidth(256); // Reset to default w-64
localStorage.setItem('library-sidebar-width', '256');
}}
title="Drag to resize, double-click to reset"
>
{/* Grip dots */}
<div className={`flex flex-col gap-1 opacity-0 group-hover/resize:opacity-100 transition-opacity ${isResizing ? 'opacity-100' : ''}`}>
<div className="w-0.5 h-0.5 rounded-full bg-white/70" />
<div className="w-0.5 h-0.5 rounded-full bg-white/70" />
<div className="w-0.5 h-0.5 rounded-full bg-white/70" />
</div>
</div>
<div className="p-3 border-b border-bambu-dark-tertiary flex items-center justify-between">
<h2 className="text-sm font-medium text-white">Folders</h2>
<button
onClick={() => {
const newValue = !wrapFolderNames;
setWrapFolderNames(newValue);
localStorage.setItem('library-wrap-folders', String(newValue));
}}
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
wrapFolderNames
? 'bg-bambu-green/20 text-bambu-green'
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
}`}
title={wrapFolderNames ? 'Disable text wrapping' : 'Enable text wrapping'}
>
Wrap
</button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{/* All Files (root) */}
@@ -1401,6 +1493,7 @@ export function FileManagerPage() {
onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
onLink={setLinkFolder}
onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
wrapNames={wrapFolderNames}
/>
))}
</div>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-nyN_9kgu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CwW-gspw.css">
<script type="module" crossorigin src="/assets/index-CGiVeylo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nwJjDqT-.css">
</head>
<body>
<div id="root"></div>