refactor(folder-browser): G.87 — extract column component + 3 hooks (cluster, multi-commit) (#654)

* refactor(folder-browser): G.87.1 — extract helpers + types

Move ColumnKind / NavPos / Column types + entryToAlbumIfPresent /
entryToTrack mappers + isFolderBrowserArrowKey /
folderBrowserHasKeyModifiers key-event helpers into
src/utils/folderBrowserHelpers.ts. Pure code move.

* refactor(folder-browser): G.87.2 — extract FolderBrowserColumn component

* refactor(folder-browser): G.87.3 — extract useFolderBrowserNowPlayingPath hook

* refactor(folder-browser): G.87.4 — extract useFolderBrowserScrolling hook

* refactor(folder-browser): G.87.5 — extract useFolderBrowserKeyboardNav hook
This commit is contained in:
Frank Stellmacher
2026-05-13 18:06:47 +02:00
committed by GitHub
parent c8e130ecea
commit d4d3b0e53f
6 changed files with 727 additions and 526 deletions
@@ -0,0 +1,136 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronRight, Folder, FolderOpen, Music } from 'lucide-react';
import type { SubsonicDirectoryEntry } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes';
import {
folderBrowserHasKeyModifiers, isFolderBrowserArrowKey,
type Column,
} from '../../utils/folderBrowserHelpers';
interface Props {
col: Column;
colIndex: number;
isCompact: boolean;
filterValue: string;
filterVisible: boolean;
filteredItems: SubsonicDirectoryEntry[];
keyboardRowIndex: number | null;
contextRowIndex: number | null;
currentTrack: Track | null;
isPlaying: boolean;
isSelectedPathForCurrentTrack: boolean;
playingPathIds: string[];
registerFilterInput: (el: HTMLInputElement | null) => void;
onFilterFocus: () => void;
onFilterBlur: () => void;
onFilterEscape: () => void;
onFilterArrowDown: () => void;
onFilterChange: (value: string) => void;
onRowClick: (item: SubsonicDirectoryEntry, rowIndex: number) => void;
onRowContextMenu: (
e: React.MouseEvent,
rowIndex: number,
col: Column,
item: SubsonicDirectoryEntry,
) => void;
}
export default function FolderBrowserColumn({
col, colIndex, isCompact, filterValue, filterVisible, filteredItems,
keyboardRowIndex, contextRowIndex, currentTrack, isPlaying,
isSelectedPathForCurrentTrack, playingPathIds,
registerFilterInput, onFilterFocus, onFilterBlur, onFilterEscape, onFilterArrowDown, onFilterChange,
onRowClick, onRowContextMenu,
}: Props) {
const { t } = useTranslation();
return (
<div
className={`folder-col${isCompact ? ' folder-col--compact' : ''}`}
data-folder-col-index={colIndex}
>
{filterVisible && (
<div className="folder-col-filter">
<input
ref={registerFilterInput}
data-folder-filter-input="true"
className="folder-col-filter-input"
value={filterValue}
placeholder={t('playlists.searchPlaceholder')}
onFocus={onFilterFocus}
onBlur={onFilterBlur}
onKeyDown={e => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onFilterEscape();
return;
}
if (e.key === 'ArrowDown' && !folderBrowserHasKeyModifiers(e)) {
e.preventDefault();
e.stopPropagation();
onFilterArrowDown();
}
}}
onChange={e => onFilterChange(e.target.value)}
/>
</div>
)}
{col.loading ? (
<div className="folder-col-status">
<div className="spinner" style={{ width: 20, height: 20 }} />
</div>
) : col.error ? (
<div className="folder-col-status folder-col-error">
{t('folderBrowser.error')}
</div>
) : filteredItems.length === 0 ? (
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
) : (
filteredItems.map((item, rowIndex) => {
const isSelected = col.selectedId === item.id;
const isContextRow = contextRowIndex === rowIndex;
const isKeyboardRow = keyboardRowIndex === rowIndex;
const isNowPlayingTrack = !item.isDir && currentTrack?.id === item.id;
const isPathPlayingIcon = !!(isSelectedPathForCurrentTrack && playingPathIds.includes(item.id));
return (
<button
key={item.id}
type="button"
title={item.title}
data-col-index={colIndex}
data-row-index={rowIndex}
data-item-id={item.id}
className={`folder-col-row${isSelected ? ' selected' : ''}${isContextRow ? ' context-active' : ''}${isKeyboardRow ? ' keyboard-active' : ''}${isNowPlayingTrack ? ' now-playing' : ''}`}
onClick={() => onRowClick(item, rowIndex)}
onKeyDown={e => {
if (!isFolderBrowserArrowKey(e) || folderBrowserHasKeyModifiers(e)) return;
e.preventDefault();
}}
onContextMenu={e => onRowContextMenu(e, rowIndex, col, item)}
>
<span className={`folder-col-icon${isPathPlayingIcon ? ' folder-col-path-playing-icon' : ''}`}>
{item.isDir ? (
isSelected ? (
<FolderOpen size={14} />
) : (
<Folder size={14} />
)
) : (
<Music
size={14}
strokeWidth={isNowPlayingTrack ? 2.5 : 2}
className={isNowPlayingTrack && isPlaying ? 'folder-col-playing-icon' : undefined}
/>
)}
</span>
<span className="folder-col-name">{item.title}</span>
{item.isDir && <ChevronRight size={12} className="folder-col-chevron" />}
</button>
);
})
)}
</div>
);
}