This commit is contained in:
+133
-15
@@ -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,21 +522,35 @@ 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
|
||||||
className: `fav${guide.favorite ? ' on' : ''}`,
|
? el('input.select-check', {
|
||||||
onClick: async (e) => {
|
type: 'checkbox',
|
||||||
e.stopPropagation();
|
checked: selected,
|
||||||
await api.library.setFavorite({ guideId: guide.guideId, favorite: !guide.favorite });
|
onClick: (e) => { e.stopPropagation(); this.toggleGuideSelection(guide.guideId); },
|
||||||
await this.refreshLibrary();
|
})
|
||||||
},
|
: 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('h4', {}, guide.title || 'Untitled guide'),
|
||||||
el('div.meta', {},
|
el('div.meta', {},
|
||||||
el('span.badge', {}, badgeText),
|
el('span.badge', {}, badgeText),
|
||||||
@@ -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;
|
||||||
label: `Move to ${folder.name}`,
|
const folderItems = (this.state.library.folders || [])
|
||||||
action: () => this.moveGuideToFolder(guide.guideId, folder.id),
|
.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] : [];
|
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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user