From 85a34d6ab510162d179ec9197a34bfaad1632930 Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Thu, 11 Jun 2026 10:19:56 -0500 Subject: [PATCH] select button on cards in library view --- app/renderer/app.js | 148 ++++++++++++++++++++++++++++++++++++----- app/renderer/style.css | 47 +++++++++++-- docs/CHANGELOG.md | 6 ++ 3 files changed, 181 insertions(+), 20 deletions(-) diff --git a/app/renderer/app.js b/app/renderer/app.js index 44b95df..94eaba1 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -21,6 +21,8 @@ class StepForgeApp { trash: [], settings: null, info: null, + selectMode: false, + selectedGuides: new Set(), }; this.editorMeta = null; this.libraryRenderToken = 0; @@ -339,6 +341,13 @@ class StepForgeApp { clearNode(this.libraryHost); const q = this.state.query.trim(); const folderLabel = this.filterLabel(); + // Selecting guides only makes sense for the plain guide grid — drop out + // of select mode for search results and the trash. + const canSelect = !q && this.state.folderFilter !== 'trash'; + if (!canSelect && this.state.selectMode) { + this.state.selectMode = false; + this.state.selectedGuides = new Set(); + } const body = el('div.library', {}, el('aside.lib-side', {}, el('h3', {}, 'Library'), @@ -357,7 +366,11 @@ class StepForgeApp { el('button', { type: 'button', onClick: () => this.importArchive('copy') }, 'Import archive'), el('button', { type: 'button', onClick: () => this.importArchive('linked') }, 'Open linked'), el('button', { type: 'button', onClick: () => this.openQuickActions() }, 'Quick actions'), - el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings'), + canSelect ? el('button', { + type: 'button', + className: this.state.selectMode ? 'primary' : '', + onClick: () => this.toggleSelectMode(), + }, this.state.selectMode ? 'Done selecting' : 'Select') : null, ), el('div.row', { style: { justifyContent: 'space-between', marginBottom: '14px' } }, el('div', {}, @@ -366,6 +379,7 @@ class StepForgeApp { ), el('div.muted', {}, this.state.info ? `StepForge ${this.state.info.version}` : ''), ), + this.domBulkBar = el('div', {}), this.domLibraryResults = el('div', {}), ), ); @@ -378,6 +392,7 @@ class StepForgeApp { } else { this.renderGuideGrid(); } + this.renderBulkBar(); this.renderTopbar(); } @@ -507,21 +522,35 @@ class StepForgeApp { const folderId = (this.state.library.guideFolders || {})[guide.guideId] || null; const folder = (this.state.library.folders || []).find((f) => f.id === folderId); const badgeText = guide.linkedSource ? 'Linked' : guide.favorite ? 'Favorite' : 'Local'; + const selectMode = this.state.selectMode; + const selected = this.state.selectedGuides.has(guide.guideId); + const description = (guide.descriptionHtml || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); const card = el('div.guide-card', { - onClick: () => this.openGuide(guide.guideId), + className: `guide-card${selectMode ? ' selectable' : ''}${selected ? ' selected' : ''}`, + onClick: () => { + if (selectMode) this.toggleGuideSelection(guide.guideId); + else this.openGuide(guide.guideId); + }, onContextMenu: (e) => { e.preventDefault(); + if (selectMode) return; this.guideContextMenu(e, guide); }, }, - el('div.fav', { - className: `fav${guide.favorite ? ' on' : ''}`, - onClick: async (e) => { - e.stopPropagation(); - await api.library.setFavorite({ guideId: guide.guideId, favorite: !guide.favorite }); - await this.refreshLibrary(); - }, - }, '★'), + selectMode + ? el('input.select-check', { + type: 'checkbox', + checked: selected, + onClick: (e) => { e.stopPropagation(); this.toggleGuideSelection(guide.guideId); }, + }) + : el('div.fav', { + className: `fav${guide.favorite ? ' on' : ''}`, + onClick: async (e) => { + e.stopPropagation(); + await api.library.setFavorite({ guideId: guide.guideId, favorite: !guide.favorite }); + await this.refreshLibrary(); + }, + }, '★'), el('h4', {}, guide.title || 'Untitled guide'), el('div.meta', {}, el('span.badge', {}, badgeText), @@ -529,6 +558,7 @@ class StepForgeApp { folder ? el('span', {}, folder.name) : null, guide.locked ? el('span.badge', {}, 'Locked') : null, ), + description ? el('div.snippet', {}, description) : null, el('div.muted', {}, fmtDate(guide.updatedAt))); return card; } @@ -546,10 +576,14 @@ class StepForgeApp { } guideContextMenu(event, guide) { - const folderItems = (this.state.library.folders || []).map((folder) => ({ - label: `Move to ${folder.name}`, - action: () => this.moveGuideToFolder(guide.guideId, folder.id), - })); + const currentFolderId = (this.state.library.guideFolders || {})[guide.guideId] || null; + const folderItems = (this.state.library.folders || []) + .filter((folder) => folder.id !== currentFolderId) + .map((folder) => ({ + label: `Move to ${folder.name}`, + action: () => this.moveGuideToFolder(guide.guideId, folder.id), + })); + if (currentFolderId) folderItems.push({ label: 'Move to no folder', action: () => this.moveGuideToFolder(guide.guideId, null) }); const moveItems = folderItems.length ? ['sep', ...folderItems] : []; contextMenu(event.clientX, event.clientY, [ { label: 'Open guide', action: () => this.openGuide(guide.guideId) }, @@ -557,12 +591,96 @@ class StepForgeApp { { label: 'Duplicate guide', action: () => this.duplicateGuide(guide.guideId) }, { label: 'Export', action: () => this.openGuideExport(guide.guideId) }, ...moveItems, - { label: 'Move to no folder', action: () => this.moveGuideToFolder(guide.guideId, null) }, 'sep', { label: 'Delete guide', danger: true, action: () => this.deleteGuide(guide.guideId) }, ]); } + toggleSelectMode() { + this.state.selectMode = !this.state.selectMode; + this.state.selectedGuides = new Set(); + this.renderLibrary(); + } + + toggleGuideSelection(guideId) { + if (this.state.selectedGuides.has(guideId)) this.state.selectedGuides.delete(guideId); + else this.state.selectedGuides.add(guideId); + this.renderGuideGrid(); + this.renderBulkBar(); + } + + selectAllGuides() { + const guides = this.state.library.guides.filter((guide) => this.scopeGuide(guide)); + this.state.selectedGuides = new Set(guides.map((g) => g.guideId)); + this.renderGuideGrid(); + this.renderBulkBar(); + } + + clearSelection() { + this.state.selectedGuides = new Set(); + this.renderGuideGrid(); + this.renderBulkBar(); + } + + renderBulkBar() { + if (!this.domBulkBar) return; + clearNode(this.domBulkBar); + if (!this.state.selectMode) return; + const guides = this.state.library.guides.filter((guide) => this.scopeGuide(guide)); + const n = this.state.selectedGuides.size; + const allSelected = guides.length > 0 && n === guides.length; + this.domBulkBar.append( + el('div.bulk-bar', {}, + el('span', {}, n ? `${n} selected` : 'Select guides to act on them'), + el('span.spacer', {}), + el('button', { + type: 'button', + onClick: () => (allSelected ? this.clearSelection() : this.selectAllGuides()), + }, allSelected ? 'Clear selection' : 'Select all'), + el('button', { type: 'button', disabled: !n, onClick: () => this.bulkSetFavorite(true) }, 'Favorite'), + el('button', { type: 'button', disabled: !n, onClick: () => this.bulkSetFavorite(false) }, 'Unfavorite'), + el('button', { type: 'button', disabled: !n, onClick: (e) => this.openBulkMoveMenu(e) }, 'Move to folder ▾'), + el('button.danger', { type: 'button', disabled: !n, onClick: () => this.bulkDelete() }, 'Delete'), + ), + ); + } + + openBulkMoveMenu(event) { + const rect = event.target.getBoundingClientRect(); + const folderItems = (this.state.library.folders || []).map((folder) => ({ + label: folder.name, + action: () => this.bulkMoveToFolder(folder.id), + })); + contextMenu(rect.left, rect.bottom + 4, [ + { label: 'No folder', action: () => this.bulkMoveToFolder(null) }, + ...(folderItems.length ? ['sep', ...folderItems] : []), + ]); + } + + async bulkSetFavorite(favorite) { + const ids = [...this.state.selectedGuides]; + if (!ids.length) return; + await Promise.all(ids.map((guideId) => api.library.setFavorite({ guideId, favorite }))); + await this.refreshLibrary(); + } + + async bulkMoveToFolder(folderId) { + const ids = [...this.state.selectedGuides]; + if (!ids.length) return; + await Promise.all(ids.map((guideId) => api.folders.moveGuide({ guideId, folderId }))); + await this.refreshLibrary(); + } + + async bulkDelete() { + const ids = [...this.state.selectedGuides]; + if (!ids.length) return; + const ok = await confirmDialog(`Delete ${ids.length} guide${ids.length === 1 ? '' : 's'}? They'll move to Trash.`, { danger: true, okLabel: 'Delete' }); + if (!ok) return; + await Promise.all(ids.map((guideId) => api.library.delete({ guideId }))); + this.state.selectedGuides = new Set(); + await this.refreshLibrary(); + } + async createGuide() { const title = await dialogs.promptText({ title: 'New Guide', diff --git a/app/renderer/style.css b/app/renderer/style.css index 961ea64..c4f1e8f 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -214,8 +214,10 @@ kbd { } .guide-card { position: relative; - padding: 14px; - min-height: 116px; + padding: 16px; + min-height: 168px; + display: flex; + flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); @@ -223,10 +225,15 @@ kbd { cursor: pointer; } .guide-card:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } +.guide-card.selectable { padding-left: 40px; } +.guide-card.selected { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--panel)); +} .guide-card h4 { margin: 0 0 8px; - font-size: 14px; - line-height: 1.25; + font-size: 15px; + line-height: 1.3; padding-right: 22px; word-break: break-word; } @@ -261,9 +268,39 @@ kbd { opacity: 1; color: #f5a524; } -.guide-card .muted { font-size: 12px; } +.guide-card .select-check { + position: absolute; + top: 14px; + left: 14px; + width: 16px; + height: 16px; + cursor: pointer; +} +.guide-card .muted { font-size: 12px; margin-top: auto; padding-top: 8px; } .guide-card .snippet, .qa-item .snippet { color: var(--muted); } +.guide-card .snippet { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.bulk-bar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px 12px; + margin-bottom: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel-2); +} +.bulk-bar .spacer { flex: 1; } .empty-state { padding: 60px 20px; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e398fc9..1777e27 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -46,6 +46,12 @@ Initial release. PageDown step navigation, Ctrl+= / Ctrl+- / Ctrl+0 zoom, annotation copy/paste (Ctrl+C/V), Ctrl+Delete deletes the step, Shift+arrows fast-nudge — plus a shortcuts reference dialog. +- Library guide cards now show a description preview and are larger; a + "Select" toggle enables multi-select with a bulk action bar (select + all, favorite/unfavorite, move to folder, delete). +- Right-click "Move to folder" on a guide no longer lists its current + folder, and "Move to no folder" only appears when the guide is + currently in a folder. ### Fixed