Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

fix(native): stage built-in firmware for kernel embed in docker build

+341 -49
+229 -34
at/index.html
··· 185 185 186 186 .search-box input:focus { border-color: rgb(205, 92, 155); } 187 187 188 + .user-filters { 189 + display: flex; 190 + gap: 0.45em; 191 + flex-wrap: wrap; 192 + margin: 0.2em 0 1em 0; 193 + } 194 + 195 + .user-filter { 196 + border: 1px solid rgba(205, 92, 155, 0.25); 197 + background: rgba(205, 92, 155, 0.08); 198 + color: rgba(0, 0, 0, 0.7); 199 + font-family: monospace; 200 + font-size: 0.75em; 201 + padding: 0.35em 0.55em; 202 + border-radius: 999px; 203 + cursor: pointer; 204 + transition: all 0.15s; 205 + } 206 + 207 + .user-filter.active { 208 + background: rgba(205, 92, 155, 0.2); 209 + border-color: rgba(205, 92, 155, 0.45); 210 + color: rgb(205, 92, 155); 211 + font-weight: bold; 212 + } 213 + 214 + .user-filter:hover { background: rgba(205, 92, 155, 0.14); } 215 + 188 216 /* User list */ 189 217 .user-list { display: flex; flex-direction: column; } 190 218 ··· 266 294 .spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(205, 92, 155, 0.3); border-radius: 50%; border-top-color: rgb(205, 92, 155); animation: spin 1s ease-in-out infinite; } 267 295 @keyframes spin { to { transform: rotate(360deg); } } 268 296 297 + .feed-filters { 298 + display: flex; 299 + gap: 0.5em; 300 + flex-wrap: wrap; 301 + margin: 0.5em 0 1em 0; 302 + } 303 + 304 + .feed-filter { 305 + display: flex; 306 + align-items: center; 307 + gap: 0.3em; 308 + font-size: 0.8em; 309 + cursor: pointer; 310 + padding: 0.3em 0.6em; 311 + background: rgba(205, 92, 155, 0.1); 312 + border-radius: 3px; 313 + user-select: none; 314 + transition: opacity 0.15s; 315 + } 316 + 317 + .feed-filter.off { opacity: 0.35; } 318 + 319 + .feed-filter input { display: none; } 320 + 269 321 .load-more { 270 322 display: block; 271 323 margin: 1em auto; ··· 349 401 <div class="search-box"> 350 402 <input type="text" id="search" placeholder="Search by handle..." autocomplete="off"> 351 403 </div> 404 + <div class="user-filters" id="user-filters"></div> 352 405 <div id="users-container"> 353 406 <div class="loading"><div class="spinner"></div><p>Loading users...</p></div> 354 407 </div> ··· 356 409 357 410 <!-- All Media Feed --> 358 411 <div class="tab-panel" id="panel-feed"> 412 + <div class="feed-filters" id="feed-filters"></div> 359 413 <div id="feed-container"> 360 414 <div class="loading"><div class="spinner"></div><p>Loading media...</p></div> 361 415 </div> ··· 385 439 { id: 'computer.aesthetic.paper', icon: '📄', label: 'paper' }, 386 440 ]; 387 441 442 + const COLLECTION_BY_LABEL = Object.fromEntries(COLLECTIONS.map(c => [c.label, c.id])); 443 + 388 444 let allUsers = []; 389 445 let feedLoaded = false; 446 + let activeUserCollection = 'all'; 447 + let userSearchQuery = ''; 448 + let didToHandle = new Map(); 449 + 450 + function normalizeHandle(value) { 451 + return String(value || '') 452 + .trim() 453 + .replace(/^@/, '') 454 + .replace(/\.at\.aesthetic\.computer$/i, '') 455 + .toLowerCase(); 456 + } 457 + 458 + function formatDid(did) { 459 + if (!did) return 'unknown'; 460 + return did.length > 24 ? `${did.slice(0, 20)}...` : did; 461 + } 462 + 463 + function userMatchesQuery(user, query) { 464 + if (!query) return true; 465 + const fields = [ 466 + user.handle, 467 + user.code, 468 + normalizeHandle(user.handle), 469 + normalizeHandle(user.code), 470 + ]; 471 + return fields.some(v => String(v || '').toLowerCase().includes(query)); 472 + } 473 + 474 + function getFilteredUsers() { 475 + let users = allUsers; 476 + if (activeUserCollection !== 'all') { 477 + const colId = COLLECTION_BY_LABEL[activeUserCollection]; 478 + users = users.filter(user => (user.collections || []).includes(colId)); 479 + } 480 + const q = normalizeHandle(userSearchQuery); 481 + if (q) users = users.filter(user => userMatchesQuery(user, q)); 482 + return users; 483 + } 484 + 485 + function renderFilteredUsers() { 486 + renderUsers(getFilteredUsers()); 487 + } 488 + 489 + function buildUserFilters() { 490 + const el = document.getElementById('user-filters'); 491 + if (!el) return; 492 + 493 + const filters = [{ label: 'all', icon: '⭐' }, ...COLLECTIONS]; 494 + el.innerHTML = filters 495 + .map(f => `<button class="user-filter ${f.label === activeUserCollection ? 'active' : ''}" data-label="${f.label}">${f.icon} ${f.label}</button>`) 496 + .join(''); 497 + 498 + el.querySelectorAll('.user-filter').forEach(btn => { 499 + btn.addEventListener('click', () => { 500 + activeUserCollection = btn.dataset.label; 501 + buildUserFilters(); 502 + renderFilteredUsers(); 503 + }); 504 + }); 505 + } 506 + 507 + function repoLabelForItem(item) { 508 + if (item._handle) return item._handle; 509 + const repo = item.uri ? item.uri.split('/')[2] : item._did; 510 + if (!repo) return 'unknown'; 511 + if (repo.startsWith('did:')) return formatDid(repo); 512 + return `@${normalizeHandle(repo)}`; 513 + } 390 514 391 515 // --- Tabs --- 392 516 document.querySelectorAll('.tab').forEach(tab => { ··· 412 536 document.getElementById('total-records').textContent = data.stats.totalRecords.toLocaleString(); 413 537 document.getElementById('active-users').textContent = data.stats.activeUsers.toLocaleString(); 414 538 } 415 - renderUsers(allUsers); 539 + buildUserFilters(); 540 + renderFilteredUsers(); 416 541 } catch (error) { 417 542 document.getElementById('users-container').innerHTML = '<div class="error">Failed to load users.</div>'; 418 543 } ··· 429 554 const row = document.createElement('a'); 430 555 row.className = 'user-row'; 431 556 const identifier = user.handle || user.code; 432 - const shortHandle = identifier.replace('.at.aesthetic.computer', '').replace('@', ''); 557 + const shortHandle = normalizeHandle(identifier); 433 558 row.href = `https://${shortHandle}.at.aesthetic.computer`; 434 559 row.target = '_blank'; 435 560 ··· 442 567 'computer.aesthetic.tape': '📼', 443 568 }; 444 569 for (const [col, emoji] of Object.entries(badgeMap)) { 445 - if (user.collections.includes(col)) { 570 + if ((user.collections || []).includes(col)) { 446 571 badges.push(`<span class="user-badge">${emoji} ${user.recordCounts[col] || 0}</span>`); 447 572 } 448 573 } ··· 473 598 } 474 599 475 600 document.getElementById('search').addEventListener('input', (e) => { 476 - const q = e.target.value.toLowerCase(); 477 - renderUsers(q ? allUsers.filter(u => (u.handle || u.code).toLowerCase().includes(q)) : allUsers); 601 + userSearchQuery = e.target.value || ''; 602 + renderFilteredUsers(); 478 603 }); 479 604 480 605 // --- All Media Feed --- 606 + let allFeedItems = []; 607 + let activeFilters = new Set(COLLECTIONS.map(c => c.label)); 608 + 609 + function timeAgo(dateStr) { 610 + const now = Date.now(); 611 + const then = new Date(dateStr).getTime(); 612 + const sec = Math.floor((now - then) / 1000); 613 + if (sec < 60) return 'just now'; 614 + const min = Math.floor(sec / 60); 615 + if (min < 60) return `${min}m ago`; 616 + const hr = Math.floor(min / 60); 617 + if (hr < 24) return `${hr}h ago`; 618 + const days = Math.floor(hr / 24); 619 + if (days < 30) return `${days}d ago`; 620 + if (days < 365) { 621 + const months = Math.floor(days / 30); 622 + return `${months}mo ago`; 623 + } 624 + const years = Math.floor(days / 365); 625 + return `${years}y ago`; 626 + } 627 + 628 + function buildFilters() { 629 + const el = document.getElementById('feed-filters'); 630 + el.innerHTML = COLLECTIONS.map(col => 631 + `<label class="feed-filter" data-col="${col.label}"> 632 + <input type="checkbox" checked> ${col.icon} ${col.label} 633 + </label>` 634 + ).join(''); 635 + el.querySelectorAll('.feed-filter').forEach(label => { 636 + label.addEventListener('click', (e) => { 637 + e.preventDefault(); 638 + const col = label.dataset.col; 639 + if (activeFilters.has(col)) { activeFilters.delete(col); label.classList.add('off'); } 640 + else { activeFilters.add(col); label.classList.remove('off'); } 641 + renderFeed(allFeedItems, document.getElementById('feed-container')); 642 + }); 643 + }); 644 + } 645 + 481 646 async function loadFeed() { 482 647 feedLoaded = true; 483 648 const container = document.getElementById('feed-container'); 649 + buildFilters(); 650 + didToHandle = new Map(); 484 651 485 652 try { 486 - // Fetch repos, then latest records from each collection across top repos 487 - const reposRes = await fetch(`${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=50`); 488 - const reposData = await reposRes.json(); 489 - const repos = (reposData.repos || []).map(r => r.did); 653 + // Use top users' DIDs from already-loaded data (avoids extra listRepos call) 654 + // Fall back to listRepos if users haven't loaded yet 655 + let dids = []; 656 + if (allUsers.length > 0) { 657 + // Get unique user IDs, resolve to DIDs via the users data 658 + const seen = new Set(); 659 + for (const u of allUsers.slice(0, 15)) { 660 + const handle = normalizeHandle(u.handle || u.code || ''); 661 + if (handle && !seen.has(handle)) { 662 + seen.add(handle); 663 + } 664 + } 665 + // Resolve handles to DIDs 666 + const resolves = await Promise.allSettled( 667 + [...seen].map(h => 668 + fetch(`${PDS_URL}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(h + '.at.aesthetic.computer')}`) 669 + .then(r => r.ok ? r.json() : null) 670 + .then(d => { 671 + if (d?.did) didToHandle.set(d.did, `@${h}`); 672 + return d?.did; 673 + }) 674 + ) 675 + ); 676 + dids = resolves.map(r => r.value).filter(Boolean); 677 + } 490 678 491 - const allItems = []; 679 + if (dids.length === 0) { 680 + const reposRes = await fetch(`${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=15`); 681 + const reposData = await reposRes.json(); 682 + const repos = reposData.repos || []; 683 + dids = repos.map(r => r.did); 684 + for (const repo of repos) { 685 + const handle = normalizeHandle(repo.handle || ''); 686 + if (repo.did && handle) didToHandle.set(repo.did, `@${handle}`); 687 + } 688 + } 492 689 493 - // For each collection, fetch recent records from all repos (limit per repo for speed) 494 - for (const col of COLLECTIONS) { 495 - for (const did of repos.slice(0, 20)) { 496 - try { 497 - const res = await fetch(`${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(col.id)}&limit=5`); 498 - if (!res.ok) continue; 499 - const data = await res.json(); 500 - for (const rec of (data.records || [])) { 501 - allItems.push({ ...rec.value, uri: rec.uri, _col: col, _did: did }); 502 - } 503 - } catch { /* skip */ } 690 + // Fire ALL requests in parallel: dids × collections 691 + const fetches = []; 692 + for (const did of dids.slice(0, 12)) { 693 + for (const col of COLLECTIONS) { 694 + fetches.push( 695 + fetch(`${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(col.id)}&limit=5&reverse=true`) 696 + .then(r => r.ok ? r.json() : { records: [] }) 697 + .then(data => (data.records || []).map(rec => ({ ...rec.value, uri: rec.uri, _col: col, _did: did, _handle: didToHandle.get(did) || '' }))) 698 + .catch(() => []) 699 + ); 504 700 } 505 701 } 506 702 507 - // Sort by when (reverse chrono) 508 - allItems.sort((a, b) => { 509 - const da = new Date(a.when || a.createdAt || 0); 510 - const db = new Date(b.when || b.createdAt || 0); 511 - return db - da; 512 - }); 703 + const results = await Promise.all(fetches); 704 + allFeedItems = results.flat(); 513 705 514 - renderFeed(allItems.slice(0, 100), container); 706 + // Sort reverse chrono 707 + allFeedItems.sort((a, b) => new Date(b.when || b.createdAt || 0) - new Date(a.when || a.createdAt || 0)); 708 + 709 + renderFeed(allFeedItems, container); 515 710 } catch (error) { 516 711 container.innerHTML = '<div class="error">Failed to load media feed.</div>'; 517 712 } 518 713 } 519 714 520 715 function renderFeed(items, container) { 521 - if (!items.length) { container.innerHTML = '<div class="loading">No media found</div>'; return; } 716 + const filtered = items.filter(item => activeFilters.has(item._col.label)); 717 + if (!filtered.length) { container.innerHTML = '<div class="loading">No media found</div>'; return; } 522 718 523 719 const feed = document.createElement('div'); 524 720 feed.className = 'feed'; 525 721 526 - for (const item of items) { 722 + for (const item of filtered.slice(0, 150)) { 527 723 const el = document.createElement('div'); 528 724 el.className = 'feed-item'; 529 725 530 726 const col = item._col; 531 727 const when = item.when || item.createdAt; 532 - const dateStr = when ? new Date(when).toLocaleDateString() : ''; 533 - const handle = item.uri ? item.uri.split('/')[2] : item._did; 534 - const shortDid = handle.length > 20 ? handle.slice(0, 16) + '...' : handle; 728 + const ago = when ? timeAgo(when) : ''; 729 + const repoLabel = repoLabelForItem(item); 535 730 536 731 let title = ''; 537 732 let thumb = ''; ··· 579 774 ${thumb} 580 775 <div class="feed-body"> 581 776 <div class="feed-title">${titleHtml}</div> 582 - <div class="feed-meta">${col.label} · ${dateStr} · ${shortDid}</div> 777 + <div class="feed-meta">${col.label} · ${ago} · ${repoLabel}</div> 583 778 </div>`; 584 779 feed.appendChild(el); 585 780 }
+92 -2
fedac/native/docker-build.sh
··· 42 42 tail -220 "$log_file" >&2 || true 43 43 } 44 44 45 + run_make_with_heartbeat() { 46 + local log_file="$1" 47 + shift 48 + local heartbeat_secs="${AC_KERNEL_HEARTBEAT_SECS:-20}" 49 + local last_count="-1" 50 + local last_line="" 51 + local line_count 52 + local current_line 53 + 54 + : > "$log_file" 55 + "$@" >"$log_file" 2>&1 & 56 + local make_pid=$! 57 + 58 + while kill -0 "$make_pid" 2>/dev/null; do 59 + sleep "$heartbeat_secs" 60 + [ -f "$log_file" ] || continue 61 + line_count=$(wc -l <"$log_file" 2>/dev/null || echo 0) 62 + current_line=$(tail -1 "$log_file" 2>/dev/null || true) 63 + current_line="${current_line:0:180}" 64 + if [ "$line_count" != "$last_count" ] || [ "$current_line" != "$last_line" ]; then 65 + log " [kernel] running... lines=$line_count last=$current_line" 66 + last_count="$line_count" 67 + last_line="$current_line" 68 + else 69 + log " [kernel] running... lines=$line_count (no new lines yet)" 70 + fi 71 + done 72 + 73 + if wait "$make_pid"; then 74 + return 0 75 + fi 76 + return $? 77 + } 78 + 45 79 log "Building $BUILD_NAME ($GIT_HASH)" 46 80 47 81 # ══════════════════════════════════════════════ ··· 334 368 # Copy config 335 369 cp "$NATIVE/kernel/config-minimal" "$LINUX_DIR/.config" 336 370 371 + # Stage built-in firmware blobs referenced by CONFIG_EXTRA_FIRMWARE 372 + # into a container-local directory and rewrite CONFIG_EXTRA_FIRMWARE_DIR. 373 + FIRMWARE_ABS="$BUILD/firmware" 374 + mkdir -p "$FIRMWARE_ABS" 375 + 376 + HOST_FWDIR="" 377 + for d in /usr/lib/firmware /lib/firmware; do 378 + if [ -d "$d" ]; then 379 + HOST_FWDIR="$d" 380 + break 381 + fi 382 + done 383 + 384 + copy_builtin_fw_blob() { 385 + local rel="$1" 386 + local src_base="$2" 387 + local dst="$FIRMWARE_ABS/$rel" 388 + mkdir -p "$(dirname "$dst")" 389 + if [ -f "$src_base/$rel" ]; then 390 + cp -L "$src_base/$rel" "$dst" 391 + return 0 392 + fi 393 + if [ -f "$src_base/$rel.zst" ]; then 394 + zstd -d "$src_base/$rel.zst" -o "$dst" 2>/dev/null && return 0 395 + fi 396 + if [ -f "$src_base/$rel.xz" ]; then 397 + xz -dc "$src_base/$rel.xz" >"$dst" 2>/dev/null && return 0 398 + fi 399 + return 1 400 + } 401 + 402 + FW_LIST=$(sed -n 's/^CONFIG_EXTRA_FIRMWARE="\([^"]*\)"/\1/p' "$LINUX_DIR/.config" | head -1) 403 + if [ -n "$FW_LIST" ]; then 404 + if [ -z "$HOST_FWDIR" ]; then 405 + err "Kernel config requests built-in firmware but no /usr/lib/firmware or /lib/firmware directory was found." 406 + exit 1 407 + fi 408 + 409 + missing_fw="" 410 + for fw in $FW_LIST; do 411 + if ! copy_builtin_fw_blob "$fw" "$HOST_FWDIR"; then 412 + missing_fw="$missing_fw $fw" 413 + fi 414 + done 415 + 416 + if [ -n "$missing_fw" ]; then 417 + err "Missing built-in firmware blobs:$missing_fw" 418 + err "Looked under: $HOST_FWDIR (including .zst/.xz variants)" 419 + exit 1 420 + fi 421 + 422 + sed -i "s|^CONFIG_EXTRA_FIRMWARE_DIR=.*|CONFIG_EXTRA_FIRMWARE_DIR=\"$FIRMWARE_ABS\"|" "$LINUX_DIR/.config" 423 + log " Built-in firmware dir: $FIRMWARE_ABS" 424 + log " Built-in firmware files: $FW_LIST" 425 + fi 426 + 337 427 # Copy initramfs into kernel tree 338 428 cp "$BUILD/initramfs.cpio.lz4" "$LINUX_DIR/initramfs.cpio.lz4" 339 429 ··· 361 451 # Build 362 452 log " Compiling (${KERNEL_JOBS} cores)..." 363 453 KERNEL_LOG="$BUILD/kernel-build.log" 364 - if ! make -j"${KERNEL_JOBS}" KALLSYMS_EXTRA_PASS=1 bzImage >"$KERNEL_LOG" 2>&1; then 454 + if ! run_make_with_heartbeat "$KERNEL_LOG" make -j"${KERNEL_JOBS}" KALLSYMS_EXTRA_PASS=1 bzImage; then 365 455 err "Kernel compile failed while building bzImage (parallel pass)." 366 456 show_kernel_error_context "$KERNEL_LOG" 367 457 if [ "${KERNEL_JOBS}" -gt 1 ]; then 368 458 err "Retrying kernel build in serial mode (-j1, V=1) for deterministic diagnostics..." 369 459 make clean 2>/dev/null || true 370 460 KERNEL_LOG_RETRY="$BUILD/kernel-build-retry.log" 371 - if ! make -j1 V=1 KALLSYMS_EXTRA_PASS=1 bzImage >"$KERNEL_LOG_RETRY" 2>&1; then 461 + if ! run_make_with_heartbeat "$KERNEL_LOG_RETRY" make -j1 V=1 KALLSYMS_EXTRA_PASS=1 bzImage; then 372 462 err "Kernel compile failed again in serial retry." 373 463 show_kernel_error_context "$KERNEL_LOG_RETRY" 374 464 exit 1
+20 -13
system/public/bills.aesthetic.computer/index.html
··· 73 73 .header-row { 74 74 display: flex; 75 75 align-items: baseline; 76 - justify-content: space-between; 76 + justify-content: flex-start; 77 77 gap: 1em; 78 - margin-bottom: 1.5em; 78 + margin-bottom: 0.8em; 79 79 } 80 80 81 81 .header-left { ··· 192 192 background: var(--pink); 193 193 color: white; 194 194 text-decoration: none; 195 + } 196 + 197 + .needs-support { 198 + display: flex; 199 + align-items: center; 200 + gap: 0.6em; 201 + flex-wrap: wrap; 202 + margin-bottom: 1em; 195 203 } 196 204 197 205 /* Alerts */ ··· 575 583 .header-left { gap: 0.4em; } 576 584 .subtitle { font-size: 0.7em; } 577 585 .bill-tab { font-size: 0.82em; padding: 0.7em 0.5em; } 586 + .needs-support { align-items: flex-start; } 578 587 .stats { grid-template-columns: 1fr 1fr; } 579 588 .stat-value { font-size: 1.4em; } 580 589 .net-flow { grid-template-columns: 1fr; gap: 0.3em; padding: 0.8em; } ··· 603 612 <h1 id="title">bills</h1> 604 613 <div class="subtitle">aesthetic.computer — cash flow</div> 605 614 </div> 606 - <div class="sponsor-badge"> 607 - <iframe src="https://github.com/sponsors/whistlegraph/button" title="Sponsor whistlegraph" height="32" width="114" style="border: 0;"></iframe> 608 - </div> 609 615 </div> 610 616 611 - <!-- Give link --> 612 - <div style="text-align: center; margin-bottom: 1.5em;"> 613 - <a class="give-bumper" href="https://give.aesthetic.computer">give.aesthetic.computer</a> 617 + <div class="bill-tabs" role="tablist" aria-label="Bills sections"> 618 + <button class="bill-tab" type="button" data-view-target="income" role="tab" aria-selected="false">Income</button> 619 + <button class="bill-tab active" type="button" data-view-target="needs" role="tab" aria-selected="true">Needs</button> 614 620 </div> 615 621 616 622 <!-- Alerts --> ··· 660 666 661 667 <hr class="section-divider"> 662 668 663 - <div class="bill-tabs" role="tablist" aria-label="Bills sections"> 664 - <button class="bill-tab" type="button" data-view-target="income" role="tab" aria-selected="false">Income</button> 665 - <button class="bill-tab active" type="button" data-view-target="needs" role="tab" aria-selected="true">Needs</button> 669 + <section class="bill-section" data-view="needs"> 670 + <div class="needs-support"> 671 + <a class="give-bumper" href="https://give.aesthetic.computer">give.aesthetic.computer</a> 672 + <div class="sponsor-badge"> 673 + <iframe src="https://github.com/sponsors/whistlegraph/button" title="Sponsor whistlegraph" height="32" width="114" style="border: 0;"></iframe> 674 + </div> 666 675 </div> 667 - 668 - <section class="bill-section" data-view="needs"> 669 676 670 677 <!-- Actions --> 671 678 <h2>priority actions</h2>