Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

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

feat: Forge Workspaces section in landing page

Replaces the simple smart folder with a dedicated Forge Workspaces
section showing individual workspace cards with:
- Decrypted workspace title (2-line clamp)
- Type badge (workspace vs report)
- Relative timestamp (2h ago, 3d ago)
- Version count badge (async fetched)
- Latest version label (e.g., "Final evaluation")

Shows both forge-workspace and forge-report tagged docs, sorted by
most recently updated. Section only appears at root level.

+175 -39
+79 -4
src/css/app.css
··· 1384 1384 border-color: var(--color-border-strong); 1385 1385 background: var(--color-hover); 1386 1386 } 1387 - .folder-card--smart { 1388 - border-color: oklch(0.55 0.08 250); 1387 + /* ── Forge Workspaces ── */ 1388 + 1389 + .forge-section { 1390 + margin-bottom: var(--space-lg); 1391 + } 1392 + .forge-section-heading { 1393 + font-size: 0.85rem; 1394 + font-weight: 600; 1395 + text-transform: uppercase; 1396 + letter-spacing: 0.05em; 1397 + color: var(--color-text-faint); 1398 + margin: 0 0 var(--space-sm) 0; 1399 + } 1400 + .forge-workspace-grid { 1401 + display: grid; 1402 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 1403 + gap: var(--space-sm); 1404 + } 1405 + .forge-workspace-card { 1406 + display: flex; 1407 + flex-direction: column; 1408 + gap: var(--space-xs); 1409 + padding: var(--space-md); 1410 + background: var(--color-surface); 1411 + border: 1px solid oklch(0.45 0.06 250); 1412 + border-radius: var(--radius-md); 1413 + cursor: pointer; 1414 + transition: all var(--transition-fast); 1415 + text-decoration: none; 1416 + color: inherit; 1417 + } 1418 + .forge-workspace-card:hover { 1419 + border-color: oklch(0.6 0.12 250); 1420 + background: var(--color-hover); 1421 + } 1422 + .forge-workspace-card-header { 1423 + display: flex; 1424 + justify-content: space-between; 1425 + align-items: center; 1426 + } 1427 + .forge-workspace-card-type { 1428 + font-size: 0.7rem; 1429 + font-weight: 500; 1430 + text-transform: uppercase; 1431 + letter-spacing: 0.04em; 1432 + color: oklch(0.65 0.1 250); 1433 + } 1434 + .forge-workspace-card-time { 1435 + font-size: 0.7rem; 1436 + color: var(--color-text-faint); 1437 + } 1438 + .forge-workspace-card-name { 1439 + font-weight: 500; 1440 + font-size: 0.9rem; 1441 + line-height: 1.3; 1442 + overflow: hidden; 1443 + text-overflow: ellipsis; 1444 + display: -webkit-box; 1445 + -webkit-line-clamp: 2; 1446 + -webkit-box-orient: vertical; 1447 + } 1448 + .forge-workspace-card-footer { 1449 + display: flex; 1450 + gap: var(--space-sm); 1451 + align-items: center; 1452 + font-size: 0.7rem; 1453 + color: var(--color-text-faint); 1454 + min-height: 1em; 1389 1455 } 1390 - .folder-card--smart:hover { 1391 - border-color: oklch(0.65 0.12 250); 1456 + .forge-workspace-card-versions { 1457 + background: oklch(0.3 0.03 250); 1458 + padding: 1px 6px; 1459 + border-radius: var(--radius-sm); 1460 + font-size: 0.65rem; 1461 + } 1462 + .forge-workspace-card-label { 1463 + overflow: hidden; 1464 + text-overflow: ellipsis; 1465 + white-space: nowrap; 1466 + flex: 1; 1392 1467 } 1393 1468 1394 1469 .folder-card-icon {
+1
src/index.html
··· 130 130 <input type="file" id="backup-import-input" accept=".json" style="display:none"> 131 131 </div> 132 132 </div> 133 + <div id="forge-workspaces"></div> 133 134 <div id="folder-list"></div> 134 135 <div id="doc-list"></div> 135 136 <div id="no-results" class="no-results" style="display:none;">No documents match your search.</div>
-11
src/landing-events-folders.ts
··· 61 61 return; 62 62 } 63 63 64 - // Smart folder click — filter by tag 65 - const smartCard = target.closest('.folder-card--smart') as HTMLElement | null; 66 - if (smartCard) { 67 - const tag = smartCard.dataset.smartTag; 68 - if (tag) { 69 - deps.setActiveTagFilter(tag); 70 - deps.renderDocuments(); 71 - } 72 - return; 73 - } 74 - 75 64 // Navigate into folder (but not if clicking actions) 76 65 const card = target.closest('.folder-card') as HTMLElement | null; 77 66 if (card && !target.closest('.folder-card-actions')) {
+95 -24
src/landing-render.ts
··· 192 192 193 193 // ── Folders ────────────────────────────────────────────────── 194 194 195 - // Smart folders: auto-generated from tags (e.g., "forge-report" → Forge folder). 196 - const SMART_FOLDERS: Array<{ tag: string; name: string; icon: string }> = [ 197 - { tag: 'forge-report', name: 'Forge', icon: '\u2692' }, // ⚒ 198 - ]; 195 + // ── Forge Workspaces section ───────────────────────────────── 196 + 197 + function relativeTime(dateStr: string): string { 198 + const diff = Date.now() - new Date(dateStr + 'Z').getTime(); 199 + const mins = Math.floor(diff / 60000); 200 + if (mins < 1) return 'just now'; 201 + if (mins < 60) return `${mins}m ago`; 202 + const hrs = Math.floor(mins / 60); 203 + if (hrs < 24) return `${hrs}h ago`; 204 + const days = Math.floor(hrs / 24); 205 + return `${days}d ago`; 206 + } 207 + 208 + export function renderForgeWorkspaces(deps: RenderDeps, activeDocs: DocumentMeta[]): void { 209 + const wsEl = document.getElementById('forge-workspaces'); 210 + const currentFolderId = deps.getCurrentFolderId(); 211 + const searchQuery = deps.getSearchQuery(); 212 + 213 + // Only show at root level, not when searching or inside a folder 214 + if (!wsEl || currentFolderId !== null || searchQuery) { 215 + if (wsEl) wsEl.innerHTML = ''; 216 + return; 217 + } 218 + 219 + // Find workspace-tagged docs (both forge-workspace and forge-report) 220 + const workspaceDocs = activeDocs.filter(d => { 221 + const tags = parseTags(d.tags); 222 + return tags.includes('forge-workspace') || tags.includes('forge-report'); 223 + }).sort((a, b) => new Date(b.updated_at + 'Z').getTime() - new Date(a.updated_at + 'Z').getTime()); 224 + 225 + if (workspaceDocs.length === 0) { 226 + wsEl.innerHTML = ''; 227 + return; 228 + } 229 + 230 + let html = '<div class="forge-section">'; 231 + html += '<h3 class="forge-section-heading">\u2692 Forge Workspaces</h3>'; 232 + html += '<div class="forge-workspace-grid">'; 233 + 234 + for (const doc of workspaceDocs) { 235 + const name = doc._decryptedName || 'Forge Workspace'; 236 + const keyStr = doc._keyStr; 237 + const href = keyStr ? `docs/${doc.id}#${keyStr}` : '#'; 238 + const time = relativeTime(doc.updated_at); 239 + const tags = parseTags(doc.tags); 240 + const isWorkspace = tags.includes('forge-workspace'); 241 + 242 + html += ` 243 + <a class="forge-workspace-card" href="${href}" data-doc-id="${doc.id}"> 244 + <div class="forge-workspace-card-header"> 245 + <span class="forge-workspace-card-type">${isWorkspace ? 'workspace' : 'report'}</span> 246 + <span class="forge-workspace-card-time">${time}</span> 247 + </div> 248 + <span class="forge-workspace-card-name">${escapeHtml(name)}</span> 249 + <div class="forge-workspace-card-footer" data-ws-doc-id="${doc.id}"></div> 250 + </a>`; 251 + } 252 + 253 + html += '</div></div>'; 254 + wsEl.innerHTML = html; 255 + 256 + // Async: fetch version counts for each workspace doc 257 + for (const doc of workspaceDocs) { 258 + fetch(`/api/documents/${doc.id}/versions`) 259 + .then(r => r.json()) 260 + .then((versions: Array<{ metadata?: string | null }>) => { 261 + const footer = wsEl.querySelector(`[data-ws-doc-id="${doc.id}"]`); 262 + if (!footer) return; 263 + if (versions.length === 0) return; 264 + 265 + // Get latest version label 266 + let latestLabel = ''; 267 + const latest = versions[0]; 268 + if (latest?.metadata) { 269 + try { 270 + const meta = typeof latest.metadata === 'string' ? JSON.parse(latest.metadata) : latest.metadata; 271 + latestLabel = (meta as Record<string, string>).label || ''; 272 + } catch { /* ignore */ } 273 + } 274 + 275 + let footerHtml = `<span class="forge-workspace-card-versions">${versions.length} version${versions.length !== 1 ? 's' : ''}</span>`; 276 + if (latestLabel) { 277 + footerHtml += `<span class="forge-workspace-card-label">${escapeHtml(latestLabel)}</span>`; 278 + } 279 + footer.innerHTML = footerHtml; 280 + }) 281 + .catch(() => { /* silent */ }); 282 + } 283 + } 284 + 285 + // ── Folders ────────────────────────────────────────────────── 199 286 200 287 export function renderFolders(deps: RenderDeps, activeDocs: DocumentMeta[]): void { 201 288 const currentFolderId = deps.getCurrentFolderId(); ··· 203 290 const folders = deps.getFolders(); 204 291 const folderAssignments = deps.getFolderAssignments(); 205 292 206 - // Only show folders at root level and when not searching 207 293 if (currentFolderId !== null || searchQuery) { 208 294 deps.folderListEl.innerHTML = ''; 209 295 return; 210 296 } 211 297 212 - // Build smart folders from tagged docs 213 - const smartFolderCards: string[] = []; 214 - for (const sf of SMART_FOLDERS) { 215 - const tagged = filterByTag(activeDocs, sf.tag) as DocumentMeta[]; 216 - if (tagged.length > 0) { 217 - // Smart folders use tag: prefix as their folder ID 218 - smartFolderCards.push(` 219 - <div class="folder-card folder-card--smart" data-smart-tag="${escapeHtml(sf.tag)}"> 220 - <span class="folder-card-icon">${sf.icon}</span> 221 - <span class="folder-card-name">${escapeHtml(sf.name)}</span> 222 - <span class="folder-card-count">${tagged.length} doc${tagged.length !== 1 ? 's' : ''}</span> 223 - </div>`); 224 - } 225 - } 226 - 227 - if (folders.length === 0 && smartFolderCards.length === 0) { 298 + if (folders.length === 0) { 228 299 deps.folderListEl.innerHTML = ''; 229 300 return; 230 301 } 231 302 232 303 let html = '<div class="folder-grid">'; 233 - // Smart folders first 234 - html += smartFolderCards.join(''); 235 - // User folders 236 304 for (const folder of folders) { 237 305 const docCount = activeDocs.filter(d => folderAssignments[d.id] === folder.id).length; 238 306 html += ` ··· 379 447 380 448 // Update view toggle icon state 381 449 updateViewToggle(deps); 450 + 451 + // Render Forge workspaces section 452 + renderForgeWorkspaces(deps, active); 382 453 383 454 // Render folder cards (only at root, not inside a folder, and not when searching) 384 455 renderFolders(deps, active);