mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(folder-browser): add per-column filter and queue append hotkey flow
Add Ctrl+F filtering for the active Folder Browser column with keyboard handoff between filter and rows, and clear right-side filters when parent selection changes. When pressing Shift+Enter on a filtered track list, append visible tracks to the queue instead of replacing the current queue.
This commit is contained in:
+174
-33
@@ -69,9 +69,12 @@ function entryToTrack(e: SubsonicDirectoryEntry): Track {
|
|||||||
export default function FolderBrowser() {
|
export default function FolderBrowser() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [columns, setColumns] = useState<Column[]>([]);
|
const [columns, setColumns] = useState<Column[]>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({});
|
||||||
|
const [filterFocusCol, setFilterFocusCol] = useState<number | null>(null);
|
||||||
const [keyboardNavActive, setKeyboardNavActive] = useState(false);
|
const [keyboardNavActive, setKeyboardNavActive] = useState(false);
|
||||||
const [playingPathIds, setPlayingPathIds] = useState<string[]>(persistedPlayingPathIds);
|
const [playingPathIds, setPlayingPathIds] = useState<string[]>(persistedPlayingPathIds);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const filterInputRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||||
const pendingNavColRef = useRef<number | null>(null);
|
const pendingNavColRef = useRef<number | null>(null);
|
||||||
const autoResolvedTrackRef = useRef<string | null>(null);
|
const autoResolvedTrackRef = useRef<string | null>(null);
|
||||||
const prevTrackIdRef = useRef<string | null>(null);
|
const prevTrackIdRef = useRef<string | null>(null);
|
||||||
@@ -83,6 +86,7 @@ export default function FolderBrowser() {
|
|||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
const playTrack = usePlayerStore(s => s.playTrack);
|
const playTrack = usePlayerStore(s => s.playTrack);
|
||||||
|
const enqueue = usePlayerStore(s => s.enqueue);
|
||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
const isContextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
const isContextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||||
|
|
||||||
@@ -179,6 +183,20 @@ export default function FolderBrowser() {
|
|||||||
return () => window.removeEventListener('mousemove', onMouseMove);
|
return () => window.removeEventListener('mousemove', onMouseMove);
|
||||||
}, [keyboardNavActive]);
|
}, [keyboardNavActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setColumnFilters(prev => {
|
||||||
|
const next: Record<number, string> = {};
|
||||||
|
let changed = false;
|
||||||
|
Object.entries(prev).forEach(([k, v]) => {
|
||||||
|
const idx = Number(k);
|
||||||
|
if (idx < columns.length) next[idx] = v;
|
||||||
|
else changed = true;
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
setFilterFocusCol(prev => (prev !== null && prev >= columns.length ? null : prev));
|
||||||
|
}, [columns.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isContextMenuOpen) setContextAnchorPos(null);
|
if (!isContextMenuOpen) setContextAnchorPos(null);
|
||||||
}, [isContextMenuOpen]);
|
}, [isContextMenuOpen]);
|
||||||
@@ -218,18 +236,31 @@ export default function FolderBrowser() {
|
|||||||
persistedPlayingPathIds = playingPathIds;
|
persistedPlayingPathIds = playingPathIds;
|
||||||
}, [playingPathIds]);
|
}, [playingPathIds]);
|
||||||
|
|
||||||
const preferredRowIndex = useCallback((col: Column): number => {
|
const filteredItemsByCol = useMemo(() => {
|
||||||
if (col.items.length === 0) return -1;
|
return columns.map((col, colIndex) => {
|
||||||
if (col.selectedId) {
|
const query = (columnFilters[colIndex] ?? '').trim().toLowerCase();
|
||||||
const selectedIdx = col.items.findIndex(it => it.id === col.selectedId);
|
if (!query) return col.items;
|
||||||
|
return col.items.filter(item => {
|
||||||
|
const haystack = `${item.title} ${item.artist ?? ''} ${item.album ?? ''}`.toLowerCase();
|
||||||
|
return haystack.includes(query);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [columns, columnFilters]);
|
||||||
|
|
||||||
|
const preferredRowIndex = useCallback((colIndex: number): number => {
|
||||||
|
const items = filteredItemsByCol[colIndex] ?? [];
|
||||||
|
if (items.length === 0) return -1;
|
||||||
|
const selectedId = columns[colIndex]?.selectedId;
|
||||||
|
if (selectedId) {
|
||||||
|
const selectedIdx = items.findIndex(it => it.id === selectedId);
|
||||||
if (selectedIdx >= 0) return selectedIdx;
|
if (selectedIdx >= 0) return selectedIdx;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}, []);
|
}, [filteredItemsByCol, columns]);
|
||||||
|
|
||||||
const fallbackNavPos = useCallback((cols: Column[]): NavPos | null => {
|
const fallbackNavPos = useCallback((cols: Column[]): NavPos | null => {
|
||||||
for (let c = 0; c < cols.length; c++) {
|
for (let c = 0; c < cols.length; c++) {
|
||||||
const rowIndex = preferredRowIndex(cols[c]);
|
const rowIndex = preferredRowIndex(c);
|
||||||
if (rowIndex >= 0) return { colIndex: c, rowIndex };
|
if (rowIndex >= 0) return { colIndex: c, rowIndex };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -239,15 +270,17 @@ export default function FolderBrowser() {
|
|||||||
if (pendingNavColRef.current !== null) {
|
if (pendingNavColRef.current !== null) {
|
||||||
const targetColIndex = pendingNavColRef.current;
|
const targetColIndex = pendingNavColRef.current;
|
||||||
const targetCol = columns[targetColIndex];
|
const targetCol = columns[targetColIndex];
|
||||||
if (targetCol && targetCol.items.length > 0 && !targetCol.loading && !targetCol.error) {
|
const targetItems = filteredItemsByCol[targetColIndex] ?? [];
|
||||||
const rowIndex = preferredRowIndex(targetCol);
|
if (targetCol && targetItems.length > 0 && !targetCol.loading && !targetCol.error) {
|
||||||
const targetItem = targetCol.items[rowIndex];
|
const rowIndex = preferredRowIndex(targetColIndex);
|
||||||
|
const safeRowIndex = Math.min(Math.max(0, rowIndex), targetItems.length - 1);
|
||||||
|
const targetItem = targetItems[safeRowIndex];
|
||||||
setColumns(prev =>
|
setColumns(prev =>
|
||||||
prev.map((c, i) => (i === targetColIndex ? { ...c, selectedId: targetItem.id } : c)),
|
prev.map((c, i) => (i === targetColIndex ? { ...c, selectedId: targetItem.id } : c)),
|
||||||
);
|
);
|
||||||
setKeyboardPos({
|
setKeyboardPos({
|
||||||
colIndex: targetColIndex,
|
colIndex: targetColIndex,
|
||||||
rowIndex,
|
rowIndex: safeRowIndex,
|
||||||
});
|
});
|
||||||
pendingNavColRef.current = null;
|
pendingNavColRef.current = null;
|
||||||
return;
|
return;
|
||||||
@@ -258,15 +291,31 @@ export default function FolderBrowser() {
|
|||||||
if (!prev) return fallbackNavPos(columns);
|
if (!prev) return fallbackNavPos(columns);
|
||||||
if (prev.colIndex >= columns.length) return fallbackNavPos(columns);
|
if (prev.colIndex >= columns.length) return fallbackNavPos(columns);
|
||||||
const col = columns[prev.colIndex];
|
const col = columns[prev.colIndex];
|
||||||
if (col.loading || col.error || col.items.length === 0) return fallbackNavPos(columns);
|
const visibleItems = filteredItemsByCol[prev.colIndex] ?? [];
|
||||||
if (prev.rowIndex >= col.items.length) {
|
if (col.loading || col.error || visibleItems.length === 0) return fallbackNavPos(columns);
|
||||||
return { colIndex: prev.colIndex, rowIndex: col.items.length - 1 };
|
if (prev.rowIndex >= visibleItems.length) {
|
||||||
|
return { colIndex: prev.colIndex, rowIndex: visibleItems.length - 1 };
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}, [columns, fallbackNavPos, preferredRowIndex]);
|
}, [columns, fallbackNavPos, preferredRowIndex, filteredItemsByCol]);
|
||||||
|
|
||||||
|
const clearFiltersRightOf = useCallback((colIndex: number) => {
|
||||||
|
setColumnFilters(prev => {
|
||||||
|
const next: Record<number, string> = {};
|
||||||
|
let changed = false;
|
||||||
|
Object.entries(prev).forEach(([k, v]) => {
|
||||||
|
const idx = Number(k);
|
||||||
|
if (idx <= colIndex) next[idx] = v;
|
||||||
|
else changed = true;
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
setFilterFocusCol(prev => (prev !== null && prev > colIndex ? null : prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDirClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
const handleDirClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||||
|
clearFiltersRightOf(colIndex);
|
||||||
const nextKind: ColumnKind = colIndex === 0 ? 'indexes' : 'directory';
|
const nextKind: ColumnKind = colIndex === 0 ? 'indexes' : 'directory';
|
||||||
setColumns(prev => [
|
setColumns(prev => [
|
||||||
...prev.slice(0, colIndex + 1).map((c, i) =>
|
...prev.slice(0, colIndex + 1).map((c, i) =>
|
||||||
@@ -305,7 +354,7 @@ export default function FolderBrowser() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, []);
|
}, [clearFiltersRightOf]);
|
||||||
|
|
||||||
const handleFileClick = useCallback(
|
const handleFileClick = useCallback(
|
||||||
(colIndex: number, item: SubsonicDirectoryEntry) => {
|
(colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||||
@@ -317,18 +366,22 @@ export default function FolderBrowser() {
|
|||||||
item.id,
|
item.id,
|
||||||
];
|
];
|
||||||
setPlayingPathIds(path);
|
setPlayingPathIds(path);
|
||||||
const col = columns[colIndex];
|
const visibleItems = filteredItemsByCol[colIndex] ?? columns[colIndex]?.items ?? [];
|
||||||
const queue = col.items.filter(it => !it.isDir).map(entryToTrack);
|
const queue = visibleItems.filter(it => !it.isDir).map(entryToTrack);
|
||||||
playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]);
|
playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]);
|
||||||
},
|
},
|
||||||
[columns, playTrack],
|
[columns, filteredItemsByCol, playTrack],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setSelectedInColumn = useCallback((colIndex: number, itemId: string) => {
|
const setSelectedInColumn = useCallback((colIndex: number, itemId: string) => {
|
||||||
setColumns(prev =>
|
setColumns(prev => {
|
||||||
prev.map((c, i) => (i === colIndex ? { ...c, selectedId: itemId } : c)),
|
const prevSelectedId = prev[colIndex]?.selectedId ?? null;
|
||||||
);
|
if (prevSelectedId !== itemId) {
|
||||||
}, []);
|
clearFiltersRightOf(colIndex);
|
||||||
|
}
|
||||||
|
return prev.map((c, i) => (i === colIndex ? { ...c, selectedId: itemId } : c));
|
||||||
|
});
|
||||||
|
}, [clearFiltersRightOf]);
|
||||||
|
|
||||||
const clearSelectedInColumn = useCallback((colIndex: number) => {
|
const clearSelectedInColumn = useCallback((colIndex: number) => {
|
||||||
setColumns(prev =>
|
setColumns(prev =>
|
||||||
@@ -336,6 +389,7 @@ export default function FolderBrowser() {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const handleActivate = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
const handleActivate = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||||
if (item.isDir) {
|
if (item.isDir) {
|
||||||
handleDirClick(colIndex, item);
|
handleDirClick(colIndex, item);
|
||||||
@@ -376,14 +430,33 @@ export default function FolderBrowser() {
|
|||||||
|
|
||||||
const onColumnsKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
const onColumnsKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (isContextMenuOpen) return;
|
if (isContextMenuOpen) return;
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const inFilterInput =
|
||||||
|
target instanceof HTMLInputElement && target.dataset.folderFilterInput === 'true';
|
||||||
|
if (inFilterInput) return;
|
||||||
const key = e.key;
|
const key = e.key;
|
||||||
|
if (e.ctrlKey && e.code === 'KeyF') {
|
||||||
|
e.preventDefault();
|
||||||
|
const current = keyboardPos ?? fallbackNavPos(columns);
|
||||||
|
if (!current) return;
|
||||||
|
const colIndex = current.colIndex;
|
||||||
|
setFilterFocusCol(colIndex);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const input = filterInputRefs.current[colIndex];
|
||||||
|
if (!input) return;
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return;
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(key)) return;
|
||||||
setKeyboardNavActive(true);
|
setKeyboardNavActive(true);
|
||||||
const current = keyboardPos ?? fallbackNavPos(columns);
|
const current = keyboardPos ?? fallbackNavPos(columns);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
|
|
||||||
const col = columns[current.colIndex];
|
const col = columns[current.colIndex];
|
||||||
const item = col?.items[current.rowIndex];
|
const visibleItems = filteredItemsByCol[current.colIndex] ?? [];
|
||||||
|
const item = visibleItems[current.rowIndex];
|
||||||
if (!col || !item) return;
|
if (!col || !item) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -403,17 +476,28 @@ export default function FolderBrowser() {
|
|||||||
if (key === 'ArrowUp') {
|
if (key === 'ArrowUp') {
|
||||||
if (current.rowIndex > 0) {
|
if (current.rowIndex > 0) {
|
||||||
const nextRowIndex = current.rowIndex - 1;
|
const nextRowIndex = current.rowIndex - 1;
|
||||||
const nextItem = col.items[nextRowIndex];
|
const nextItem = visibleItems[nextRowIndex];
|
||||||
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
|
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
|
||||||
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
|
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
|
||||||
else setSelectedInColumn(current.colIndex, nextItem.id);
|
else setSelectedInColumn(current.colIndex, nextItem.id);
|
||||||
|
} else if (
|
||||||
|
current.rowIndex === 0 &&
|
||||||
|
(filterFocusCol === current.colIndex || !!columnFilters[current.colIndex])
|
||||||
|
) {
|
||||||
|
setFilterFocusCol(current.colIndex);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const input = filterInputRefs.current[current.colIndex];
|
||||||
|
if (!input) return;
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key === 'ArrowDown') {
|
if (key === 'ArrowDown') {
|
||||||
if (current.rowIndex < col.items.length - 1) {
|
if (current.rowIndex < visibleItems.length - 1) {
|
||||||
const nextRowIndex = current.rowIndex + 1;
|
const nextRowIndex = current.rowIndex + 1;
|
||||||
const nextItem = col.items[nextRowIndex];
|
const nextItem = visibleItems[nextRowIndex];
|
||||||
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
|
setKeyboardPos({ colIndex: current.colIndex, rowIndex: nextRowIndex });
|
||||||
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
|
if (nextItem.isDir) handleDirClick(current.colIndex, nextItem);
|
||||||
else setSelectedInColumn(current.colIndex, nextItem.id);
|
else setSelectedInColumn(current.colIndex, nextItem.id);
|
||||||
@@ -424,7 +508,8 @@ export default function FolderBrowser() {
|
|||||||
if (current.colIndex > 0) {
|
if (current.colIndex > 0) {
|
||||||
clearSelectedInColumn(current.colIndex);
|
clearSelectedInColumn(current.colIndex);
|
||||||
const nextColIndex = current.colIndex - 1;
|
const nextColIndex = current.colIndex - 1;
|
||||||
const rowIndex = preferredRowIndex(columns[nextColIndex]);
|
clearFiltersRightOf(nextColIndex);
|
||||||
|
const rowIndex = preferredRowIndex(nextColIndex);
|
||||||
if (rowIndex >= 0) setKeyboardPos({ colIndex: nextColIndex, rowIndex });
|
if (rowIndex >= 0) setKeyboardPos({ colIndex: nextColIndex, rowIndex });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -432,9 +517,10 @@ export default function FolderBrowser() {
|
|||||||
if (key === 'ArrowRight') {
|
if (key === 'ArrowRight') {
|
||||||
const nextColIndex = current.colIndex + 1;
|
const nextColIndex = current.colIndex + 1;
|
||||||
if (nextColIndex < columns.length) {
|
if (nextColIndex < columns.length) {
|
||||||
const rowIndex = preferredRowIndex(columns[nextColIndex]);
|
const nextVisibleItems = filteredItemsByCol[nextColIndex] ?? [];
|
||||||
|
const rowIndex = Math.min(preferredRowIndex(nextColIndex), nextVisibleItems.length - 1);
|
||||||
if (rowIndex >= 0) {
|
if (rowIndex >= 0) {
|
||||||
const nextItem = columns[nextColIndex].items[rowIndex];
|
const nextItem = nextVisibleItems[rowIndex];
|
||||||
setSelectedInColumn(nextColIndex, nextItem.id);
|
setSelectedInColumn(nextColIndex, nextItem.id);
|
||||||
setKeyboardPos({ colIndex: nextColIndex, rowIndex });
|
setKeyboardPos({ colIndex: nextColIndex, rowIndex });
|
||||||
return;
|
return;
|
||||||
@@ -444,9 +530,16 @@ export default function FolderBrowser() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key === 'Enter') {
|
if (key === 'Enter') {
|
||||||
|
if (e.shiftKey && !item.isDir) {
|
||||||
|
const toAppend = (filteredItemsByCol[current.colIndex] ?? [])
|
||||||
|
.filter(it => !it.isDir)
|
||||||
|
.map(entryToTrack);
|
||||||
|
if (toAppend.length > 0) enqueue(toAppend);
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleActivate(current.colIndex, item);
|
handleActivate(current.colIndex, item);
|
||||||
}
|
}
|
||||||
}, [keyboardPos, fallbackNavPos, columns, preferredRowIndex, handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn, openContextMenuForEntry, isContextMenuOpen]);
|
}, [keyboardPos, fallbackNavPos, columns, preferredRowIndex, handleActivate, handleDirClick, setSelectedInColumn, clearSelectedInColumn, openContextMenuForEntry, isContextMenuOpen, filteredItemsByCol, filterFocusCol, columnFilters, enqueue, clearFiltersRightOf]);
|
||||||
|
|
||||||
const onRowContextMenu = useCallback(
|
const onRowContextMenu = useCallback(
|
||||||
(e: React.MouseEvent, colIndex: number, rowIndex: number, col: Column, item: SubsonicDirectoryEntry) => {
|
(e: React.MouseEvent, colIndex: number, rowIndex: number, col: Column, item: SubsonicDirectoryEntry) => {
|
||||||
@@ -602,6 +695,55 @@ export default function FolderBrowser() {
|
|||||||
className={`folder-col${isColumnCompact(col, colIndex) ? ' folder-col--compact' : ''}`}
|
className={`folder-col${isColumnCompact(col, colIndex) ? ' folder-col--compact' : ''}`}
|
||||||
data-folder-col-index={colIndex}
|
data-folder-col-index={colIndex}
|
||||||
>
|
>
|
||||||
|
{(filterFocusCol === colIndex || !!columnFilters[colIndex]) && (
|
||||||
|
<div className="folder-col-filter">
|
||||||
|
<input
|
||||||
|
ref={el => { filterInputRefs.current[colIndex] = el; }}
|
||||||
|
data-folder-filter-input="true"
|
||||||
|
className="folder-col-filter-input"
|
||||||
|
value={columnFilters[colIndex] ?? ''}
|
||||||
|
placeholder={t('playlists.searchPlaceholder')}
|
||||||
|
onFocus={() => setFilterFocusCol(colIndex)}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!(columnFilters[colIndex] ?? '').trim()) {
|
||||||
|
setFilterFocusCol(prev => (prev === colIndex ? null : prev));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setColumnFilters(prev => ({ ...prev, [colIndex]: '' }));
|
||||||
|
setFilterFocusCol(null);
|
||||||
|
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const rowIndex = preferredRowIndex(colIndex);
|
||||||
|
if (rowIndex >= 0) {
|
||||||
|
const nextItem = (filteredItemsByCol[colIndex] ?? [])[rowIndex];
|
||||||
|
if (nextItem) {
|
||||||
|
if (nextItem.isDir) handleDirClick(colIndex, nextItem);
|
||||||
|
else setSelectedInColumn(colIndex, nextItem.id);
|
||||||
|
}
|
||||||
|
setKeyboardPos({ colIndex, rowIndex });
|
||||||
|
requestAnimationFrame(() => wrapperRef.current?.focus({ preventScroll: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={e => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setColumnFilters(prev => ({ ...prev, [colIndex]: value }));
|
||||||
|
setKeyboardPos(prev => {
|
||||||
|
if (!prev || prev.colIndex !== colIndex) return prev;
|
||||||
|
return { colIndex, rowIndex: 0 };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{col.loading ? (
|
{col.loading ? (
|
||||||
<div className="folder-col-status">
|
<div className="folder-col-status">
|
||||||
<div className="spinner" style={{ width: 20, height: 20 }} />
|
<div className="spinner" style={{ width: 20, height: 20 }} />
|
||||||
@@ -610,12 +752,11 @@ export default function FolderBrowser() {
|
|||||||
<div className="folder-col-status folder-col-error">
|
<div className="folder-col-status folder-col-error">
|
||||||
{t('folderBrowser.error')}
|
{t('folderBrowser.error')}
|
||||||
</div>
|
</div>
|
||||||
) : col.items.length === 0 ? (
|
) : (filteredItemsByCol[colIndex]?.length ?? 0) === 0 ? (
|
||||||
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
|
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
|
||||||
) : (
|
) : (
|
||||||
col.items.map(item => {
|
(filteredItemsByCol[colIndex] ?? []).map((item, rowIndex) => {
|
||||||
const isSelected = col.selectedId === item.id;
|
const isSelected = col.selectedId === item.id;
|
||||||
const rowIndex = col.items.findIndex(it => it.id === item.id);
|
|
||||||
const isContextRow =
|
const isContextRow =
|
||||||
contextAnchorPos?.colIndex === colIndex && contextAnchorPos.rowIndex === rowIndex;
|
contextAnchorPos?.colIndex === colIndex && contextAnchorPos.rowIndex === rowIndex;
|
||||||
const isKeyboardRow =
|
const isKeyboardRow =
|
||||||
|
|||||||
@@ -7256,6 +7256,35 @@ html.no-compositing .fs-lyrics-rail {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.folder-col-filter {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
background: color-mix(in srgb, var(--bg-base) 92%, transparent);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-col-filter-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-col-filter-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 70%, var(--border-subtle));
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-col.folder-col--compact .folder-col-filter {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.folder-col-error {
|
.folder-col-error {
|
||||||
color: var(--danger, #f38ba8);
|
color: var(--danger, #f38ba8);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user