select button on cards in library view
Template tests / tests (push) Successful in 1m52s

This commit is contained in:
Iisyourdad
2026-06-11 10:19:56 -05:00
parent dca3e042f2
commit 85a34d6ab5
3 changed files with 181 additions and 20 deletions
+123 -5
View File
@@ -21,6 +21,8 @@ class StepForgeApp {
trash: [], trash: [],
settings: null, settings: null,
info: null, info: null,
selectMode: false,
selectedGuides: new Set(),
}; };
this.editorMeta = null; this.editorMeta = null;
this.libraryRenderToken = 0; this.libraryRenderToken = 0;
@@ -339,6 +341,13 @@ class StepForgeApp {
clearNode(this.libraryHost); clearNode(this.libraryHost);
const q = this.state.query.trim(); const q = this.state.query.trim();
const folderLabel = this.filterLabel(); 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', {}, const body = el('div.library', {},
el('aside.lib-side', {}, el('aside.lib-side', {},
el('h3', {}, 'Library'), 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('copy') }, 'Import archive'),
el('button', { type: 'button', onClick: () => this.importArchive('linked') }, 'Open linked'), 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.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.row', { style: { justifyContent: 'space-between', marginBottom: '14px' } },
el('div', {}, el('div', {},
@@ -366,6 +379,7 @@ class StepForgeApp {
), ),
el('div.muted', {}, this.state.info ? `StepForge ${this.state.info.version}` : ''), el('div.muted', {}, this.state.info ? `StepForge ${this.state.info.version}` : ''),
), ),
this.domBulkBar = el('div', {}),
this.domLibraryResults = el('div', {}), this.domLibraryResults = el('div', {}),
), ),
); );
@@ -378,6 +392,7 @@ class StepForgeApp {
} else { } else {
this.renderGuideGrid(); this.renderGuideGrid();
} }
this.renderBulkBar();
this.renderTopbar(); this.renderTopbar();
} }
@@ -507,14 +522,28 @@ class StepForgeApp {
const folderId = (this.state.library.guideFolders || {})[guide.guideId] || null; const folderId = (this.state.library.guideFolders || {})[guide.guideId] || null;
const folder = (this.state.library.folders || []).find((f) => f.id === folderId); const folder = (this.state.library.folders || []).find((f) => f.id === folderId);
const badgeText = guide.linkedSource ? 'Linked' : guide.favorite ? 'Favorite' : 'Local'; 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', { 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) => { onContextMenu: (e) => {
e.preventDefault(); e.preventDefault();
if (selectMode) return;
this.guideContextMenu(e, guide); this.guideContextMenu(e, guide);
}, },
}, },
el('div.fav', { 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' : ''}`, className: `fav${guide.favorite ? ' on' : ''}`,
onClick: async (e) => { onClick: async (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -529,6 +558,7 @@ class StepForgeApp {
folder ? el('span', {}, folder.name) : null, folder ? el('span', {}, folder.name) : null,
guide.locked ? el('span.badge', {}, 'Locked') : null, guide.locked ? el('span.badge', {}, 'Locked') : null,
), ),
description ? el('div.snippet', {}, description) : null,
el('div.muted', {}, fmtDate(guide.updatedAt))); el('div.muted', {}, fmtDate(guide.updatedAt)));
return card; return card;
} }
@@ -546,10 +576,14 @@ class StepForgeApp {
} }
guideContextMenu(event, guide) { guideContextMenu(event, guide) {
const folderItems = (this.state.library.folders || []).map((folder) => ({ 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}`, label: `Move to ${folder.name}`,
action: () => this.moveGuideToFolder(guide.guideId, folder.id), 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] : []; const moveItems = folderItems.length ? ['sep', ...folderItems] : [];
contextMenu(event.clientX, event.clientY, [ contextMenu(event.clientX, event.clientY, [
{ label: 'Open guide', action: () => this.openGuide(guide.guideId) }, { label: 'Open guide', action: () => this.openGuide(guide.guideId) },
@@ -557,12 +591,96 @@ class StepForgeApp {
{ label: 'Duplicate guide', action: () => this.duplicateGuide(guide.guideId) }, { label: 'Duplicate guide', action: () => this.duplicateGuide(guide.guideId) },
{ label: 'Export', action: () => this.openGuideExport(guide.guideId) }, { label: 'Export', action: () => this.openGuideExport(guide.guideId) },
...moveItems, ...moveItems,
{ label: 'Move to no folder', action: () => this.moveGuideToFolder(guide.guideId, null) },
'sep', 'sep',
{ label: 'Delete guide', danger: true, action: () => this.deleteGuide(guide.guideId) }, { 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() { async createGuide() {
const title = await dialogs.promptText({ const title = await dialogs.promptText({
title: 'New Guide', title: 'New Guide',
+42 -5
View File
@@ -214,8 +214,10 @@ kbd {
} }
.guide-card { .guide-card {
position: relative; position: relative;
padding: 14px; padding: 16px;
min-height: 116px; min-height: 168px;
display: flex;
flex-direction: column;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--panel); background: var(--panel);
@@ -223,10 +225,15 @@ kbd {
cursor: pointer; cursor: pointer;
} }
.guide-card:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } .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 { .guide-card h4 {
margin: 0 0 8px; margin: 0 0 8px;
font-size: 14px; font-size: 15px;
line-height: 1.25; line-height: 1.3;
padding-right: 22px; padding-right: 22px;
word-break: break-word; word-break: break-word;
} }
@@ -261,9 +268,39 @@ kbd {
opacity: 1; opacity: 1;
color: #f5a524; 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, .guide-card .snippet,
.qa-item .snippet { color: var(--muted); } .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 { .empty-state {
padding: 60px 20px; padding: 60px 20px;
+6
View File
@@ -46,6 +46,12 @@ Initial release.
PageDown step navigation, Ctrl+= / Ctrl+- / Ctrl+0 zoom, annotation PageDown step navigation, Ctrl+= / Ctrl+- / Ctrl+0 zoom, annotation
copy/paste (Ctrl+C/V), Ctrl+Delete deletes the step, Shift+arrows copy/paste (Ctrl+C/V), Ctrl+Delete deletes the step, Shift+arrows
fast-nudge — plus a shortcuts reference dialog. 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 ### Fixed