experiments in a post-browser web
10
fork

Configure Feed

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

refactor: rename group mode to space mode, storage key group-workspaces to spaces (Phase 1b+1c)

+370 -3
+109
features/tags/home.css
··· 426 426 color: var(--base05); 427 427 } 428 428 429 + /* Entity filter bar */ 430 + .entity-filter-bar { 431 + display: none; 432 + flex-direction: column; 433 + gap: 6px; 434 + margin-bottom: 8px; 435 + padding: 8px 10px; 436 + background: var(--base01); 437 + border: 1px solid var(--base02); 438 + border-radius: 6px; 439 + } 440 + 441 + .entity-filter-bar.visible { 442 + display: flex; 443 + } 444 + 445 + .entity-filter-active { 446 + display: flex; 447 + align-items: center; 448 + gap: 5px; 449 + flex-wrap: wrap; 450 + padding-bottom: 6px; 451 + border-bottom: 1px solid var(--base02); 452 + } 453 + 454 + .entity-filter-group { 455 + display: flex; 456 + align-items: center; 457 + gap: 5px; 458 + flex-wrap: wrap; 459 + } 460 + 461 + .entity-filter-label { 462 + font-size: 10px; 463 + font-weight: 600; 464 + color: var(--base04); 465 + text-transform: uppercase; 466 + letter-spacing: 0.4px; 467 + flex-shrink: 0; 468 + margin-right: 2px; 469 + } 470 + 471 + .entity-chip { 472 + display: inline-flex; 473 + align-items: center; 474 + gap: 3px; 475 + padding: 2px 8px; 476 + background: var(--base02); 477 + border: 1px solid var(--base03); 478 + border-radius: 10px; 479 + font-size: 11px; 480 + color: var(--base05); 481 + cursor: pointer; 482 + transition: all 0.15s; 483 + white-space: nowrap; 484 + } 485 + 486 + .entity-chip:hover { 487 + background: var(--base03); 488 + border-color: var(--base04); 489 + } 490 + 491 + .entity-chip.active { 492 + background: color-mix(in srgb, var(--base0C) 20%, var(--base01)); 493 + border-color: color-mix(in srgb, var(--base0C) 40%, transparent); 494 + color: var(--base05); 495 + font-weight: 500; 496 + } 497 + 498 + .entity-chip-name { 499 + max-width: 150px; 500 + overflow: hidden; 501 + text-overflow: ellipsis; 502 + white-space: nowrap; 503 + } 504 + 505 + .entity-chip-remove { 506 + background: none; 507 + border: none; 508 + color: var(--base04); 509 + cursor: pointer; 510 + font-size: 13px; 511 + line-height: 1; 512 + padding: 0; 513 + opacity: 0.8; 514 + } 515 + 516 + .entity-chip-remove:hover { 517 + opacity: 1; 518 + color: var(--base08); 519 + } 520 + 521 + .entity-filter-clear { 522 + background: var(--base02); 523 + border: none; 524 + color: var(--base04); 525 + cursor: pointer; 526 + font-size: 11px; 527 + padding: 3px 8px; 528 + border-radius: 10px; 529 + margin-left: 2px; 530 + transition: all 0.15s; 531 + } 532 + 533 + .entity-filter-clear:hover { 534 + background: var(--base03); 535 + color: var(--base05); 536 + } 537 + 429 538 /* Grid toolbar */ 430 539 peek-grid-toolbar.grid-toolbar { 431 540 margin-bottom: 6px;
+2
features/tags/home.html
··· 127 127 <div class="selected-tags-bar"></div> 128 128 <!-- Tag management panel --> 129 129 <div class="tag-manage-panel"></div> 130 + <!-- Entity filter bar --> 131 + <div class="entity-filter-bar"></div> 130 132 <!-- Grid toolbar --> 131 133 <peek-grid-toolbar class="grid-toolbar" view-mode="columns"></peek-grid-toolbar> 132 134
+259 -3
features/tags/home.js
··· 71 71 currentView: VIEW_LIST, // 'list' | 'detail' 72 72 activeFilter: 'all', // 'all' | 'page' | 'text' | 'tagset' | 'image' 73 73 activeTags: [], // Array of tag objects for filtering (AND logic) 74 + activeEntities: [], // Array of entity objects for filtering (AND logic) 74 75 items: [], // All addresses 75 76 tags: [], // All tags sorted by frecency 76 77 itemTags: new Map(), // Map of addressId -> [tags] 78 + itemEntities: new Map(), // Map of item URL -> [entity objects] 79 + allEntities: [], // All entities with observations on displayed items 77 80 selectedIndex: 0, 78 81 searchQuery: '', 79 82 editingItem: null, // Item being viewed in detail ··· 179 182 state.tags = []; 180 183 } 181 184 185 + // Load entity data for URL items 186 + await loadEntityData(); 187 + 182 188 // Update filter counts 183 189 updateFilterCounts(); 184 190 }; ··· 197 203 }; 198 204 199 205 /** 206 + * Load entity data: all entities and their observations, then build a 207 + * mapping of item URL -> entities so we can offer entity filtering. 208 + */ 209 + const loadEntityData = async () => { 210 + state.itemEntities = new Map(); 211 + state.allEntities = []; 212 + 213 + try { 214 + // Load all entity items 215 + const entitiesResult = await api.datastore.queryItems({ type: 'entity' }); 216 + if (!entitiesResult.success || !entitiesResult.data) return; 217 + 218 + const entities = entitiesResult.data.filter(e => e.deletedAt === 0); 219 + if (entities.length === 0) return; 220 + 221 + // Parse metadata for each entity 222 + const entityMap = new Map(); // entityId -> { ...item, _meta } 223 + for (const e of entities) { 224 + let meta = {}; 225 + try { meta = JSON.parse(e.metadata || '{}'); } catch {} 226 + // Skip thumbs-down entities 227 + if (meta.feedback?.score === -1) continue; 228 + entityMap.set(e.id, { ...e, _meta: meta }); 229 + } 230 + 231 + // Load observations (item_events) for all entities to build URL -> entity mapping 232 + const urlEntityMap = new Map(); // url -> Set of entityId 233 + const eventsResult = await api.datastore.queryItemEvents({ limit: 500, order: 'desc' }); 234 + if (eventsResult.success && eventsResult.data) { 235 + for (const evt of eventsResult.data) { 236 + if (entityMap.has(evt.itemId) && evt.content) { 237 + if (!urlEntityMap.has(evt.content)) { 238 + urlEntityMap.set(evt.content, new Set()); 239 + } 240 + urlEntityMap.get(evt.content).add(evt.itemId); 241 + } 242 + } 243 + } 244 + 245 + // Build item -> entities mapping based on item URLs 246 + const seenEntityIds = new Set(); 247 + for (const item of state.items) { 248 + const itemUrl = item.content || item.uri || ''; 249 + if (!itemUrl) continue; 250 + 251 + const entityIds = urlEntityMap.get(itemUrl); 252 + if (!entityIds || entityIds.size === 0) continue; 253 + 254 + const itemEntityList = []; 255 + for (const eid of entityIds) { 256 + const entity = entityMap.get(eid); 257 + if (entity) { 258 + itemEntityList.push(entity); 259 + seenEntityIds.add(eid); 260 + } 261 + } 262 + if (itemEntityList.length > 0) { 263 + state.itemEntities.set(item.id, itemEntityList); 264 + } 265 + } 266 + 267 + // Collect all unique entities that appear on displayed items 268 + state.allEntities = Array.from(seenEntityIds) 269 + .map(id => entityMap.get(id)) 270 + .filter(Boolean) 271 + .sort((a, b) => { 272 + // Sort by entity type, then by name 273 + const typeCompare = (a._meta.entityType || '').localeCompare(b._meta.entityType || ''); 274 + if (typeCompare !== 0) return typeCompare; 275 + return (a.content || '').localeCompare(b.content || ''); 276 + }); 277 + 278 + debug && console.log('[tags] Loaded entity data:', state.allEntities.length, 'entities across', state.itemEntities.size, 'items'); 279 + } catch (err) { 280 + console.error('[tags] Failed to load entity data:', err); 281 + } 282 + }; 283 + 284 + /** 200 285 * Internal ESC handler for tags navigation (following groups pattern). 201 286 * Returns { handled: true } if we navigated internally. 202 287 * Returns { handled: false } if at root (list view) and window should close. ··· 249 334 if (state.searchQuery) { 250 335 state.searchQuery = ''; 251 336 searchInput.value = ''; 337 + render(); 338 + return { handled: true }; 339 + } 340 + 341 + // If entity filter is active, clear it 342 + if (state.activeEntities.length > 0) { 343 + state.activeEntities = []; 344 + state.selectedIndex = 0; 252 345 render(); 253 346 return { handled: true }; 254 347 } ··· 451 544 // so we need this to refresh when new data arrives via sync 452 545 api.subscribe('sync:pull-completed', (msg) => { 453 546 debug && console.log('[tags] sync:pull-completed event received:', msg); 547 + debouncedRefresh(); 548 + }, api.scopes.GLOBAL); 549 + 550 + // Subscribe to entity extraction events to update entity filter bar 551 + api.subscribe('entities:extracted', (msg) => { 552 + debug && console.log('[tags] entities:extracted event received:', msg); 454 553 debouncedRefresh(); 455 554 }, api.scopes.GLOBAL); 456 555 ··· 739 838 renderFilterButtons(); 740 839 renderTagSidebar(); 741 840 renderSelectedTagsBar(); 841 + renderEntityFilterBar(); 742 842 renderCards(); 743 843 renderActiveTagIndicator(); 744 844 updateSearchPlaceholder(); ··· 869 969 renderTagManagePanel(); 870 970 }; 871 971 972 + // Entity type display labels 973 + const ENTITY_TYPE_LABELS = { 974 + person: 'People', 975 + organization: 'Organizations', 976 + place: 'Places', 977 + event: 'Events', 978 + email: 'Emails', 979 + phone: 'Phones', 980 + product: 'Products', 981 + creative_work: 'Works', 982 + price: 'Prices', 983 + }; 984 + 985 + /** 986 + * Render the entity filter bar. 987 + * Shows available entities from currently displayed items, grouped by type. 988 + * Clicking an entity chip toggles it as a filter (AND logic for multiple). 989 + */ 990 + const renderEntityFilterBar = () => { 991 + const bar = document.querySelector('.entity-filter-bar'); 992 + if (!bar) return; 993 + 994 + // Collect entities from the currently visible items (after tag/type/search filter, before entity filter) 995 + const visibleItems = getFilteredItems(); 996 + const visibleEntityIds = new Set(); 997 + for (const item of visibleItems) { 998 + const ents = state.itemEntities.get(item.id) || []; 999 + for (const e of ents) { 1000 + visibleEntityIds.add(e.id); 1001 + } 1002 + } 1003 + 1004 + // Get visible entities, keeping selected ones even if no longer in visible set 1005 + const selectedIds = new Set(state.activeEntities.map(e => e.id)); 1006 + const visibleEntities = state.allEntities.filter( 1007 + e => visibleEntityIds.has(e.id) || selectedIds.has(e.id) 1008 + ); 1009 + 1010 + if (visibleEntities.length === 0) { 1011 + bar.classList.remove('visible'); 1012 + bar.innerHTML = ''; 1013 + return; 1014 + } 1015 + 1016 + bar.innerHTML = ''; 1017 + bar.classList.add('visible'); 1018 + 1019 + // Group by entity type 1020 + const grouped = new Map(); 1021 + for (const entity of visibleEntities) { 1022 + const type = entity._meta.entityType || 'other'; 1023 + if (!grouped.has(type)) grouped.set(type, []); 1024 + grouped.get(type).push(entity); 1025 + } 1026 + 1027 + // Active entity chips first (if any) 1028 + if (state.activeEntities.length > 0) { 1029 + const activeSection = document.createElement('div'); 1030 + activeSection.className = 'entity-filter-active'; 1031 + 1032 + state.activeEntities.forEach(entity => { 1033 + const chip = createEntityChip(entity, true); 1034 + activeSection.appendChild(chip); 1035 + }); 1036 + 1037 + const clearBtn = document.createElement('button'); 1038 + clearBtn.className = 'entity-filter-clear'; 1039 + clearBtn.textContent = 'Clear'; 1040 + clearBtn.title = 'Clear entity filters'; 1041 + clearBtn.addEventListener('click', () => { 1042 + state.activeEntities = []; 1043 + state.selectedIndex = 0; 1044 + render(); 1045 + }); 1046 + activeSection.appendChild(clearBtn); 1047 + 1048 + bar.appendChild(activeSection); 1049 + } 1050 + 1051 + // Entity chips grouped by type 1052 + for (const [type, entities] of grouped) { 1053 + // Skip entities that are already in the active section 1054 + const remaining = entities.filter(e => !selectedIds.has(e.id)); 1055 + if (remaining.length === 0) continue; 1056 + 1057 + const group = document.createElement('div'); 1058 + group.className = 'entity-filter-group'; 1059 + 1060 + const label = document.createElement('span'); 1061 + label.className = 'entity-filter-label'; 1062 + label.textContent = ENTITY_TYPE_LABELS[type] || type; 1063 + group.appendChild(label); 1064 + 1065 + remaining.forEach(entity => { 1066 + const chip = createEntityChip(entity, false); 1067 + group.appendChild(chip); 1068 + }); 1069 + 1070 + bar.appendChild(group); 1071 + } 1072 + }; 1073 + 1074 + /** 1075 + * Create an entity chip element for the filter bar. 1076 + */ 1077 + const createEntityChip = (entity, isActive) => { 1078 + const chip = document.createElement('span'); 1079 + chip.className = 'entity-chip' + (isActive ? ' active' : ''); 1080 + chip.dataset.entityId = entity.id; 1081 + chip.title = `${entity._meta.entityType || 'entity'}: ${entity.content}`; 1082 + 1083 + const nameSpan = document.createElement('span'); 1084 + nameSpan.className = 'entity-chip-name'; 1085 + nameSpan.textContent = entity.content; 1086 + chip.appendChild(nameSpan); 1087 + 1088 + if (isActive) { 1089 + const removeBtn = document.createElement('button'); 1090 + removeBtn.className = 'entity-chip-remove'; 1091 + removeBtn.innerHTML = '&times;'; 1092 + removeBtn.addEventListener('click', (e) => { 1093 + e.stopPropagation(); 1094 + state.activeEntities = state.activeEntities.filter(ae => ae.id !== entity.id); 1095 + state.selectedIndex = 0; 1096 + render(); 1097 + }); 1098 + chip.appendChild(removeBtn); 1099 + } 1100 + 1101 + chip.addEventListener('click', () => { 1102 + if (isActive) return; // handled by remove button 1103 + // Toggle entity filter 1104 + const existingIdx = state.activeEntities.findIndex(ae => ae.id === entity.id); 1105 + if (existingIdx >= 0) { 1106 + state.activeEntities = state.activeEntities.filter(ae => ae.id !== entity.id); 1107 + } else { 1108 + state.activeEntities = [...state.activeEntities, entity]; 1109 + } 1110 + state.selectedIndex = 0; 1111 + render(); 1112 + }); 1113 + 1114 + return chip; 1115 + }; 1116 + 872 1117 /** 873 1118 * Render the tag management panel (rename / delete) for a single selected tag. 874 1119 */ ··· 1062 1307 const renderCards = () => { 1063 1308 let items = getFilteredItems(); 1064 1309 1310 + // Apply entity filter (AND logic - item must have ALL selected entities) 1311 + if (state.activeEntities.length > 0) { 1312 + items = items.filter(item => { 1313 + const itemEnts = state.itemEntities.get(item.id) || []; 1314 + const itemEntIds = new Set(itemEnts.map(e => e.id)); 1315 + return state.activeEntities.every(ae => itemEntIds.has(ae.id)); 1316 + }); 1317 + } 1318 + 1065 1319 if (items.length === 0) { 1066 1320 const message = state.searchQuery 1067 1321 ? 'No items match your search.' 1068 - : state.activeTags.length > 0 1069 - ? `No items with all selected tags.` 1070 - : 'No tagged items yet. Tag items to see them here.'; 1322 + : state.activeEntities.length > 0 1323 + ? 'No items match all selected entities.' 1324 + : state.activeTags.length > 0 1325 + ? `No items with all selected tags.` 1326 + : 'No tagged items yet. Tag items to see them here.'; 1071 1327 cardsContainer.innerHTML = `<div class="empty-state">${message}</div>`; 1072 1328 return; 1073 1329 }