From e385bdd447bd94f547d34fd8b6500b64479a086a Mon Sep 17 00:00:00 2001 From: Moritz Reis Date: Wed, 9 Sep 2026 21:46:39 +0200 Subject: [PATCH 1/6] feat(search): add case-insensitive board filtering and clear functionality --- README.md | 1 + e2e/helpers/board-ui.mjs | 19 +++++++ e2e/specs/system.e2e.mjs | 18 +++++++ src/components/BoardList.css | 99 ++++++++++++++++++++++++++++++++++++ src/components/BoardList.tsx | 98 +++++++++++++++++++++++++++++++++-- 5 files changed, 231 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4a31b46..5a1a4e6 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ Current automated scenarios (`e2e/specs/system.e2e.mjs`): - Smoke flow (create board + open/close settings) - Board lifecycle (create, rename, duplicate) +- Sidebar search filtering (case-insensitive match + clear) - Board persistence across app restart (`browser.reloadSession`) - Settings persistence across app restart (`browser.reloadSession`) diff --git a/e2e/helpers/board-ui.mjs b/e2e/helpers/board-ui.mjs index 0ea433e..ad4987b 100644 --- a/e2e/helpers/board-ui.mjs +++ b/e2e/helpers/board-ui.mjs @@ -1,6 +1,8 @@ const SELECTORS = { createBoardInput: '[data-testid="create-board-input"]', createBoardSubmit: '[data-testid="create-board-submit"]', + boardSearchInput: '[data-testid="board-search-input"]', + boardSearchClear: '[data-testid="board-search-clear"]', settingsOpenButton: '[data-testid="open-settings-btn"]', settingsModal: '[data-testid="settings-modal"]', settingsCloseButton: '[data-testid="close-settings-btn"]', @@ -68,6 +70,23 @@ export async function waitForBoardVisible(name) { await boardName.waitForDisplayed({ timeout: 10000 }); } +export async function filterBoards(query) { + const searchInput = await $(SELECTORS.boardSearchInput); + await searchInput.waitForDisplayed({ timeout: 10000 }); + await searchInput.setValue(query); +} + +export async function clearBoardFilter() { + const clearButton = await $(SELECTORS.boardSearchClear); + await clearButton.waitForClickable({ timeout: 10000 }); + await clearButton.click(); +} + +export async function assertBoardHidden(name) { + const boardName = await $(boardNameXpath(name)); + await boardName.waitForExist({ reverse: true, timeout: 10000 }); +} + export async function selectBoard(name) { const boardItem = await $(boardItemXpath(name)); await boardItem.waitForDisplayed({ timeout: 10000 }); diff --git a/e2e/specs/system.e2e.mjs b/e2e/specs/system.e2e.mjs index ee2fc59..a0cfdf5 100644 --- a/e2e/specs/system.e2e.mjs +++ b/e2e/specs/system.e2e.mjs @@ -1,13 +1,16 @@ import { assertActiveBoard, assertEditorInstancePreserved, + assertBoardHidden, assertSidebarCollapsed, assertExportRowHidden, closeSettings, closeSettingsWithEscape, + clearBoardFilter, createBoard, createBoardFromCommandPalette, duplicateBoard, + filterBoards, openBoardFromCommandPalette, openSettings, openSettingsFromCommandPalette, @@ -58,6 +61,21 @@ describe('System suite', () => { await assertEditorInstancePreserved(); }); + it('sidebar search: filters board names case-insensitively and clears the filter', async () => { + const matchingBoard = uniqueBoardName('Search Match'); + const otherBoard = uniqueBoardName('Search Other'); + + await createBoard(matchingBoard); + await createBoard(otherBoard); + await filterBoards(matchingBoard.toUpperCase()); + + await waitForBoardVisible(matchingBoard); + await assertBoardHidden(otherBoard); + + await clearBoardFilter(); + await waitForBoardVisible(otherBoard); + }); + it('persistence: keeps created boards after app restart', async () => { const boardName = uniqueBoardName('Persistence Board'); const editedName = `${boardName} Edited`; diff --git a/src/components/BoardList.css b/src/components/BoardList.css index 8923a23..3018f81 100644 --- a/src/components/BoardList.css +++ b/src/components/BoardList.css @@ -210,6 +210,85 @@ cursor: not-allowed; } +.board-search { + position: relative; + display: flex; + align-items: center; + margin: var(--space-3) var(--space-4) var(--space-2); +} + +.board-search-icon { + position: absolute; + left: 10px; + color: var(--text-muted); + font-size: 12px; + pointer-events: none; +} + +.board-search-input { + width: 100%; + min-width: 0; + height: 34px; + background: var(--bg-folder-header); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: 0 92px 0 32px; + color: var(--text-primary); + font-size: 13px; + transition: + background 0.15s ease, + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.board-search-input::-webkit-search-cancel-button { + display: none; +} + +.board-search-input::placeholder { + color: var(--text-placeholder); +} + +.board-search-input:hover { + background: var(--bg-folder-header-hover); + border-color: var(--border-default); +} + +.board-search-input:focus { + outline: none; + background: var(--bg-elevated); + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-primary-ring); +} + +.board-search-clear { + position: absolute; + right: 8px; + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; +} + +.board-search-clear:hover { + background: var(--bg-active); + color: var(--text-primary); +} + +.board-search-status { + position: absolute; + right: 34px; + color: var(--text-muted); + font-size: 10px; + pointer-events: none; +} + .boards-scroll { flex: 1; overflow-y: auto; @@ -309,6 +388,22 @@ color: var(--text-disabled); } +.clear-filter-btn { + margin-top: var(--space-4); + padding: 6px 10px; + background: var(--bg-elevated); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; +} + +.clear-filter-btn:hover { + background: var(--bg-active); + color: var(--text-primary); +} + /* ============================================================================= Board Item Styles ============================================================================= */ @@ -475,6 +570,10 @@ color: var(--text-secondary); } +.folder-toggle:disabled { + cursor: default; +} + .folder-toggle svg { transition: transform 0.2s ease; } diff --git a/src/components/BoardList.tsx b/src/components/BoardList.tsx index 1b775c1..faa785c 100644 --- a/src/components/BoardList.tsx +++ b/src/components/BoardList.tsx @@ -20,11 +20,13 @@ import { faFileImage, faGear, faGripVertical, + faMagnifyingGlass, faPen, faPlus, faStar, faTrash, faUpload, + faXmark, } from '@fortawesome/free-solid-svg-icons'; import { DndContext, @@ -319,6 +321,7 @@ function DraggableBoardItem({ interface DraggableFolderItemProps { folder: BoardFolder; isCollapsed: boolean; + collapseDisabled?: boolean; isEditing: boolean; editName: string; onEditNameChange: (name: string) => void; @@ -335,6 +338,7 @@ interface DraggableFolderItemProps { function DraggableFolderItem({ folder, isCollapsed, + collapseDisabled, isEditing, editName, onEditNameChange, @@ -411,7 +415,15 @@ function DraggableFolderItem({ className={`folder-toggle ${isCollapsed ? 'collapsed' : ''}`} onPointerDown={(e) => e.stopPropagation()} onClick={handleToggleClick} - aria-label={isCollapsed ? 'Expand folder' : 'Collapse folder'} + disabled={collapseDisabled} + aria-label={ + collapseDisabled + ? 'Folders stay expanded while filtering' + : isCollapsed + ? 'Expand folder' + : 'Collapse folder' + } + title={collapseDisabled ? 'Folders stay expanded while filtering' : undefined} > @@ -514,6 +526,7 @@ export function BoardList({ // State // --------------------------------------------------------------------------- const [newBoardName, setNewBoardName] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const [editingFolderId, setEditingFolderId] = useState(null); @@ -573,6 +586,44 @@ export function BoardList({ [flattenedBoards], ); + const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase(); + const isFiltering = normalizedSearchQuery.length > 0; + + const filteredItems = useMemo(() => { + if (!normalizedSearchQuery) return items; + + return items.reduce((matches, item) => { + if (item.type === 'board') { + if (item.name.toLocaleLowerCase().includes(normalizedSearchQuery)) { + matches.push(item); + } + return matches; + } + + if (item.name.toLocaleLowerCase().includes(normalizedSearchQuery)) { + matches.push(item); + return matches; + } + + const matchingBoards = item.items.filter((board) => + board.name.toLocaleLowerCase().includes(normalizedSearchQuery), + ); + if (matchingBoards.length > 0) { + matches.push({ ...item, items: matchingBoards }); + } + return matches; + }, []); + }, [items, normalizedSearchQuery]); + + const filteredBoardCount = useMemo( + () => + filteredItems.reduce( + (count, item) => count + (item.type === 'folder' ? item.items.length : 1), + 0, + ), + [filteredItems], + ); + const duplicateImportIds = useMemo(() => { const seen = new Set(); const duplicates = new Set(); @@ -925,7 +976,7 @@ export function BoardList({ }); }; - const dragDisabled = Boolean(activeMenu || editingId || editingFolderId); + const dragDisabled = Boolean(activeMenu || editingId || editingFolderId || isFiltering); // --------------------------------------------------------------------------- // Thumbnail Hover Handlers @@ -1281,14 +1332,52 @@ export function BoardList({ +
+ + setSearchQuery(event.target.value)} + placeholder="Filter boards..." + aria-label="Filter boards" + className="board-search-input" + /> + {isFiltering ? ( + + ) : null} + + {isFiltering + ? `${filteredBoardCount} ${filteredBoardCount === 1 ? 'match' : 'matches'}` + : ''} + +
+
{items.length === 0 ? (

No boards yet

Create a new board to get started

+ ) : filteredItems.length === 0 ? ( +
+

No matching boards

+

Try another board or folder name

+ +
) : ( - items.map((item) => { + filteredItems.map((item) => { if (item.type === 'folder') { const folderId = makeDragId('folder', item.id); const isOverFolder = dragState.overId === folderId; @@ -1299,7 +1388,8 @@ export function BoardList({ Date: Sat, 19 Sep 2026 12:30:32 +0200 Subject: [PATCH 2/6] chore: pnpm update --- package.json | 3 +- pnpm-lock.yaml | 158 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e68664..8c9237a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "excastoneboard", "private": true, "version": "0.3.1", - "packageManager": "pnpm@11.15.0+sha512.266f8957a30d2be6e9468e5e66bcdedd35a794175f71b067ba8504d686cce1d0c0f429b33c323c3c569ad4891e667574a49ff71d1b89a22cc66f13c65818c578", + "packageManager": "pnpm@12.4.2+sha512.08adc6613180275c7c9edada39dcf08c9c61ad4e7eaf330a4f3461f102b0f907423454d117f98e72d47fef0616070644d7bffc973a6a57f5090a6d7c368b07c9", "description": "Multi-board manager for Excalidraw - manage multiple drawing boards without losing access to old ones", "type": "module", "scripts": { @@ -14,6 +14,7 @@ "preview": "vite preview", "test:system": "pnpm --dir e2e install && pnpm --dir e2e test", "tauri": "tauri", + "tauri:dev": "tauri dev", "tauri:windows:build": "tauri-windows-bundle build" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1aa4b4..cb4a4c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.4.2 + version: 12.4.2 + +packages: + + '@pnpm/exe.android-arm64@12.4.2': + resolution: {integrity: sha512-E255MbcQ0V1577M2BV0ajWuP7KmTPYA0jqZnk3rK6Lq3kTvZWulMMb7iqRmnLsQbyWTkMEB2CfyM70loVDA8xg==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.4.2': + resolution: {integrity: sha512-J1pSeCUwuKxMG70ZzpWn8JzElxiEa8NN/3N0SlZRtU2xbztChaJCKF3HaBNIj9yPfeofWzJo/lwNvDDjraQuZw==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.4.2': + resolution: {integrity: sha512-A0WDo8iErfZBXgrLseQxw8i8Y9ctUpOEl/Uu+cubnTzpD8tT9ykIB548L8YTM2WD4OS+ZOHSxy8aGZcvKq8PaQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.4.2': + resolution: {integrity: sha512-MSgJdovBWHcb5DEOvfPH9yNi/T5O1Xa4ess+E1ESGo/5yuty7S4JoiIjami+fsNXfnoQMlwsMUqYU4zAvBcNCw==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.4.2': + resolution: {integrity: sha512-h2YumlQSNvgbRPv+RXwABohX65f9bOBZn+jMIt7bFDISZPCzQ+Nvpt6Awbp4ip5PwQgYxbu5iREJ1fHE39Fm8A==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.4.2': + resolution: {integrity: sha512-LwSEtSEDTv6S51YLs3YvSkPyun/QmfMic1UGICUkPWFu6ByP43RdMlkKvmVkfGhAYCpnxO057vrmyJqtfZrPCA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.4.2': + resolution: {integrity: sha512-2dSiDXyhx+RTHsewxex8f/jVjqQXWJ2oow4kCVHEWdZKeBpgMxvZ6fHkTAUBJXgqhbKIDHvuNlZBP7gJfUWL5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.4.2': + resolution: {integrity: sha512-8Itc+jQk+MTz04LS9D1RcH+VctAWmzM4l1cJQ+Sx7pAJNo7EKHdRMbC0Tok1jyQ7eEHNJZD5EleYneZqWfZM+g==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.4.2': + resolution: {integrity: sha512-hleOeqhTVpH+z9RVMGnxvU4ZnrkBUClWjCbHD9u6kwyqhGSpevoU1wTGish+CBRhmIgMAy9pAfFpqhbAKOKNfw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.4.2': + resolution: {integrity: sha512-LAsQRRdP9aToENR6dtcIJ9l+e1zMYOX0tQcLGpRyLPVBQcYRLlvAPcmDshsiIHQjp05SDa9FI0czX1ZQ1a7a/A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.4.2': + resolution: {integrity: sha512-kzfzH2/0BWdTABK14Yj5a1xsdkTEQUp2eXEPNakaD9jKL025lq3hyaKHIz/gIZaPDMe/1bFFK/En4ztFlbBJxw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.4.2': + resolution: {integrity: sha512-/pbt0UVTa8NMDhzOWLQRfZ6G9ROKXlJPZtx845BqyWfc7hCrWXhTLBd70yO2y8+E+IWN3oHM1s/JsIQNGk1yvg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.4.2': + resolution: {integrity: sha512-PsW19e4dAUNpZ0cS9flaxFuAmpt2dKlH/Vvi8TZ4qyJjjQzue/CEsWm+6wKVP4CJ7IT8RdL4qh+soOPWIgF2Zw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.4.2': + resolution: {integrity: sha512-+xGoeE0g55ztWvl8i5QqdmNfW3nIrTVcoQrNshEOwxthm9Ag48oXton3uxOA3/SANyz5AXexEjj2KO20NnOrEw==} + cpu: [x64] + os: [win32] + + pnpm@12.4.2: + resolution: {integrity: sha512-CK3GYTGAJ1x8ntraOdzwjJxhrU5+rzMKTzRh8QKw+QdCNFTRF/mOctR/7wYWBwZE17/8lzpqV/UJCm18NosHyQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.4.2': + optional: true + + '@pnpm/exe.android-x64@12.4.2': + optional: true + + '@pnpm/exe.darwin-arm64@12.4.2': + optional: true + + '@pnpm/exe.darwin-x64@12.4.2': + optional: true + + '@pnpm/exe.freebsd-x64@12.4.2': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.4.2': + optional: true + + '@pnpm/exe.linux-arm64@12.4.2': + optional: true + + '@pnpm/exe.linux-ppc64@12.4.2': + optional: true + + '@pnpm/exe.linux-riscv64@12.4.2': + optional: true + + '@pnpm/exe.linux-s390x@12.4.2': + optional: true + + '@pnpm/exe.linux-x64-musl@12.4.2': + optional: true + + '@pnpm/exe.linux-x64@12.4.2': + optional: true + + '@pnpm/exe.win32-arm64@12.4.2': + optional: true + + '@pnpm/exe.win32-x64@12.4.2': + optional: true + + pnpm@12.4.2: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.4.2 + '@pnpm/exe.android-x64': 12.4.2 + '@pnpm/exe.darwin-arm64': 12.4.2 + '@pnpm/exe.darwin-x64': 12.4.2 + '@pnpm/exe.freebsd-x64': 12.4.2 + '@pnpm/exe.linux-arm64': 12.4.2 + '@pnpm/exe.linux-arm64-musl': 12.4.2 + '@pnpm/exe.linux-ppc64': 12.4.2 + '@pnpm/exe.linux-riscv64': 12.4.2 + '@pnpm/exe.linux-s390x': 12.4.2 + '@pnpm/exe.linux-x64': 12.4.2 + '@pnpm/exe.linux-x64-musl': 12.4.2 + '@pnpm/exe.win32-arm64': 12.4.2 + '@pnpm/exe.win32-x64': 12.4.2 + +--- lockfileVersion: '9.0' settings: From ba7a0d6dd4cf21c261f1426b3acd37c36dfdde71 Mon Sep 17 00:00:00 2001 From: Moritz Reis Date: Sat, 19 Sep 2026 12:37:55 +0200 Subject: [PATCH 3/6] feat: toggle search functionality --- e2e/helpers/board-ui.mjs | 15 ++++++ src/components/BoardList.css | 22 ++++++++ src/components/BoardList.tsx | 97 +++++++++++++++++++++++++----------- 3 files changed, 105 insertions(+), 29 deletions(-) diff --git a/e2e/helpers/board-ui.mjs b/e2e/helpers/board-ui.mjs index ad4987b..dae867a 100644 --- a/e2e/helpers/board-ui.mjs +++ b/e2e/helpers/board-ui.mjs @@ -3,6 +3,7 @@ const SELECTORS = { createBoardSubmit: '[data-testid="create-board-submit"]', boardSearchInput: '[data-testid="board-search-input"]', boardSearchClear: '[data-testid="board-search-clear"]', + toggleSearchButton: '[data-testid="toggle-search-btn"]', settingsOpenButton: '[data-testid="open-settings-btn"]', settingsModal: '[data-testid="settings-modal"]', settingsCloseButton: '[data-testid="close-settings-btn"]', @@ -70,7 +71,21 @@ export async function waitForBoardVisible(name) { await boardName.waitForDisplayed({ timeout: 10000 }); } +export async function openBoardSearch() { + const searchInput = await $(SELECTORS.boardSearchInput); + if (await searchInput.isDisplayed().catch(() => false)) { + return; + } + + const toggleButton = await $(SELECTORS.toggleSearchButton); + await toggleButton.waitForClickable({ timeout: 10000 }); + await toggleButton.click(); + await searchInput.waitForDisplayed({ timeout: 10000 }); +} + export async function filterBoards(query) { + await openBoardSearch(); + const searchInput = await $(SELECTORS.boardSearchInput); await searchInput.waitForDisplayed({ timeout: 10000 }); await searchInput.setValue(query); diff --git a/src/components/BoardList.css b/src/components/BoardList.css index 3018f81..14f2f7d 100644 --- a/src/components/BoardList.css +++ b/src/components/BoardList.css @@ -69,6 +69,16 @@ color: var(--text-primary); } +.icon-btn.active { + background: var(--accent-primary-subtle); + color: var(--accent-primary); +} + +.icon-btn.active:hover { + background: var(--accent-primary-subtle); + color: var(--accent-primary); +} + .toggle-btn:hover { background: var(--bg-active); color: var(--text-primary); @@ -215,6 +225,18 @@ display: flex; align-items: center; margin: var(--space-3) var(--space-4) var(--space-2); + animation: board-search-fade-in 0.15s ease; +} + +@keyframes board-search-fade-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } } .board-search-icon { diff --git a/src/components/BoardList.tsx b/src/components/BoardList.tsx index faa785c..189b43f 100644 --- a/src/components/BoardList.tsx +++ b/src/components/BoardList.tsx @@ -527,6 +527,7 @@ export function BoardList({ // --------------------------------------------------------------------------- const [newBoardName, setNewBoardName] = useState(''); const [searchQuery, setSearchQuery] = useState(''); + const [isSearchOpen, setIsSearchOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const [editingFolderId, setEditingFolderId] = useState(null); @@ -565,6 +566,7 @@ export function BoardList({ const boardsScrollRef = useRef(null); const menuRef = useRef(null); + const searchInputRef = useRef(null); // --------------------------------------------------------------------------- // Memoized Data @@ -647,6 +649,12 @@ export function BoardList({ // --------------------------------------------------------------------------- // Effects // --------------------------------------------------------------------------- + useEffect(() => { + if (isSearchOpen) { + searchInputRef.current?.focus(); + } + }, [isSearchOpen]); + useEffect(() => { if (!activeMenu) return; const handleClick = (event: MouseEvent) => { @@ -782,6 +790,24 @@ export function BoardList({ } }; + const handleToggleSearch = () => { + setIsSearchOpen((prev) => { + const next = !prev; + if (!next) { + setSearchQuery(''); + } + return next; + }); + }; + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + setIsSearchOpen(false); + setSearchQuery(''); + } + }; + const handleStartEdit = (board: Board) => { setEditingId(board.id); setEditName(board.name); @@ -1264,6 +1290,15 @@ export function BoardList({

Boards

+ - ) : null} - - {isFiltering - ? `${filteredBoardCount} ${filteredBoardCount === 1 ? 'match' : 'matches'}` - : ''} - -
+ {isSearchOpen && ( +
+ + setSearchQuery(event.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="Filter boards..." + aria-label="Filter boards" + className="board-search-input" + /> + {isFiltering ? ( + + ) : null} + + {isFiltering + ? `${filteredBoardCount} ${filteredBoardCount === 1 ? 'match' : 'matches'}` + : ''} + +
+ )}
{items.length === 0 ? ( From b06bb3b03a6852a0e71b725a1c9941db7c9e587c Mon Sep 17 00:00:00 2001 From: Moritz Reis Date: Sat, 19 Sep 2026 12:44:08 +0200 Subject: [PATCH 4/6] feat: add search to command palette and sidebar --- e2e/helpers/board-ui.mjs | 20 ++++++++++++++++++++ e2e/specs/system.e2e.mjs | 4 ++++ src/App.tsx | 18 ++++++++++++++++++ src/components/BoardList.tsx | 16 ++++++++++++++-- 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/e2e/helpers/board-ui.mjs b/e2e/helpers/board-ui.mjs index dae867a..e1b34bc 100644 --- a/e2e/helpers/board-ui.mjs +++ b/e2e/helpers/board-ui.mjs @@ -14,6 +14,7 @@ const SELECTORS = { commandPaletteInput: '[data-testid="command-palette-input"]', commandPaletteCreateBoardItem: '[data-testid="command-palette-item-create-board"]', commandPaletteOpenBoardItem: '[data-testid="command-palette-item-open-board"]', + commandPaletteSearchBoardsItem: '[data-testid="command-palette-item-search-boards"]', commandPaletteOpenSettingsItem: '[data-testid="command-palette-item-open-settings"]', }; @@ -259,6 +260,25 @@ export async function openSettingsFromCommandPalette() { await settingsModal.waitForDisplayed({ timeout: 10000 }); } +export async function openSearchFromCommandPalette() { + await openCommandPalette(); + + const paletteInput = await $(SELECTORS.commandPaletteInput); + await paletteInput.waitForDisplayed({ timeout: 10000 }); + await paletteInput.setValue('search boards'); + + const searchBoardsItem = await $(SELECTORS.commandPaletteSearchBoardsItem); + await searchBoardsItem.waitForDisplayed({ timeout: 10000 }); + await browser.keys('Enter'); + + const searchInput = await $(SELECTORS.boardSearchInput); + await searchInput.waitForDisplayed({ timeout: 10000 }); + await browser.waitUntil(async () => searchInput.isFocused(), { + timeout: 10000, + timeoutMsg: 'Expected the sidebar board search to receive focus.', + }); +} + export async function openBoardFromCommandPalette(name) { await openCommandPalette(); diff --git a/e2e/specs/system.e2e.mjs b/e2e/specs/system.e2e.mjs index a0cfdf5..af3792e 100644 --- a/e2e/specs/system.e2e.mjs +++ b/e2e/specs/system.e2e.mjs @@ -12,6 +12,7 @@ import { duplicateBoard, filterBoards, openBoardFromCommandPalette, + openSearchFromCommandPalette, openSettings, openSettingsFromCommandPalette, rememberEditorInstance, @@ -124,5 +125,8 @@ describe('System suite', () => { await openBoardFromCommandPalette(secondBoardName); await openSettingsFromCommandPalette(); await closeSettings(); + await setSidebarCollapsed(true); + await openSearchFromCommandPalette(); + await setSidebarCollapsed(false); }); }); diff --git a/src/App.tsx b/src/App.tsx index 469bca4..3f025fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -67,6 +67,7 @@ interface CommandPaletteCommandsConfig { renameBoard: AppController['renameBoard']; deleteBoard: AppController['deleteBoard']; duplicateBoard: AppController['duplicateBoard']; + requestOpenBoardSearch: () => void; requestOpenSettings: () => void; requestOpenImportBoards: () => void; sidebarCollapsed: AppController['sidebarCollapsed']; @@ -264,6 +265,7 @@ const createCommandPaletteCommands = ({ renameBoard, deleteBoard, duplicateBoard, + requestOpenBoardSearch, requestOpenSettings, requestOpenImportBoards, sidebarCollapsed, @@ -305,6 +307,13 @@ const createCommandPaletteCommands = ({ action: createCreateBoardAction(createBoard), }, ...createBoardCommandGroupCommands(hasBoards, boardSearchEmptyState, boardCommands), + { + id: 'search-boards', + label: 'Search boards', + description: 'Open and focus the sidebar board filter', + keywords: 'search boards filter find sidebar', + action: requestOpenBoardSearch, + }, { id: 'export-boards', label: 'Export boards', @@ -432,6 +441,13 @@ const useCommandPaletteController = ({ window.dispatchEvent(new CustomEvent('boardlist:open-settings')); }, []); + const requestOpenBoardSearch = useCallback(() => { + if (sidebarCollapsed) { + toggleSidebar(); + } + window.dispatchEvent(new CustomEvent('boardlist:open-search')); + }, [sidebarCollapsed, toggleSidebar]); + const requestOpenImportBoards = useCallback(() => { window.dispatchEvent(new CustomEvent('boardlist:import-boards')); }, []); @@ -448,6 +464,7 @@ const useCommandPaletteController = ({ renameBoard, deleteBoard, duplicateBoard, + requestOpenBoardSearch, requestOpenSettings, requestOpenImportBoards, sidebarCollapsed, @@ -477,6 +494,7 @@ const useCommandPaletteController = ({ handleExportSvg, handleSelectBoard, renameBoard, + requestOpenBoardSearch, requestOpenImportBoards, requestOpenSettings, sidebarCollapsed, diff --git a/src/components/BoardList.tsx b/src/components/BoardList.tsx index 189b43f..53fe323 100644 --- a/src/components/BoardList.tsx +++ b/src/components/BoardList.tsx @@ -650,10 +650,10 @@ export function BoardList({ // Effects // --------------------------------------------------------------------------- useEffect(() => { - if (isSearchOpen) { + if (isSearchOpen && !isCollapsed) { searchInputRef.current?.focus(); } - }, [isSearchOpen]); + }, [isCollapsed, isSearchOpen]); useEffect(() => { if (!activeMenu) return; @@ -736,6 +736,18 @@ export function BoardList({ }; }, []); + useEffect(() => { + const handleOpenSearch = () => { + searchInputRef.current?.focus(); + setIsSearchOpen(true); + }; + + window.addEventListener('boardlist:open-search', handleOpenSearch); + return () => { + window.removeEventListener('boardlist:open-search', handleOpenSearch); + }; + }, []); + useEffect(() => { const handleOpenSettings = () => { setSettingsOpen(true); From 8ce4971d2e09d6a63a695a4ba127a16941978822 Mon Sep 17 00:00:00 2001 From: Moritz Reis Date: Sat, 19 Sep 2026 13:02:56 +0200 Subject: [PATCH 5/6] chore: improve code quality --- src/App.tsx | 16 +- src/components/BoardList.tsx | 1335 +++++++++++++++++++++------------- 2 files changed, 823 insertions(+), 528 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3f025fe..c988dce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -255,6 +255,14 @@ const createBoardCommandGroupCommands = ( }), ]; +const createSearchBoardsCommand = (requestOpenBoardSearch: () => void): CommandPaletteItem => ({ + id: 'search-boards', + label: 'Search boards', + description: 'Open and focus the sidebar board filter', + keywords: 'search boards filter find sidebar', + action: requestOpenBoardSearch, +}); + const createCommandPaletteCommands = ({ activeBoardId, boardDataLoading, @@ -307,13 +315,7 @@ const createCommandPaletteCommands = ({ action: createCreateBoardAction(createBoard), }, ...createBoardCommandGroupCommands(hasBoards, boardSearchEmptyState, boardCommands), - { - id: 'search-boards', - label: 'Search boards', - description: 'Open and focus the sidebar board filter', - keywords: 'search boards filter find sidebar', - action: requestOpenBoardSearch, - }, + createSearchBoardsCommand(requestOpenBoardSearch), { id: 'export-boards', label: 'Export boards', diff --git a/src/components/BoardList.tsx b/src/components/BoardList.tsx index 53fe323..2fde14a 100644 --- a/src/components/BoardList.tsx +++ b/src/components/BoardList.tsx @@ -102,11 +102,18 @@ interface DragState { dropPosition: DropPosition | null; } +interface ThumbnailPreviewState { + boardId: string; + anchorRect: DOMRect; +} + interface ImportBoardEntry extends BoardsExportEntry { key: string; index: number; } +type FlattenedBoard = { board: Board; folderId?: string }; + // ============================================================================= // Utility Functions // ============================================================================= @@ -189,6 +196,111 @@ const buildImportBoards = (payload: Partial): ImportBoardEntry }, []); }; +const filterBoardItems = (items: BoardListItem[], normalizedQuery: string): BoardListItem[] => { + if (!normalizedQuery) return items; + + return items.reduce((matches, item) => { + if (item.type === 'board') { + if (item.name.toLocaleLowerCase().includes(normalizedQuery)) matches.push(item); + return matches; + } + + if (item.name.toLocaleLowerCase().includes(normalizedQuery)) { + matches.push(item); + return matches; + } + + const matchingBoards = item.items.filter((board) => + board.name.toLocaleLowerCase().includes(normalizedQuery), + ); + if (matchingBoards.length > 0) matches.push({ ...item, items: matchingBoards }); + return matches; + }, []); +}; + +const countBoards = (items: BoardListItem[]) => + items.reduce((count, item) => count + (item.type === 'folder' ? item.items.length : 1), 0); + +const shouldDisableDrag = ( + menuOpen: boolean, + editingBoard: boolean, + editingFolder: boolean, + isFiltering: boolean, +) => menuOpen || editingBoard || editingFolder || isFiltering; + +const shouldCollapseFolder = (isFiltering: boolean, isCollapsed: boolean) => + !isFiltering && isCollapsed; + +const getDragPointerY = (event: DragMoveEvent) => { + const activatorEvent = event.activatorEvent as PointerEvent | MouseEvent | TouchEvent; + let initialY = 0; + if ('clientY' in activatorEvent) { + initialY = activatorEvent.clientY; + } else if ('touches' in activatorEvent && activatorEvent.touches.length > 0) { + initialY = activatorEvent.touches[0].clientY; + } + return initialY + (event.delta?.y ?? 0); +}; + +const useBoardSearch = (items: BoardListItem[], sidebarCollapsed: boolean) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const searchInputRef = useRef(null); + const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase(); + const isFiltering = normalizedSearchQuery.length > 0; + const filteredItems = useMemo( + () => filterBoardItems(items, normalizedSearchQuery), + [items, normalizedSearchQuery], + ); + const filteredBoardCount = useMemo(() => countBoards(filteredItems), [filteredItems]); + + const clearSearch = useCallback(() => setSearchQuery(''), []); + const closeSearch = useCallback(() => { + setIsSearchOpen(false); + clearSearch(); + }, [clearSearch]); + const toggleSearch = useCallback(() => { + setIsSearchOpen((open) => { + if (open) clearSearch(); + return !open; + }); + }, [clearSearch]); + const handleSearchKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + closeSearch(); + }, + [closeSearch], + ); + + useEffect(() => { + if (isSearchOpen && !sidebarCollapsed) searchInputRef.current?.focus(); + }, [isSearchOpen, sidebarCollapsed]); + + useEffect(() => { + const openSearch = () => { + searchInputRef.current?.focus(); + setIsSearchOpen(true); + }; + window.addEventListener('boardlist:open-search', openSearch); + return () => window.removeEventListener('boardlist:open-search', openSearch); + }, []); + + return { + clearSearch, + filteredBoardCount, + filteredItems, + handleSearchKeyDown, + isFiltering, + isSearchOpen, + searchInputRef, + searchQuery, + setSearchQuery, + toggleSearch, + }; +}; + // ============================================================================= // Draggable/Droppable Item Components // ============================================================================= @@ -335,6 +447,39 @@ interface DraggableFolderItemProps { children: React.ReactNode; } +interface FolderCollapseToggleProps { + collapseDisabled?: boolean; + isCollapsed: boolean; + onToggle: (event: React.MouseEvent) => void; +} + +function FolderCollapseToggle({ + collapseDisabled, + isCollapsed, + onToggle, +}: FolderCollapseToggleProps) { + const disabledLabel = 'Folders stay expanded while filtering'; + const label = collapseDisabled + ? disabledLabel + : isCollapsed + ? 'Expand folder' + : 'Collapse folder'; + + return ( + + ); +} + function DraggableFolderItem({ folder, isCollapsed, @@ -410,23 +555,11 @@ function DraggableFolderItem({
) : (
- + @@ -493,6 +626,499 @@ function FolderOverlay({ folder }: FolderOverlayProps) { ); } +interface BoardSearchToggleProps { + isOpen: boolean; + onToggle: () => void; +} + +function BoardSearchToggle({ isOpen, onToggle }: BoardSearchToggleProps) { + const label = isOpen ? 'Hide search' : 'Search boards'; + + return ( + + ); +} + +interface BoardSearchProps { + count: number; + inputRef: React.RefObject; + isFiltering: boolean; + isOpen: boolean; + onChange: (query: string) => void; + onClear: () => void; + onKeyDown: (event: React.KeyboardEvent) => void; + query: string; +} + +function BoardSearch({ + count, + inputRef, + isFiltering, + isOpen, + onChange, + onClear, + onKeyDown, + query, +}: BoardSearchProps) { + if (!isOpen) return null; + const status = isFiltering ? `${count} ${count === 1 ? 'match' : 'matches'}` : ''; + + return ( +
+ + onChange(event.target.value)} + onKeyDown={onKeyDown} + placeholder="Filter boards..." + aria-label="Filter boards" + className="board-search-input" + /> + {isFiltering && ( + + )} + + {status} + +
+ ); +} + +interface BoardListEmptyStateProps { + hasBoards: boolean; + hasMatches: boolean; + onClearFilter: () => void; +} + +function BoardListEmptyState({ hasBoards, hasMatches, onClearFilter }: BoardListEmptyStateProps) { + if (!hasBoards) { + return ( +
+

No boards yet

+

Create a new board to get started

+
+ ); + } + + if (hasMatches) return null; + return ( +
+

No matching boards

+

Try another board or folder name

+ +
+ ); +} + +interface BoardExportActionsProps { + disabled: boolean; + hidden: boolean; + onCopyPng: () => void; + onExportPng: () => void; + onExportSvg: () => void; +} + +function BoardExportActions({ + disabled, + hidden, + onCopyPng, + onExportPng, + onExportSvg, +}: BoardExportActionsProps) { + if (hidden) return null; + + return ( +
+ + + +
+ ); +} + +interface CollapsedBoardListProps { + activeBoardId: string | null; + boards: FlattenedBoard[]; + onExpand: () => void; + onSelectBoard: (boardId: string) => void; +} + +function CollapsedBoardList({ + activeBoardId, + boards, + onExpand, + onSelectBoard, +}: CollapsedBoardListProps) { + return ( +
+ +
+ {boards.map(({ board }) => ( + + ))} +
+
+ ); +} + +const formatAppVersion = (version: string | null) => { + if (!version) return 'Loading...'; + return version === 'Unknown' ? version : `v${version}`; +}; + +interface SettingsDialogProps { + appVersion: string | null; + boardsExporting: boolean; + boardsImporting: boolean; + hideExportRow: boolean; + importDialogOpen: boolean; + importError: string | null; + isOpen: boolean; + onClose: () => void; + onExportBoards: () => Promise; + onHideExportRowChange: (value: boolean) => void; + onOpenImport: () => void; + onOpenReleases: () => void; + onShowTimestampsChange: (value: boolean) => void; + showTimestamps: boolean; +} + +function SettingsDialog({ + appVersion, + boardsExporting, + boardsImporting, + hideExportRow, + importDialogOpen, + importError, + isOpen, + onClose, + onExportBoards, + onHideExportRowChange, + onOpenImport, + onOpenReleases, + onShowTimestampsChange, + showTimestamps, +}: SettingsDialogProps) { + if (!isOpen) return null; + + return createPortal( +
+
event.stopPropagation()} + > +

Settings

+
+
Boards
+
+ + +
+
+
+
Display
+ +
+ {!importDialogOpen && importError &&
{importError}
} +
+
Sidebar
+ +
+
+ Version + +
+
+ +
+
+
, + document.body, + ); +} + +interface ImportDialogProps { + boardsImporting: boolean; + duplicateImportIds: Set; + error: string | null; + existingBoardIds: Set; + importBoards: ImportBoardEntry[]; + importSelection: Record; + isOpen: boolean; + onClearAll: () => void; + onClose: () => void; + onConfirm: () => void; + onSelectAll: () => void; + onToggleSelection: (key: string) => void; + selectedCount: number; + sourceName: string | null; +} + +function ImportDialog({ + boardsImporting, + duplicateImportIds, + error, + existingBoardIds, + importBoards, + importSelection, + isOpen, + onClearAll, + onClose, + onConfirm, + onSelectAll, + onToggleSelection, + selectedCount, + sourceName, +}: ImportDialogProps) { + if (!isOpen) return null; + + const handleOverlayClick = () => { + if (!boardsImporting) onClose(); + }; + + return createPortal( +
+
event.stopPropagation()}> +

Import boards

+ {sourceName &&

Source: {sourceName}

} +
+ + +
+
+ {importBoards.map((entry) => { + const isSelected = Boolean(importSelection[entry.key]); + const hasId = Boolean(entry.id); + const isDuplicate = + hasId && (existingBoardIds.has(entry.id) || duplicateImportIds.has(entry.id)); + return ( + + ); + })} +
+
{selectedCount} selected
+ {error &&
{error}
} +
+ + +
+
+
, + document.body, + ); +} + +interface BoardDragOverlayProps { + activeItem: Board | BoardFolder | null | undefined; + formatDate: (date: string) => string; + showTimestamps: boolean; +} + +function BoardDragOverlay({ activeItem, formatDate, showTimestamps }: BoardDragOverlayProps) { + let content: React.ReactNode = null; + if (activeItem) { + content = + 'items' in activeItem ? ( + + ) : ( + + ); + } + + return ( + + {content} + + ); +} + +interface ThumbnailPreviewPortalProps { + preview: ThumbnailPreviewState | null; + thumbnails: Record; +} + +function ThumbnailPreviewPortal({ preview, thumbnails }: ThumbnailPreviewPortalProps) { + if (!preview) return null; + const thumbnail = thumbnails[preview.boardId]; + if (!thumbnail) return null; + + return createPortal( +
+ Board preview +
, + document.body, + ); +} + +interface ContextMenuPortalProps { + activeMenu: boolean; + children: React.ReactNode; + menuRef: React.RefObject; + style: React.CSSProperties; +} + +function ContextMenuPortal({ activeMenu, children, menuRef, style }: ContextMenuPortalProps) { + if (!activeMenu || !children) return null; + + return createPortal( +
+ {children} +
, + document.body, + ); +} + // ============================================================================= // Main Component // ============================================================================= @@ -526,8 +1152,18 @@ export function BoardList({ // State // --------------------------------------------------------------------------- const [newBoardName, setNewBoardName] = useState(''); - const [searchQuery, setSearchQuery] = useState(''); - const [isSearchOpen, setIsSearchOpen] = useState(false); + const { + clearSearch, + filteredBoardCount, + filteredItems, + handleSearchKeyDown, + isFiltering, + isSearchOpen, + searchInputRef, + searchQuery, + setSearchQuery, + toggleSearch, + } = useBoardSearch(items, isCollapsed); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const [editingFolderId, setEditingFolderId] = useState(null); @@ -566,13 +1202,10 @@ export function BoardList({ const boardsScrollRef = useRef(null); const menuRef = useRef(null); - const searchInputRef = useRef(null); // --------------------------------------------------------------------------- // Memoized Data // --------------------------------------------------------------------------- - type FlattenedBoard = { board: Board; folderId?: string }; - const flattenedBoards = useMemo( () => items.flatMap((item) => @@ -588,44 +1221,6 @@ export function BoardList({ [flattenedBoards], ); - const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase(); - const isFiltering = normalizedSearchQuery.length > 0; - - const filteredItems = useMemo(() => { - if (!normalizedSearchQuery) return items; - - return items.reduce((matches, item) => { - if (item.type === 'board') { - if (item.name.toLocaleLowerCase().includes(normalizedSearchQuery)) { - matches.push(item); - } - return matches; - } - - if (item.name.toLocaleLowerCase().includes(normalizedSearchQuery)) { - matches.push(item); - return matches; - } - - const matchingBoards = item.items.filter((board) => - board.name.toLocaleLowerCase().includes(normalizedSearchQuery), - ); - if (matchingBoards.length > 0) { - matches.push({ ...item, items: matchingBoards }); - } - return matches; - }, []); - }, [items, normalizedSearchQuery]); - - const filteredBoardCount = useMemo( - () => - filteredItems.reduce( - (count, item) => count + (item.type === 'folder' ? item.items.length : 1), - 0, - ), - [filteredItems], - ); - const duplicateImportIds = useMemo(() => { const seen = new Set(); const duplicates = new Set(); @@ -649,12 +1244,6 @@ export function BoardList({ // --------------------------------------------------------------------------- // Effects // --------------------------------------------------------------------------- - useEffect(() => { - if (isSearchOpen && !isCollapsed) { - searchInputRef.current?.focus(); - } - }, [isCollapsed, isSearchOpen]); - useEffect(() => { if (!activeMenu) return; const handleClick = (event: MouseEvent) => { @@ -736,18 +1325,6 @@ export function BoardList({ }; }, []); - useEffect(() => { - const handleOpenSearch = () => { - searchInputRef.current?.focus(); - setIsSearchOpen(true); - }; - - window.addEventListener('boardlist:open-search', handleOpenSearch); - return () => { - window.removeEventListener('boardlist:open-search', handleOpenSearch); - }; - }, []); - useEffect(() => { const handleOpenSettings = () => { setSettingsOpen(true); @@ -802,24 +1379,6 @@ export function BoardList({ } }; - const handleToggleSearch = () => { - setIsSearchOpen((prev) => { - const next = !prev; - if (!next) { - setSearchQuery(''); - } - return next; - }); - }; - - const handleSearchKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - setIsSearchOpen(false); - setSearchQuery(''); - } - }; - const handleStartEdit = (board: Board) => { setEditingId(board.id); setEditName(board.name); @@ -1014,16 +1573,18 @@ export function BoardList({ }); }; - const dragDisabled = Boolean(activeMenu || editingId || editingFolderId || isFiltering); + const dragDisabled = shouldDisableDrag( + Boolean(activeMenu), + Boolean(editingId), + Boolean(editingFolderId), + isFiltering, + ); // --------------------------------------------------------------------------- // Thumbnail Hover Handlers // --------------------------------------------------------------------------- const hoverTimerRef = useRef(null); - const [thumbnailPreview, setThumbnailPreview] = useState<{ - boardId: string; - anchorRect: DOMRect; - } | null>(null); + const [thumbnailPreview, setThumbnailPreview] = useState(null); const handleBoardMouseEnter = useCallback( (boardId: string, event: React.MouseEvent) => { @@ -1104,16 +1665,7 @@ export function BoardList({ const handleDragMove = (event: DragMoveEvent) => { const { active, over } = event; - - // Get pointer position from activator event + delta - const activatorEvent = event.activatorEvent as PointerEvent | MouseEvent | TouchEvent; - let initialY = 0; - if ('clientY' in activatorEvent) { - initialY = activatorEvent.clientY; - } else if ('touches' in activatorEvent && activatorEvent.touches.length > 0) { - initialY = activatorEvent.touches[0].clientY; - } - const pointerY = initialY + (event.delta?.y ?? 0); + const pointerY = getDragPointerY(event); if (!over) { setDragState((prev) => ({ @@ -1264,23 +1816,12 @@ export function BoardList({ // --------------------------------------------------------------------------- if (isCollapsed) { return ( -
- -
- {flattenedBoards.map(({ board }) => ( - - ))} -
-
+ ); } @@ -1302,15 +1843,7 @@ export function BoardList({

Boards

- +
- {!hideExportRow && ( -
- - - -
- )} +
- {settingsOpen - ? createPortal( -
-
event.stopPropagation()} - > -

Settings

-
-
Boards
-
- - -
-
-
-
Display
- -
- {!importDialogOpen && importError && ( -
{importError}
- )} -
-
Sidebar
- -
-
- Version - -
-
- -
-
-
, - document.body, - ) - : null} - - {importDialogOpen - ? createPortal( -
{ - if (!boardsImporting) closeImportDialog(); - }} - > -
event.stopPropagation()}> -

Import boards

- {importSourceName &&

Source: {importSourceName}

} -
- - -
-
- {importBoards.map((entry) => { - const isSelected = Boolean(importSelection[entry.key]); - const hasId = Boolean(entry.id); - const isDuplicate = - hasId && (existingBoardIds.has(entry.id) || duplicateImportIds.has(entry.id)); - return ( - - ); - })} -
-
{selectedImportBoards.length} selected
- {importError &&
{importError}
} -
- - -
-
-
, - document.body, - ) - : null} - - {/* Drag Overlay - shows a preview following the cursor */} - - {activeItem ? ( - 'items' in activeItem ? ( - - ) : ( - - ) - ) : null} - - - {/* Thumbnail hover preview portal */} - {thumbnailPreview && thumbnails[thumbnailPreview.boardId] - ? createPortal( -
- Board preview -
, - document.body, - ) - : null} - - {/* Context menu portal */} - {activeMenu && menuContent - ? createPortal( -
- {menuContent} -
, - document.body, - ) - : null} + + + + + + + + {menuContent} + ); } From 9a1edf99aa7c6a21b23a79ad441f7f91ba050f1a Mon Sep 17 00:00:00 2001 From: Moritz Reis Date: Sat, 19 Sep 2026 13:11:13 +0200 Subject: [PATCH 6/6] chore: improve code quality --- src/components/BoardList.tsx | 516 +--------------------------- src/components/BoardListDialogs.tsx | 232 +++++++++++++ src/components/BoardListSidebar.tsx | 209 +++++++++++ src/hooks/useBoardSearch.ts | 87 +++++ 4 files changed, 537 insertions(+), 507 deletions(-) create mode 100644 src/components/BoardListDialogs.tsx create mode 100644 src/components/BoardListSidebar.tsx create mode 100644 src/hooks/useBoardSearch.ts diff --git a/src/components/BoardList.tsx b/src/components/BoardList.tsx index 2fde14a..f061219 100644 --- a/src/components/BoardList.tsx +++ b/src/components/BoardList.tsx @@ -13,20 +13,13 @@ import { faChevronLeft, faChevronRight, faClone, - faCopy, - faDownload, faEllipsisVertical, - faFileCode, - faFileImage, faGear, faGripVertical, - faMagnifyingGlass, faPen, faPlus, faStar, faTrash, - faUpload, - faXmark, } from '@fortawesome/free-solid-svg-icons'; import { DndContext, @@ -62,6 +55,15 @@ import { parseDragId, type DropPosition, } from './boardListDnd'; +import { + BoardExportActions, + BoardListEmptyState, + BoardSearch, + BoardSearchToggle, + CollapsedBoardList, +} from './BoardListSidebar'; +import { ImportDialog, SettingsDialog } from './BoardListDialogs'; +import { useBoardSearch } from '../hooks/useBoardSearch'; import './BoardList.css'; // ============================================================================= @@ -196,31 +198,6 @@ const buildImportBoards = (payload: Partial): ImportBoardEntry }, []); }; -const filterBoardItems = (items: BoardListItem[], normalizedQuery: string): BoardListItem[] => { - if (!normalizedQuery) return items; - - return items.reduce((matches, item) => { - if (item.type === 'board') { - if (item.name.toLocaleLowerCase().includes(normalizedQuery)) matches.push(item); - return matches; - } - - if (item.name.toLocaleLowerCase().includes(normalizedQuery)) { - matches.push(item); - return matches; - } - - const matchingBoards = item.items.filter((board) => - board.name.toLocaleLowerCase().includes(normalizedQuery), - ); - if (matchingBoards.length > 0) matches.push({ ...item, items: matchingBoards }); - return matches; - }, []); -}; - -const countBoards = (items: BoardListItem[]) => - items.reduce((count, item) => count + (item.type === 'folder' ? item.items.length : 1), 0); - const shouldDisableDrag = ( menuOpen: boolean, editingBoard: boolean, @@ -242,65 +219,6 @@ const getDragPointerY = (event: DragMoveEvent) => { return initialY + (event.delta?.y ?? 0); }; -const useBoardSearch = (items: BoardListItem[], sidebarCollapsed: boolean) => { - const [searchQuery, setSearchQuery] = useState(''); - const [isSearchOpen, setIsSearchOpen] = useState(false); - const searchInputRef = useRef(null); - const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase(); - const isFiltering = normalizedSearchQuery.length > 0; - const filteredItems = useMemo( - () => filterBoardItems(items, normalizedSearchQuery), - [items, normalizedSearchQuery], - ); - const filteredBoardCount = useMemo(() => countBoards(filteredItems), [filteredItems]); - - const clearSearch = useCallback(() => setSearchQuery(''), []); - const closeSearch = useCallback(() => { - setIsSearchOpen(false); - clearSearch(); - }, [clearSearch]); - const toggleSearch = useCallback(() => { - setIsSearchOpen((open) => { - if (open) clearSearch(); - return !open; - }); - }, [clearSearch]); - const handleSearchKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== 'Escape') return; - event.preventDefault(); - closeSearch(); - }, - [closeSearch], - ); - - useEffect(() => { - if (isSearchOpen && !sidebarCollapsed) searchInputRef.current?.focus(); - }, [isSearchOpen, sidebarCollapsed]); - - useEffect(() => { - const openSearch = () => { - searchInputRef.current?.focus(); - setIsSearchOpen(true); - }; - window.addEventListener('boardlist:open-search', openSearch); - return () => window.removeEventListener('boardlist:open-search', openSearch); - }, []); - - return { - clearSearch, - filteredBoardCount, - filteredItems, - handleSearchKeyDown, - isFiltering, - isSearchOpen, - searchInputRef, - searchQuery, - setSearchQuery, - toggleSearch, - }; -}; - // ============================================================================= // Draggable/Droppable Item Components // ============================================================================= @@ -626,422 +544,6 @@ function FolderOverlay({ folder }: FolderOverlayProps) { ); } -interface BoardSearchToggleProps { - isOpen: boolean; - onToggle: () => void; -} - -function BoardSearchToggle({ isOpen, onToggle }: BoardSearchToggleProps) { - const label = isOpen ? 'Hide search' : 'Search boards'; - - return ( - - ); -} - -interface BoardSearchProps { - count: number; - inputRef: React.RefObject; - isFiltering: boolean; - isOpen: boolean; - onChange: (query: string) => void; - onClear: () => void; - onKeyDown: (event: React.KeyboardEvent) => void; - query: string; -} - -function BoardSearch({ - count, - inputRef, - isFiltering, - isOpen, - onChange, - onClear, - onKeyDown, - query, -}: BoardSearchProps) { - if (!isOpen) return null; - const status = isFiltering ? `${count} ${count === 1 ? 'match' : 'matches'}` : ''; - - return ( -
- - onChange(event.target.value)} - onKeyDown={onKeyDown} - placeholder="Filter boards..." - aria-label="Filter boards" - className="board-search-input" - /> - {isFiltering && ( - - )} - - {status} - -
- ); -} - -interface BoardListEmptyStateProps { - hasBoards: boolean; - hasMatches: boolean; - onClearFilter: () => void; -} - -function BoardListEmptyState({ hasBoards, hasMatches, onClearFilter }: BoardListEmptyStateProps) { - if (!hasBoards) { - return ( -
-

No boards yet

-

Create a new board to get started

-
- ); - } - - if (hasMatches) return null; - return ( -
-

No matching boards

-

Try another board or folder name

- -
- ); -} - -interface BoardExportActionsProps { - disabled: boolean; - hidden: boolean; - onCopyPng: () => void; - onExportPng: () => void; - onExportSvg: () => void; -} - -function BoardExportActions({ - disabled, - hidden, - onCopyPng, - onExportPng, - onExportSvg, -}: BoardExportActionsProps) { - if (hidden) return null; - - return ( -
- - - -
- ); -} - -interface CollapsedBoardListProps { - activeBoardId: string | null; - boards: FlattenedBoard[]; - onExpand: () => void; - onSelectBoard: (boardId: string) => void; -} - -function CollapsedBoardList({ - activeBoardId, - boards, - onExpand, - onSelectBoard, -}: CollapsedBoardListProps) { - return ( -
- -
- {boards.map(({ board }) => ( - - ))} -
-
- ); -} - -const formatAppVersion = (version: string | null) => { - if (!version) return 'Loading...'; - return version === 'Unknown' ? version : `v${version}`; -}; - -interface SettingsDialogProps { - appVersion: string | null; - boardsExporting: boolean; - boardsImporting: boolean; - hideExportRow: boolean; - importDialogOpen: boolean; - importError: string | null; - isOpen: boolean; - onClose: () => void; - onExportBoards: () => Promise; - onHideExportRowChange: (value: boolean) => void; - onOpenImport: () => void; - onOpenReleases: () => void; - onShowTimestampsChange: (value: boolean) => void; - showTimestamps: boolean; -} - -function SettingsDialog({ - appVersion, - boardsExporting, - boardsImporting, - hideExportRow, - importDialogOpen, - importError, - isOpen, - onClose, - onExportBoards, - onHideExportRowChange, - onOpenImport, - onOpenReleases, - onShowTimestampsChange, - showTimestamps, -}: SettingsDialogProps) { - if (!isOpen) return null; - - return createPortal( -
-
event.stopPropagation()} - > -

Settings

-
-
Boards
-
- - -
-
-
-
Display
- -
- {!importDialogOpen && importError &&
{importError}
} -
-
Sidebar
- -
-
- Version - -
-
- -
-
-
, - document.body, - ); -} - -interface ImportDialogProps { - boardsImporting: boolean; - duplicateImportIds: Set; - error: string | null; - existingBoardIds: Set; - importBoards: ImportBoardEntry[]; - importSelection: Record; - isOpen: boolean; - onClearAll: () => void; - onClose: () => void; - onConfirm: () => void; - onSelectAll: () => void; - onToggleSelection: (key: string) => void; - selectedCount: number; - sourceName: string | null; -} - -function ImportDialog({ - boardsImporting, - duplicateImportIds, - error, - existingBoardIds, - importBoards, - importSelection, - isOpen, - onClearAll, - onClose, - onConfirm, - onSelectAll, - onToggleSelection, - selectedCount, - sourceName, -}: ImportDialogProps) { - if (!isOpen) return null; - - const handleOverlayClick = () => { - if (!boardsImporting) onClose(); - }; - - return createPortal( -
-
event.stopPropagation()}> -

Import boards

- {sourceName &&

Source: {sourceName}

} -
- - -
-
- {importBoards.map((entry) => { - const isSelected = Boolean(importSelection[entry.key]); - const hasId = Boolean(entry.id); - const isDuplicate = - hasId && (existingBoardIds.has(entry.id) || duplicateImportIds.has(entry.id)); - return ( - - ); - })} -
-
{selectedCount} selected
- {error &&
{error}
} -
- - -
-
-
, - document.body, - ); -} - interface BoardDragOverlayProps { activeItem: Board | BoardFolder | null | undefined; formatDate: (date: string) => string; diff --git a/src/components/BoardListDialogs.tsx b/src/components/BoardListDialogs.tsx new file mode 100644 index 0000000..8174f56 --- /dev/null +++ b/src/components/BoardListDialogs.tsx @@ -0,0 +1,232 @@ +import { createPortal } from 'react-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faArrowUpRightFromSquare, faDownload, faUpload } from '@fortawesome/free-solid-svg-icons'; + +const formatAppVersion = (version: string | null) => { + if (!version) return 'Loading...'; + return version === 'Unknown' ? version : `v${version}`; +}; + +interface SettingsDialogProps { + appVersion: string | null; + boardsExporting: boolean; + boardsImporting: boolean; + hideExportRow: boolean; + importDialogOpen: boolean; + importError: string | null; + isOpen: boolean; + onClose: () => void; + onExportBoards: () => Promise; + onHideExportRowChange: (value: boolean) => void; + onOpenImport: () => void; + onOpenReleases: () => void; + onShowTimestampsChange: (value: boolean) => void; + showTimestamps: boolean; +} + +export function SettingsDialog({ + appVersion, + boardsExporting, + boardsImporting, + hideExportRow, + importDialogOpen, + importError, + isOpen, + onClose, + onExportBoards, + onHideExportRowChange, + onOpenImport, + onOpenReleases, + onShowTimestampsChange, + showTimestamps, +}: SettingsDialogProps) { + if (!isOpen) return null; + + return createPortal( +
+
event.stopPropagation()} + > +

Settings

+
+
Boards
+
+ + +
+
+
+
Display
+ +
+ {!importDialogOpen && importError &&
{importError}
} +
+
Sidebar
+ +
+
+ Version + +
+
+ +
+
+
, + document.body, + ); +} + +interface ImportBoardDialogEntry { + id?: string; + key: string; + name: string; +} + +interface ImportDialogProps { + boardsImporting: boolean; + duplicateImportIds: Set; + error: string | null; + existingBoardIds: Set; + importBoards: ImportBoardDialogEntry[]; + importSelection: Record; + isOpen: boolean; + onClearAll: () => void; + onClose: () => void; + onConfirm: () => void; + onSelectAll: () => void; + onToggleSelection: (key: string) => void; + selectedCount: number; + sourceName: string | null; +} + +export function ImportDialog({ + boardsImporting, + duplicateImportIds, + error, + existingBoardIds, + importBoards, + importSelection, + isOpen, + onClearAll, + onClose, + onConfirm, + onSelectAll, + onToggleSelection, + selectedCount, + sourceName, +}: ImportDialogProps) { + if (!isOpen) return null; + + const handleOverlayClick = () => { + if (!boardsImporting) onClose(); + }; + + return createPortal( +
+
event.stopPropagation()}> +

Import boards

+ {sourceName &&

Source: {sourceName}

} +
+ + +
+
+ {importBoards.map((entry) => { + const isSelected = Boolean(importSelection[entry.key]); + const entryId = entry.id; + const isDuplicate = Boolean( + entryId && (existingBoardIds.has(entryId) || duplicateImportIds.has(entryId)), + ); + return ( + + ); + })} +
+
{selectedCount} selected
+ {error &&
{error}
} +
+ + +
+
+
, + document.body, + ); +} diff --git a/src/components/BoardListSidebar.tsx b/src/components/BoardListSidebar.tsx new file mode 100644 index 0000000..392203c --- /dev/null +++ b/src/components/BoardListSidebar.tsx @@ -0,0 +1,209 @@ +import type { KeyboardEvent, RefObject } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faChevronRight, + faCopy, + faFileCode, + faFileImage, + faMagnifyingGlass, + faXmark, +} from '@fortawesome/free-solid-svg-icons'; +import type { Board } from '../types/board'; + +interface BoardSearchToggleProps { + isOpen: boolean; + onToggle: () => void; +} + +export function BoardSearchToggle({ isOpen, onToggle }: BoardSearchToggleProps) { + const label = isOpen ? 'Hide search' : 'Search boards'; + + return ( + + ); +} + +interface BoardSearchProps { + count: number; + inputRef: RefObject; + isFiltering: boolean; + isOpen: boolean; + onChange: (query: string) => void; + onClear: () => void; + onKeyDown: (event: KeyboardEvent) => void; + query: string; +} + +export function BoardSearch({ + count, + inputRef, + isFiltering, + isOpen, + onChange, + onClear, + onKeyDown, + query, +}: BoardSearchProps) { + if (!isOpen) return null; + const status = isFiltering ? `${count} ${count === 1 ? 'match' : 'matches'}` : ''; + + return ( +
+ + onChange(event.target.value)} + onKeyDown={onKeyDown} + placeholder="Filter boards..." + aria-label="Filter boards" + className="board-search-input" + /> + {isFiltering && ( + + )} + + {status} + +
+ ); +} + +interface BoardListEmptyStateProps { + hasBoards: boolean; + hasMatches: boolean; + onClearFilter: () => void; +} + +export function BoardListEmptyState({ + hasBoards, + hasMatches, + onClearFilter, +}: BoardListEmptyStateProps) { + if (!hasBoards) { + return ( +
+

No boards yet

+

Create a new board to get started

+
+ ); + } + + if (hasMatches) return null; + return ( +
+

No matching boards

+

Try another board or folder name

+ +
+ ); +} + +interface BoardExportActionsProps { + disabled: boolean; + hidden: boolean; + onCopyPng: () => void; + onExportPng: () => void; + onExportSvg: () => void; +} + +export function BoardExportActions({ + disabled, + hidden, + onCopyPng, + onExportPng, + onExportSvg, +}: BoardExportActionsProps) { + if (hidden) return null; + + return ( +
+ + + +
+ ); +} + +interface CollapsedBoardListProps { + activeBoardId: string | null; + boards: { board: Board }[]; + onExpand: () => void; + onSelectBoard: (boardId: string) => void; +} + +export function CollapsedBoardList({ + activeBoardId, + boards, + onExpand, + onSelectBoard, +}: CollapsedBoardListProps) { + return ( +
+ +
+ {boards.map(({ board }) => ( + + ))} +
+
+ ); +} diff --git a/src/hooks/useBoardSearch.ts b/src/hooks/useBoardSearch.ts new file mode 100644 index 0000000..dad17aa --- /dev/null +++ b/src/hooks/useBoardSearch.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { KeyboardEvent } from 'react'; +import type { BoardListItem } from '../types/board'; + +const filterBoardItems = (items: BoardListItem[], normalizedQuery: string): BoardListItem[] => { + if (!normalizedQuery) return items; + + return items.reduce((matches, item) => { + if (item.type === 'board') { + if (item.name.toLocaleLowerCase().includes(normalizedQuery)) matches.push(item); + return matches; + } + + if (item.name.toLocaleLowerCase().includes(normalizedQuery)) { + matches.push(item); + return matches; + } + + const matchingBoards = item.items.filter((board) => + board.name.toLocaleLowerCase().includes(normalizedQuery), + ); + if (matchingBoards.length > 0) matches.push({ ...item, items: matchingBoards }); + return matches; + }, []); +}; + +const countBoards = (items: BoardListItem[]) => + items.reduce((count, item) => count + (item.type === 'folder' ? item.items.length : 1), 0); + +export const useBoardSearch = (items: BoardListItem[], sidebarCollapsed: boolean) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const searchInputRef = useRef(null); + const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase(); + const isFiltering = normalizedSearchQuery.length > 0; + const filteredItems = useMemo( + () => filterBoardItems(items, normalizedSearchQuery), + [items, normalizedSearchQuery], + ); + const filteredBoardCount = useMemo(() => countBoards(filteredItems), [filteredItems]); + + const clearSearch = useCallback(() => setSearchQuery(''), []); + const closeSearch = useCallback(() => { + setIsSearchOpen(false); + clearSearch(); + }, [clearSearch]); + const toggleSearch = useCallback(() => { + setIsSearchOpen((open) => { + if (open) clearSearch(); + return !open; + }); + }, [clearSearch]); + const handleSearchKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + closeSearch(); + }, + [closeSearch], + ); + + useEffect(() => { + if (isSearchOpen && !sidebarCollapsed) searchInputRef.current?.focus(); + }, [isSearchOpen, sidebarCollapsed]); + + useEffect(() => { + const openSearch = () => { + searchInputRef.current?.focus(); + setIsSearchOpen(true); + }; + window.addEventListener('boardlist:open-search', openSearch); + return () => window.removeEventListener('boardlist:open-search', openSearch); + }, []); + + return { + clearSearch, + filteredBoardCount, + filteredItems, + handleSearchKeyDown, + isFiltering, + isSearchOpen, + searchInputRef, + searchQuery, + setSearchQuery, + toggleSearch, + }; +};