experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tags): add rename and delete tag operations

Backend datastore + IPC handlers for renameTag/deleteTag with pubsub
notifications. Tags home UI updated with management controls.

+447 -13
+17
backend/electron/datastore.ts
··· 2245 2245 return { tag: newTag, created: true }; 2246 2246 } 2247 2247 2248 + export function renameTag(id: string, newName: string): Tag | null { 2249 + const slug = newName.toLowerCase().trim().replace(/\s+/g, '-'); 2250 + const timestamp = now(); 2251 + const result = getDb().prepare( 2252 + 'UPDATE tags SET name = ?, slug = ?, updatedAt = ? WHERE id = ?' 2253 + ).run(newName.trim(), slug, timestamp, id); 2254 + if (result.changes === 0) return null; 2255 + return getDb().prepare('SELECT * FROM tags WHERE id = ?').get(id) as Tag; 2256 + } 2257 + 2258 + export function deleteTag(id: string): boolean { 2259 + const db = getDb(); 2260 + db.prepare('DELETE FROM item_tags WHERE tagId = ?').run(id); 2261 + const result = db.prepare('DELETE FROM tags WHERE id = ?').run(id); 2262 + return result.changes > 0; 2263 + } 2264 + 2248 2265 export function getTagsByFrecency(domain?: string): Tag[] { 2249 2266 let tags = getDb().prepare('SELECT * FROM tags').all() as Tag[]; 2250 2267
+35
backend/electron/ipc.ts
··· 14 14 addContent, 15 15 queryContent, 16 16 getOrCreateTag, 17 + renameTag, 18 + deleteTag, 17 19 getTagsByFrecency, 18 20 getTable, 19 21 setRow, ··· 576 578 ipcMain.handle('datastore-get-tags-by-frecency', async (ev, data = {}) => { 577 579 try { 578 580 const result = getTagsByFrecency(data.limit); 581 + return { success: true, data: result }; 582 + } catch (error) { 583 + const message = error instanceof Error ? error.message : String(error); 584 + return { success: false, error: message }; 585 + } 586 + }); 587 + 588 + ipcMain.handle('datastore-rename-tag', async (ev, data) => { 589 + try { 590 + const result = renameTag(data.tagId, data.newName); 591 + if (result) { 592 + publish('system', PubSubScopes.GLOBAL, 'tag:renamed', { 593 + tagId: data.tagId, 594 + newName: data.newName 595 + }); 596 + if (DEBUG) console.log('[ipc] tag:renamed', data.tagId, data.newName); 597 + } 598 + return { success: true, data: result }; 599 + } catch (error) { 600 + const message = error instanceof Error ? error.message : String(error); 601 + return { success: false, error: message }; 602 + } 603 + }); 604 + 605 + ipcMain.handle('datastore-delete-tag', async (ev, data) => { 606 + try { 607 + const result = deleteTag(data.tagId); 608 + if (result) { 609 + publish('system', PubSubScopes.GLOBAL, 'tag:deleted', { 610 + tagId: data.tagId 611 + }); 612 + if (DEBUG) console.log('[ipc] tag:deleted', data.tagId); 613 + } 579 614 return { success: true, data: result }; 580 615 } catch (error) { 581 616 const message = error instanceof Error ? error.message : String(error);
+175 -7
extensions/tags/home.css
··· 256 256 flex-direction: column; 257 257 } 258 258 259 + /* Selected tags bar - shows active tag filters above the grid */ 260 + .selected-tags-bar { 261 + display: none; 262 + align-items: center; 263 + gap: 6px; 264 + flex-wrap: wrap; 265 + margin-bottom: 8px; 266 + } 267 + 268 + .selected-tags-bar.visible { 269 + display: flex; 270 + } 271 + 272 + .selected-tag-chip { 273 + display: flex; 274 + align-items: center; 275 + gap: 4px; 276 + padding: 4px 10px; 277 + background: var(--base0D); 278 + border-radius: 12px; 279 + font-size: 12px; 280 + color: var(--base00); 281 + font-weight: 500; 282 + } 283 + 284 + .selected-tag-chip .remove-selected-tag { 285 + background: none; 286 + border: none; 287 + color: var(--base00); 288 + cursor: pointer; 289 + font-size: 14px; 290 + line-height: 1; 291 + padding: 0; 292 + opacity: 0.8; 293 + } 294 + 295 + .selected-tag-chip .remove-selected-tag:hover { 296 + opacity: 1; 297 + } 298 + 299 + .tag-gear-btn { 300 + display: flex; 301 + align-items: center; 302 + justify-content: center; 303 + width: 24px; 304 + height: 24px; 305 + background: transparent; 306 + border: 1px solid var(--base03); 307 + border-radius: 6px; 308 + cursor: pointer; 309 + color: var(--base04); 310 + padding: 0; 311 + transition: all 0.15s; 312 + } 313 + 314 + .tag-gear-btn:hover { 315 + background: var(--base02); 316 + color: var(--base05); 317 + } 318 + 319 + .tag-gear-btn.active { 320 + background: var(--base02); 321 + color: var(--base0D); 322 + border-color: var(--base0D); 323 + } 324 + 325 + /* Tag management panel */ 326 + .tag-manage-panel { 327 + display: none; 328 + align-items: center; 329 + gap: 8px; 330 + padding: 10px 12px; 331 + background: var(--base01); 332 + border: 1px solid var(--base02); 333 + border-radius: 6px; 334 + margin-bottom: 8px; 335 + } 336 + 337 + .tag-manage-panel.visible { 338 + display: flex; 339 + } 340 + 341 + .tag-manage-panel input { 342 + flex: 1; 343 + padding: 6px 10px; 344 + font-size: 13px; 345 + font-family: var(--theme-font-sans); 346 + background: var(--base00); 347 + border: 1px solid var(--base02); 348 + border-radius: 4px; 349 + color: var(--base05); 350 + outline: none; 351 + min-width: 0; 352 + } 353 + 354 + .tag-manage-panel input:focus { 355 + border-color: var(--base0D); 356 + } 357 + 358 + .tag-manage-panel button { 359 + padding: 6px 12px; 360 + font-size: 12px; 361 + font-weight: 500; 362 + border: none; 363 + border-radius: 4px; 364 + cursor: pointer; 365 + white-space: nowrap; 366 + transition: all 0.15s; 367 + } 368 + 369 + .tag-manage-save { 370 + background: var(--base0D); 371 + color: var(--base00); 372 + } 373 + 374 + .tag-manage-save:hover { 375 + filter: brightness(1.1); 376 + } 377 + 378 + .tag-manage-delete { 379 + background: var(--base08); 380 + color: var(--base00); 381 + } 382 + 383 + .tag-manage-delete:hover { 384 + filter: brightness(1.1); 385 + } 386 + 387 + /* Sidebar header with sort controls */ 388 + .sidebar-header { 389 + display: flex; 390 + align-items: center; 391 + gap: 6px; 392 + margin-bottom: 12px; 393 + } 394 + 395 + .sidebar-header .sidebar-title { 396 + flex: 1; 397 + margin-bottom: 0; 398 + } 399 + 400 + .sidebar-sort-btn { 401 + display: flex; 402 + align-items: center; 403 + justify-content: center; 404 + width: 24px; 405 + height: 24px; 406 + background: transparent; 407 + border: 1px solid transparent; 408 + border-radius: 4px; 409 + cursor: pointer; 410 + color: var(--base04); 411 + font-size: 11px; 412 + padding: 0; 413 + transition: all 0.15s; 414 + } 415 + 416 + .sidebar-sort-btn:hover { 417 + background: var(--base02); 418 + color: var(--base05); 419 + } 420 + 421 + .sidebar-sort-btn.active { 422 + background: var(--base01); 423 + border-color: var(--base02); 424 + color: var(--base0D); 425 + } 426 + 259 427 /* Grid toolbar */ 260 428 peek-grid-toolbar.grid-toolbar { 261 429 margin-bottom: 8px; ··· 269 437 270 438 /* Cards grid - peek-grid custom properties */ 271 439 peek-grid.cards { 272 - --peek-grid-min-item-width: 280px; 273 - --peek-grid-gap: 12px; 440 + --peek-grid-min-item-width: 240px; 441 + --peek-grid-gap: 8px; 274 442 } 275 443 276 444 /* Card customization - peek-card custom properties */ ··· 278 446 --peek-card-bg: var(--base01); 279 447 --peek-card-border: transparent; 280 448 --peek-card-radius: 8px; 281 - --peek-card-padding: 14px; 282 - --peek-card-gap: 10px; 449 + --peek-card-padding: 10px; 450 + --peek-card-gap: 6px; 283 451 } 284 452 285 453 peek-card[selected] { ··· 290 458 .card-header { 291 459 display: flex; 292 460 align-items: flex-start; 293 - gap: 10px; 461 + gap: 8px; 294 462 } 295 463 296 464 .card-favicon { 297 - width: 24px; 298 - height: 24px; 465 + width: 20px; 466 + height: 20px; 299 467 border-radius: 4px; 300 468 flex-shrink: 0; 301 469 background: var(--base02);
+10 -2
extensions/tags/home.html
··· 114 114 115 115 <div class="content-wrapper"> 116 116 <aside class="tag-sidebar"> 117 - <h2 class="sidebar-title">Tags</h2> 117 + <div class="sidebar-header"> 118 + <h2 class="sidebar-title">Tags</h2> 119 + <button class="sidebar-sort-btn" data-sort="name" title="Sort alphabetically">A↓</button> 120 + <button class="sidebar-sort-btn" data-sort="direction" title="Reverse sort order">↕</button> 121 + </div> 118 122 <div class="tag-list"></div> 119 123 </aside> 120 124 121 125 <main class="items-container"> 126 + <!-- Selected tags bar --> 127 + <div class="selected-tags-bar"></div> 128 + <!-- Tag management panel --> 129 + <div class="tag-manage-panel"></div> 122 130 <!-- Grid toolbar --> 123 131 <peek-grid-toolbar class="grid-toolbar" view-mode="columns"></peek-grid-toolbar> 124 132 125 133 <!-- Card grid view (list) --> 126 - <peek-grid class="cards" min-item-width="280" gap="12"></peek-grid> 134 + <peek-grid class="cards" min-item-width="240" gap="8"></peek-grid> 127 135 128 136 <!-- Inline detail view (replaces card grid when an item is selected) --> 129 137 <div class="detail-view" style="display: none;">
+204 -4
extensions/tags/home.js
··· 88 88 itemTags: new Map(), // Map of addressId -> [tags] 89 89 selectedIndex: 0, 90 90 searchQuery: '', 91 - editingItem: null // Item being viewed in detail 91 + editingItem: null, // Item being viewed in detail 92 + tagSortBy: 'frecency', // 'frecency' | 'name' 93 + tagSortDirection: 'asc', // 'asc' | 'desc' 94 + managePanelOpen: false // Whether the tag manage panel is visible 92 95 }; 93 96 94 97 // Expose state for debugging ··· 449 452 debouncedRefresh(); 450 453 }, api.scopes.GLOBAL); 451 454 455 + api.subscribe('tag:renamed', (msg) => { 456 + debug && console.log('[tags] tag:renamed event received:', msg); 457 + debouncedRefresh(); 458 + }, api.scopes.GLOBAL); 459 + 460 + api.subscribe('tag:deleted', (msg) => { 461 + debug && console.log('[tags] tag:deleted event received:', msg); 462 + debouncedRefresh(); 463 + }, api.scopes.GLOBAL); 464 + 452 465 // Subscribe to item events for reactive updates 453 466 api.subscribe('item:created', (msg) => { 454 467 debug && console.log('[tags] item:created event received:', msg); ··· 496 509 }); 497 510 }); 498 511 512 + // Sidebar sort buttons 513 + document.querySelectorAll('.sidebar-sort-btn').forEach(btn => { 514 + btn.addEventListener('click', () => { 515 + const action = btn.dataset.sort; 516 + if (action === 'name') { 517 + // Toggle between frecency and name sort 518 + state.tagSortBy = state.tagSortBy === 'name' ? 'frecency' : 'name'; 519 + } else if (action === 'direction') { 520 + // Toggle sort direction 521 + state.tagSortDirection = state.tagSortDirection === 'asc' ? 'desc' : 'asc'; 522 + } 523 + renderTagSidebar(); 524 + }); 525 + }); 526 + 499 527 // New tag input in detail view 500 528 const newTagInput = document.querySelector('.new-tag-input'); 501 529 const addTagBtn = document.querySelector('.add-tag-btn'); ··· 712 740 * AND match the search query against content/title/URL/tags. 713 741 */ 714 742 const getFilteredItems = () => { 715 - let items = [...state.items]; 743 + // Only show items that have at least one tag 744 + let items = state.items.filter(item => { 745 + const tags = state.itemTags.get(item.id); 746 + return tags && tags.length > 0; 747 + }); 716 748 717 749 // Filter by type 718 750 if (state.activeFilter !== 'all') { ··· 779 811 const render = () => { 780 812 renderFilterButtons(); 781 813 renderTagSidebar(); 814 + renderSelectedTagsBar(); 782 815 renderCards(); 783 816 renderActiveTagIndicator(); 784 817 updateSearchPlaceholder(); ··· 856 889 }; 857 890 858 891 /** 892 + * Render selected tags bar at top of the right pane. 893 + * Shows active tag filter chips with remove buttons, and a gear icon when a single tag is selected. 894 + */ 895 + const renderSelectedTagsBar = () => { 896 + const bar = document.querySelector('.selected-tags-bar'); 897 + const panel = document.querySelector('.tag-manage-panel'); 898 + 899 + if (state.activeTags.length === 0) { 900 + bar.classList.remove('visible'); 901 + bar.innerHTML = ''; 902 + panel.classList.remove('visible'); 903 + panel.innerHTML = ''; 904 + state.managePanelOpen = false; 905 + return; 906 + } 907 + 908 + bar.innerHTML = ''; 909 + bar.classList.add('visible'); 910 + 911 + state.activeTags.forEach(tag => { 912 + const chip = document.createElement('span'); 913 + chip.className = 'selected-tag-chip'; 914 + chip.innerHTML = ` 915 + ${escapeHtml(tag.name)} 916 + <button class="remove-selected-tag">&times;</button> 917 + `; 918 + chip.querySelector('.remove-selected-tag').addEventListener('click', () => { 919 + removeActiveTag(tag.id); 920 + }); 921 + bar.appendChild(chip); 922 + }); 923 + 924 + // Show gear icon when exactly one tag is selected 925 + if (state.activeTags.length === 1) { 926 + const gearBtn = document.createElement('button'); 927 + gearBtn.className = 'tag-gear-btn' + (state.managePanelOpen ? ' active' : ''); 928 + gearBtn.title = 'Manage tag'; 929 + gearBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`; 930 + gearBtn.addEventListener('click', () => { 931 + state.managePanelOpen = !state.managePanelOpen; 932 + gearBtn.classList.toggle('active', state.managePanelOpen); 933 + renderTagManagePanel(); 934 + }); 935 + bar.appendChild(gearBtn); 936 + } else { 937 + panel.classList.remove('visible'); 938 + panel.innerHTML = ''; 939 + state.managePanelOpen = false; 940 + } 941 + 942 + renderTagManagePanel(); 943 + }; 944 + 945 + /** 946 + * Render the tag management panel (rename / delete) for a single selected tag. 947 + */ 948 + const renderTagManagePanel = () => { 949 + const panel = document.querySelector('.tag-manage-panel'); 950 + 951 + if (!state.managePanelOpen || state.activeTags.length !== 1) { 952 + panel.classList.remove('visible'); 953 + panel.innerHTML = ''; 954 + return; 955 + } 956 + 957 + const tag = state.activeTags[0]; 958 + panel.innerHTML = ''; 959 + panel.classList.add('visible'); 960 + 961 + const input = document.createElement('input'); 962 + input.type = 'text'; 963 + input.value = tag.name; 964 + input.placeholder = 'Tag name'; 965 + 966 + const saveBtn = document.createElement('button'); 967 + saveBtn.className = 'tag-manage-save'; 968 + saveBtn.textContent = 'Rename'; 969 + saveBtn.addEventListener('click', () => renameTagAction(tag.id, input.value)); 970 + 971 + input.addEventListener('keydown', (e) => { 972 + if (e.key === 'Enter') renameTagAction(tag.id, input.value); 973 + }); 974 + 975 + const deleteBtn = document.createElement('button'); 976 + deleteBtn.className = 'tag-manage-delete'; 977 + deleteBtn.textContent = 'Delete'; 978 + deleteBtn.addEventListener('click', () => deleteTagAction(tag.id)); 979 + 980 + panel.appendChild(input); 981 + panel.appendChild(saveBtn); 982 + panel.appendChild(deleteBtn); 983 + }; 984 + 985 + /** 986 + * Rename a tag via IPC, update local state, and re-render. 987 + */ 988 + const renameTagAction = async (tagId, newName) => { 989 + newName = newName.trim(); 990 + if (!newName) return; 991 + 992 + const result = await api.datastore.renameTag(tagId, newName); 993 + if (result.success) { 994 + // Update local state 995 + const tagInList = state.tags.find(t => t.id === tagId); 996 + if (tagInList) tagInList.name = newName; 997 + 998 + const activeTag = state.activeTags.find(t => t.id === tagId); 999 + if (activeTag) activeTag.name = newName; 1000 + 1001 + // Update itemTags references 1002 + state.itemTags.forEach(tags => { 1003 + const t = tags.find(t => t.id === tagId); 1004 + if (t) t.name = newName; 1005 + }); 1006 + 1007 + render(); 1008 + debug && console.log('[tags] Renamed tag', tagId, 'to', newName); 1009 + } else { 1010 + console.error('[tags] Failed to rename tag:', result.error); 1011 + } 1012 + }; 1013 + 1014 + /** 1015 + * Delete a tag via IPC, update local state, and re-render. 1016 + */ 1017 + const deleteTagAction = async (tagId) => { 1018 + if (!confirm('Delete this tag? It will be removed from all items.')) return; 1019 + 1020 + const result = await api.datastore.deleteTag(tagId); 1021 + if (result.success) { 1022 + // Remove from state 1023 + state.tags = state.tags.filter(t => t.id !== tagId); 1024 + state.activeTags = state.activeTags.filter(t => t.id !== tagId); 1025 + state.managePanelOpen = false; 1026 + 1027 + // Remove from itemTags 1028 + state.itemTags.forEach((tags, itemId) => { 1029 + state.itemTags.set(itemId, tags.filter(t => t.id !== tagId)); 1030 + }); 1031 + 1032 + state.selectedIndex = 0; 1033 + render(); 1034 + debug && console.log('[tags] Deleted tag', tagId); 1035 + } else { 1036 + console.error('[tags] Failed to delete tag:', result.error); 1037 + } 1038 + }; 1039 + 1040 + /** 859 1041 * Render the tag sidebar. 860 1042 * Selected tags are pinned to the top of the list with a visual separator. 861 1043 */ 862 1044 const renderTagSidebar = () => { 863 - const tags = getFilteredTags(); 1045 + let tags = getFilteredTags(); 1046 + 1047 + // Sort tags by current preference 1048 + if (state.tagSortBy === 'name') { 1049 + const dir = state.tagSortDirection === 'asc' ? 1 : -1; 1050 + tags = [...tags].sort((a, b) => dir * a.name.localeCompare(b.name)); 1051 + } else { 1052 + // frecency (default) — already sorted by frecency from getTagsByFrecency 1053 + if (state.tagSortDirection === 'desc') { 1054 + tags = [...tags].reverse(); 1055 + } 1056 + } 1057 + 1058 + // Update sort button active states 1059 + document.querySelectorAll('.sidebar-sort-btn').forEach(btn => { 1060 + if (btn.dataset.sort === 'name') { 1061 + btn.classList.toggle('active', state.tagSortBy === 'name'); 1062 + } 1063 + }); 864 1064 865 1065 if (tags.length === 0) { 866 1066 tagList.innerHTML = '<div class="empty-state">No tags yet</div>'; ··· 940 1140 ? 'No items match your search.' 941 1141 : state.activeTags.length > 0 942 1142 ? `No items with all selected tags.` 943 - : 'No saved items yet.'; 1143 + : 'No tagged items yet. Tag items to see them here.'; 944 1144 cardsContainer.innerHTML = `<div class="empty-state">${message}</div>`; 945 1145 return; 946 1146 }
+6
preload.js
··· 517 517 getItemsByTag: (tagId) => { 518 518 return ipcRenderer.invoke('datastore-get-items-by-tag', { tagId }); 519 519 }, 520 + renameTag: (tagId, newName) => { 521 + return ipcRenderer.invoke('datastore-rename-tag', { tagId, newName }); 522 + }, 523 + deleteTag: (tagId) => { 524 + return ipcRenderer.invoke('datastore-delete-tag', { tagId }); 525 + }, 520 526 521 527 // History operations (visits joined with addresses) 522 528 getHistory: (filter = {}) => {