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..e1b34bc 100644 --- a/e2e/helpers/board-ui.mjs +++ b/e2e/helpers/board-ui.mjs @@ -1,6 +1,9 @@ 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"]', + toggleSearchButton: '[data-testid="toggle-search-btn"]', settingsOpenButton: '[data-testid="open-settings-btn"]', settingsModal: '[data-testid="settings-modal"]', settingsCloseButton: '[data-testid="close-settings-btn"]', @@ -11,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"]', }; @@ -68,6 +72,37 @@ 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); +} + +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 }); @@ -225,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 ee2fc59..af3792e 100644 --- a/e2e/specs/system.e2e.mjs +++ b/e2e/specs/system.e2e.mjs @@ -1,14 +1,18 @@ import { assertActiveBoard, assertEditorInstancePreserved, + assertBoardHidden, assertSidebarCollapsed, assertExportRowHidden, closeSettings, closeSettingsWithEscape, + clearBoardFilter, createBoard, createBoardFromCommandPalette, duplicateBoard, + filterBoards, openBoardFromCommandPalette, + openSearchFromCommandPalette, openSettings, openSettingsFromCommandPalette, rememberEditorInstance, @@ -58,6 +62,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`; @@ -106,5 +125,8 @@ describe('System suite', () => { await openBoardFromCommandPalette(secondBoardName); await openSettingsFromCommandPalette(); await closeSettings(); + await setSidebarCollapsed(true); + await openSearchFromCommandPalette(); + await setSidebarCollapsed(false); }); }); 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: diff --git a/src/App.tsx b/src/App.tsx index 469bca4..c988dce 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']; @@ -254,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, @@ -264,6 +273,7 @@ const createCommandPaletteCommands = ({ renameBoard, deleteBoard, duplicateBoard, + requestOpenBoardSearch, requestOpenSettings, requestOpenImportBoards, sidebarCollapsed, @@ -305,6 +315,7 @@ const createCommandPaletteCommands = ({ action: createCreateBoardAction(createBoard), }, ...createBoardCommandGroupCommands(hasBoards, boardSearchEmptyState, boardCommands), + createSearchBoardsCommand(requestOpenBoardSearch), { id: 'export-boards', label: 'Export boards', @@ -432,6 +443,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 +466,7 @@ const useCommandPaletteController = ({ renameBoard, deleteBoard, duplicateBoard, + requestOpenBoardSearch, requestOpenSettings, requestOpenImportBoards, sidebarCollapsed, @@ -477,6 +496,7 @@ const useCommandPaletteController = ({ handleExportSvg, handleSelectBoard, renameBoard, + requestOpenBoardSearch, requestOpenImportBoards, requestOpenSettings, sidebarCollapsed, diff --git a/src/components/BoardList.css b/src/components/BoardList.css index 8923a23..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); @@ -210,6 +220,97 @@ cursor: not-allowed; } +.board-search { + position: relative; + 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 { + 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 +410,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 +592,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..f061219 100644 --- a/src/components/BoardList.tsx +++ b/src/components/BoardList.tsx @@ -13,18 +13,13 @@ import { faChevronLeft, faChevronRight, faClone, - faCopy, - faDownload, faEllipsisVertical, - faFileCode, - faFileImage, faGear, faGripVertical, faPen, faPlus, faStar, faTrash, - faUpload, } from '@fortawesome/free-solid-svg-icons'; import { DndContext, @@ -60,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'; // ============================================================================= @@ -100,11 +104,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 // ============================================================================= @@ -187,6 +198,27 @@ const buildImportBoards = (payload: Partial): ImportBoardEntry }, []); }; +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); +}; + // ============================================================================= // Draggable/Droppable Item Components // ============================================================================= @@ -319,6 +351,7 @@ function DraggableBoardItem({ interface DraggableFolderItemProps { folder: BoardFolder; isCollapsed: boolean; + collapseDisabled?: boolean; isEditing: boolean; editName: string; onEditNameChange: (name: string) => void; @@ -332,9 +365,43 @@ 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, + collapseDisabled, isEditing, editName, onEditNameChange, @@ -406,15 +473,11 @@ function DraggableFolderItem({ ) : (
- + @@ -481,6 +544,83 @@ function FolderOverlay({ folder }: FolderOverlayProps) { ); } +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 // ============================================================================= @@ -514,6 +654,18 @@ export function BoardList({ // State // --------------------------------------------------------------------------- const [newBoardName, setNewBoardName] = useState(''); + 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); @@ -556,8 +708,6 @@ export function BoardList({ // --------------------------------------------------------------------------- // Memoized Data // --------------------------------------------------------------------------- - type FlattenedBoard = { board: Board; folderId?: string }; - const flattenedBoards = useMemo( () => items.flatMap((item) => @@ -925,16 +1075,18 @@ export function BoardList({ }); }; - const dragDisabled = Boolean(activeMenu || editingId || editingFolderId); + 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) => { @@ -1015,16 +1167,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) => ({ @@ -1175,23 +1318,12 @@ export function BoardList({ // --------------------------------------------------------------------------- if (isCollapsed) { return ( -
- -
- {flattenedBoards.map(({ board }) => ( - - ))} -
-
+ ); } @@ -1213,6 +1345,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} + ); } 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, + }; +};