feat: brain masonry card grid — Karakeep-style layout with Atelier aesthetics

- 3-column CSS masonry grid (2 on tablet, 1 on mobile)
- Link cards show screenshot thumbnails
- Note cards show content body inline
- Tags as pills, folder/date in meta footer
- Screenshot serving endpoint added to brain API
- Auto-polling for pending items (3s interval)
- Detail sheet shows raw_content for notes
- Warm frosted glass card styling matching Atelier design

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 17:42:59 -05:00
parent 477188f542
commit 6565b23deb
3 changed files with 323 additions and 148 deletions

View File

@@ -1,11 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { onDestroy } from 'svelte';
interface BrainItem { interface BrainItem {
id: string; id: string;
type: string; type: string;
title: string | null; title: string | null;
url: string | null; url: string | null;
raw_content: string | null;
extracted_text: string | null;
folder: string | null; folder: string | null;
tags: string[] | null; tags: string[] | null;
summary: string | null; summary: string | null;
@@ -159,10 +163,42 @@
if (e.key === 'Enter') search(); if (e.key === 'Enter') search();
} }
// Poll for pending items
let pollTimer: ReturnType<typeof setInterval> | null = null;
function startPolling() {
stopPolling();
pollTimer = setInterval(async () => {
const hasPending = items.some(i => i.processing_status === 'pending' || i.processing_status === 'processing');
if (hasPending) {
await loadItems();
// Update selected item if it was pending
if (selectedItem) {
const updated = items.find(i => i.id === selectedItem!.id);
if (updated) selectedItem = updated;
}
} else {
stopPolling();
}
}, 3000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
// Start polling after capture
$effect(() => {
const hasPending = items.some(i => i.processing_status === 'pending' || i.processing_status === 'processing');
if (hasPending && !pollTimer) startPolling();
});
onMount(async () => { onMount(async () => {
await loadConfig(); await loadConfig();
await loadItems(); await loadItems();
}); });
onDestroy(stopPolling);
</script> </script>
<div class="page"> <div class="page">
@@ -220,24 +256,14 @@
{/each} {/each}
</section> </section>
<!-- ═══ Search + Item feed ═══ --> <!-- ═══ Search bar ═══ -->
<section class="brain-layout"> <section class="search-section">
<div class="brain-main">
<div class="toolbar-card">
<div class="toolbar-head">
<div>
<div class="toolbar-label">Search</div>
<div class="toolbar-title">Find saved items</div>
</div>
<div class="toolbar-meta">{items.length} item{items.length !== 1 ? 's' : ''}</div>
</div>
<div class="search-wrap"> <div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg> <svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input <input
type="text" type="text"
class="search-input" class="search-input"
placeholder="Search by title, content, tags..." placeholder="Search your brain..."
bind:value={searchQuery} bind:value={searchQuery}
onkeydown={handleSearchKey} onkeydown={handleSearchKey}
/> />
@@ -247,66 +273,77 @@
</button> </button>
{/if} {/if}
</div> </div>
</div> </section>
<div class="queue-header">
<div>
<div class="queue-label">Feed</div>
<div class="queue-title">{activeFolder || 'All items'}</div>
<div class="queue-summary">
{#if activeFolder}
Items the AI classified under {activeFolder.toLowerCase()}.
{:else}
Your complete saved collection, newest first.
{/if}
</div>
</div>
</div>
<!-- ═══ Masonry card grid ═══ -->
{#if loading} {#if loading}
<div class="items-card"> <div class="masonry">
{#each [1, 2, 3, 4] as _} {#each [1, 2, 3, 4, 5, 6] as _}
<div class="item-row skeleton-row" style="height: 80px"></div> <div class="card skeleton-card"></div>
{/each} {/each}
</div> </div>
{:else if items.length === 0} {:else if items.length === 0}
<div class="items-card"> <div class="empty-state">
<div class="empty">No items yet. Paste a URL or note above to get started.</div> <div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
</div>
<div class="empty-title">Nothing saved yet</div>
<div class="empty-desc">Paste a URL or type a note in the capture bar above.</div>
</div> </div>
{:else} {:else}
<div class="items-card"> <div class="masonry">
{#each items as item (item.id)} {#each items as item (item.id)}
<button class="item-row" onclick={() => selectedItem = item}> <button class="card" class:is-note={item.type === 'note'} class:is-processing={item.processing_status !== 'ready'} onclick={() => selectedItem = item}>
<div class="row-accent" class:processing={item.processing_status === 'processing'} class:failed={item.processing_status === 'failed'}></div> <!-- Screenshot for links -->
<div class="item-info"> {#if item.type === 'link' && item.assets?.some(a => a.asset_type === 'screenshot')}
<div class="item-name">{item.title || 'Processing...'}</div> <div class="card-thumb">
<div class="item-meta"> <img src="/api/brain/storage/{item.id}/screenshot/screenshot.png" alt="" loading="lazy" />
<span>{formatDate(item.created_at)}</span> {#if item.processing_status !== 'ready'}
{#if item.folder}<span class="meta-folder">{item.folder}</span>{/if} <div class="card-processing-overlay">
{#if item.url}<span class="meta-url">{new URL(item.url).hostname}</span>{/if} <span class="processing-dot"></span>
{#each (item.tags || []).slice(0, 2) as tag} Processing...
<span class="meta-tag">{tag}</span> </div>
{/if}
</div>
{:else if item.type === 'note'}
<!-- Note shows content directly -->
<div class="card-note-body">
{(item.raw_content || '').slice(0, 200)}{(item.raw_content || '').length > 200 ? '...' : ''}
</div>
{:else if item.processing_status !== 'ready'}
<div class="card-placeholder">
<span class="processing-dot"></span>
<span>Processing...</span>
</div>
{/if}
<!-- Card content -->
<div class="card-content">
<div class="card-title">{item.title || 'Untitled'}</div>
{#if item.url}
<div class="card-domain">{(() => { try { return new URL(item.url).hostname; } catch { return ''; } })()}</div>
{/if}
{#if item.summary && item.type !== 'note'}
<div class="card-summary">{item.summary.slice(0, 100)}{item.summary.length > 100 ? '...' : ''}</div>
{/if}
<div class="card-footer">
{#if item.tags && item.tags.length > 0}
<div class="card-tags">
{#each item.tags.slice(0, 3) as tag}
<span class="card-tag">{tag}</span>
{/each} {/each}
</div> </div>
</div>
<div class="item-tail">
{#if item.processing_status === 'pending' || item.processing_status === 'processing'}
<span class="status-badge processing-badge">Processing</span>
{:else if item.processing_status === 'failed'}
<span class="status-badge error">Failed</span>
{:else if item.confidence && item.confidence > 0.8}
<span class="conf-dot high"></span>
{:else if item.confidence}
<span class="conf-dot med"></span>
{/if} {/if}
<svg class="row-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg> <div class="card-meta">
{#if item.folder}<span class="card-folder">{item.folder}</span>{/if}
<span class="card-date">{formatDate(item.created_at)}</span>
</div>
</div>
</div> </div>
</button> </button>
{/each} {/each}
</div> </div>
{/if} {/if}
</div>
</section>
</div> </div>
</div> </div>
@@ -330,8 +367,18 @@
<a class="detail-url" href={selectedItem.url} target="_blank" rel="noopener">{selectedItem.url}</a> <a class="detail-url" href={selectedItem.url} target="_blank" rel="noopener">{selectedItem.url}</a>
{/if} {/if}
{#if selectedItem.raw_content}
<div class="detail-content">
<div class="content-label">Note</div>
<div class="content-body">{selectedItem.raw_content}</div>
</div>
{/if}
{#if selectedItem.summary} {#if selectedItem.summary}
<div class="detail-summary">{selectedItem.summary}</div> <div class="detail-summary">
<div class="content-label">AI Summary</div>
{selectedItem.summary}
</div>
{/if} {/if}
<div class="detail-meta-grid"> <div class="detail-meta-grid">
@@ -440,80 +487,170 @@
.signal-value { font-size: clamp(1.6rem, 3vw, 2.2rem); line-height: 0.95; letter-spacing: -0.05em; color: #1e1812; } .signal-value { font-size: clamp(1.6rem, 3vw, 2.2rem); line-height: 0.95; letter-spacing: -0.05em; color: #1e1812; }
.signal-note { color: #4f463d; line-height: 1.6; font-size: 0.85rem; } .signal-note { color: #4f463d; line-height: 1.6; font-size: 0.85rem; }
/* ═══ Layout ═══ */ /* ═══ Search ═══ */
.brain-layout { display: grid; gap: 18px; } .search-section { margin-bottom: 18px; }
.brain-main {
border-radius: 28px; border: 1px solid rgba(35,26,17,0.08);
background: linear-gradient(180deg, rgba(255,252,248,0.84), rgba(244,237,229,0.74));
padding: 16px;
}
/* ═══ Toolbar ═══ */
.toolbar-card {
border-radius: 28px; border: 1px solid rgba(35,26,17,0.08);
background: rgba(255,255,255,0.42); padding: 16px 16px 12px; margin-bottom: 8px;
}
.toolbar-head { display: flex; align-items: end; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.toolbar-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: #7d6f61; }
.toolbar-title { margin-top: 4px; font-size: 1.1rem; letter-spacing: -0.035em; color: #1e1812; }
.toolbar-meta { color: #4f463d; font-size: 0.88rem; }
.search-wrap { position: relative; } .search-wrap { position: relative; }
.search-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: #7f7365; pointer-events: none; } .search-icon { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: #7f7365; pointer-events: none; }
.search-input { .search-input {
width: 100%; padding: 12px 40px 12px 42px; width: 100%; padding: 14px 40px 14px 46px;
border-radius: var(--radius); border: 1.5px solid rgba(35,26,17,0.12); border-radius: 28px; border: 1px solid rgba(35,26,17,0.08);
background: rgba(255,255,255,0.92); color: #1e1812; background: rgba(255,252,248,0.68); backdrop-filter: blur(14px);
font-size: var(--text-md); font-family: var(--font); color: #1e1812; font-size: 1rem; font-family: var(--font);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.5);
} }
.search-input::placeholder { color: #8b7b6a; } .search-input::placeholder { color: #8b7b6a; }
.search-input:focus { outline: none; border-color: rgba(179,92,50,0.5); box-shadow: 0 0 0 4px rgba(179,92,50,0.08); } .search-input:focus { outline: none; border-color: rgba(179,92,50,0.4); box-shadow: 0 0 0 4px rgba(179,92,50,0.06); background: rgba(255,255,255,0.9); }
.search-clear { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; color: #7f7365; } .search-clear { position: absolute; right: 14px; top: 50%; transform: translateY(-50%); background: none; border: none; color: #7f7365; }
.search-clear svg { width: 16px; height: 16px; } .search-clear svg { width: 16px; height: 16px; }
/* ═══ Queue ═══ */ /* ═══ Masonry grid ═══ */
.queue-header { display: flex; align-items: end; justify-content: space-between; gap: 14px; padding: 8px 4px 14px; } .masonry {
.queue-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; color: #7d6f61; } columns: 3;
.queue-title { margin-top: 4px; font-size: 1.35rem; letter-spacing: -0.04em; color: #1e1812; } column-gap: 14px;
.queue-summary { margin-top: 6px; color: #6a5d50; font-size: 0.94rem; line-height: 1.45; }
/* ═══ Items ═══ */
.items-card {
background: rgba(255,255,255,0.82); border-radius: 24px;
border: 1px solid rgba(35,26,17,0.06); overflow: hidden;
box-shadow: 0 16px 44px rgba(42,30,19,0.05);
} }
.item-row {
display: grid; grid-template-columns: 4px minmax(0, 1fr) auto; .card {
align-items: center; gap: 16px; padding: 18px 16px; break-inside: avoid;
width: 100%; background: none; border: none; text-align: left; display: flex;
transition: background 160ms ease, transform 160ms ease; flex-direction: column;
border-radius: 20px;
border: 1px solid rgba(35,26,17,0.08);
background: rgba(255,252,248,0.82);
backdrop-filter: blur(10px);
overflow: hidden;
margin-bottom: 14px;
width: 100%;
text-align: left;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
} }
.item-row:hover { background: rgba(255,248,242,0.88); transform: translateX(2px); } .card:hover {
.item-row + .item-row { border-top: 1px solid rgba(35,26,17,0.07); } transform: translateY(-3px);
box-shadow: 0 12px 32px rgba(42,30,19,0.08);
border-color: rgba(35,26,17,0.14);
}
.card:active { transform: scale(0.985); }
.row-accent { align-self: stretch; border-radius: 999px; background: rgba(35,26,17,0.08); } .card.is-processing { opacity: 0.7; }
.row-accent.processing { background: linear-gradient(180deg, #f6a33a, #e28615); }
.row-accent.failed { background: linear-gradient(180deg, #ff5d45, #ef3d2f); }
.item-info { flex: 1; min-width: 0; } /* Card thumbnail */
.item-name { font-size: 1rem; font-weight: 700; color: #1e1812; line-height: 1.25; } .card-thumb {
.item-meta { display: flex; flex-wrap: wrap; gap: 8px 12px; margin-top: 6px; color: #5d5248; font-size: 0.88rem; } width: 100%;
.meta-folder { font-weight: 600; color: #8c7b69; } position: relative;
.meta-url { color: #7d6f61; } overflow: hidden;
.meta-tag { background: rgba(35,26,17,0.06); padding: 1px 8px; border-radius: 999px; font-size: 0.8rem; color: #5d5248; } background: rgba(244,237,229,0.6);
}
.card-thumb img {
width: 100%;
height: auto;
display: block;
object-fit: cover;
max-height: 240px;
}
.card-processing-overlay {
position: absolute; inset: 0;
background: rgba(30,24,18,0.6);
display: flex; align-items: center; justify-content: center; gap: 6px;
color: white; font-size: 0.85rem; font-weight: 600;
}
.processing-dot {
width: 6px; height: 6px; border-radius: 50%; background: #f6a33a;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.item-tail { display: flex; align-items: center; gap: 12px; margin-left: auto; } /* Note card body */
.conf-dot { width: 8px; height: 8px; border-radius: 50%; } .card-note-body {
.conf-dot.high { background: #059669; } padding: 18px 18px 0;
.conf-dot.med { background: #D97706; } font-size: 0.92rem;
.status-badge { font-size: 11px; font-weight: 700; padding: 5px 10px; border-radius: 999px; flex-shrink: 0; } color: #3d342c;
.processing-badge { background: rgba(217,119,6,0.12); color: #9a5d09; } line-height: 1.65;
.status-badge.error { background: var(--error-dim); color: var(--error); } white-space: pre-wrap;
.row-chevron { width: 14px; height: 14px; color: #7d6f61; flex-shrink: 0; opacity: 0.7; } word-break: break-word;
}
.empty { padding: 48px; text-align: center; color: #5f564b; font-size: 1rem; } .card-placeholder {
padding: 32px;
display: flex; align-items: center; justify-content: center; gap: 6px;
color: #8c7b69; font-size: 0.85rem;
background: rgba(244,237,229,0.4);
}
/* Card content */
.card-content {
padding: 14px 18px 16px;
}
.card-title {
font-size: 0.95rem;
font-weight: 700;
color: #1e1812;
line-height: 1.3;
margin-bottom: 4px;
}
.card-domain {
font-size: 0.8rem;
color: #8c7b69;
margin-bottom: 6px;
}
.card-summary {
font-size: 0.82rem;
color: #5c5046;
line-height: 1.5;
margin-bottom: 8px;
}
.card-footer {
display: flex;
flex-direction: column;
gap: 6px;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.card-tag {
background: rgba(35,26,17,0.06);
padding: 2px 8px;
border-radius: 999px;
font-size: 0.72rem;
color: #5d5248;
font-weight: 500;
}
.card-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
color: #8c7b69;
}
.card-folder {
font-weight: 600;
}
/* Skeleton cards */
.skeleton-card {
height: 200px;
background: linear-gradient(90deg, rgba(244,237,229,0.5) 25%, rgba(255,252,248,0.8) 50%, rgba(244,237,229,0.5) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
margin-bottom: 14px;
break-inside: avoid;
}
/* Empty state */
.empty-state {
padding: 64px 32px;
text-align: center;
}
.empty-icon { color: #8c7b69; margin-bottom: 16px; }
.empty-title { font-size: 1.2rem; font-weight: 700; color: #1e1812; margin-bottom: 6px; }
.empty-desc { font-size: 0.95rem; color: #6a5d50; }
/* ═══ Detail sheet ═══ */ /* ═══ Detail sheet ═══ */
.detail-overlay { .detail-overlay {
@@ -545,8 +682,20 @@
} }
.detail-url:hover { color: #1e1812; text-decoration: underline; } .detail-url:hover { color: #1e1812; text-decoration: underline; }
.detail-content {
margin-bottom: 20px; padding-bottom: 20px;
border-bottom: 1px solid rgba(35,26,17,0.08);
}
.content-label {
font-size: 11px; text-transform: uppercase;
letter-spacing: 0.12em; color: #7d6f61; margin-bottom: 8px;
}
.content-body {
font-size: 1rem; color: #1e1812; line-height: 1.7;
white-space: pre-wrap; word-break: break-word;
}
.detail-summary { .detail-summary {
font-size: 1rem; color: #3d342c; line-height: 1.6; font-size: 0.95rem; color: #3d342c; line-height: 1.6;
margin-bottom: 20px; padding-bottom: 20px; margin-bottom: 20px; padding-bottom: 20px;
border-bottom: 1px solid rgba(35,26,17,0.08); border-bottom: 1px solid rgba(35,26,17,0.08);
} }
@@ -591,10 +740,14 @@
@keyframes sheetIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } } @keyframes sheetIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
/* ═══ Mobile ═══ */ /* ═══ Mobile ═══ */
@media (max-width: 1100px) {
.masonry { columns: 2; }
}
@media (max-width: 768px) { @media (max-width: 768px) {
.brain-command { display: grid; gap: 14px; } .brain-command { display: grid; gap: 14px; }
.command-actions { justify-items: start; } .command-actions { justify-items: start; }
.signal-strip { grid-template-columns: 1fr 1fr; } .signal-strip { grid-template-columns: 1fr 1fr; }
.masonry { columns: 1; }
.detail-sheet { width: 100%; padding: 20px; } .detail-sheet { width: 100%; padding: 20px; }
.detail-meta-grid { grid-template-columns: 1fr; } .detail-meta-grid { grid-template-columns: 1fr; }
} }

View File

@@ -19,6 +19,7 @@ from app.models.schema import (
HybridSearchQuery, SearchResult, ConfigOut, HybridSearchQuery, SearchResult, ConfigOut,
) )
from app.services.storage import storage from app.services.storage import storage
from fastapi.responses import Response
from app.worker.tasks import enqueue_process_item from app.worker.tasks import enqueue_process_item
router = APIRouter(prefix="/api", tags=["brain"]) router = APIRouter(prefix="/api", tags=["brain"])
@@ -317,3 +318,22 @@ async def hybrid_search(
folder=body.folder, tags=body.tags, item_type=body.type, limit=body.limit, folder=body.folder, tags=body.tags, item_type=body.type, limit=body.limit,
) )
return SearchResult(items=items, total=len(items), query=body.q) return SearchResult(items=items, total=len(items), query=body.q)
# ── Serve stored files (screenshots, archived HTML) ──
@router.get("/storage/{item_id}/{asset_type}/{filename}")
async def serve_asset(item_id: str, asset_type: str, filename: str):
"""Serve a stored asset file."""
path = f"{item_id}/{asset_type}/{filename}"
if not storage.exists(path):
raise HTTPException(status_code=404, detail="Asset not found")
data = storage.read(path)
ct = "application/octet-stream"
if filename.endswith(".png"): ct = "image/png"
elif filename.endswith(".jpg") or filename.endswith(".jpeg"): ct = "image/jpeg"
elif filename.endswith(".html"): ct = "text/html"
elif filename.endswith(".pdf"): ct = "application/pdf"
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=3600"})

View File

@@ -68,6 +68,8 @@ class ItemOut(BaseModel):
type: str type: str
title: Optional[str] = None title: Optional[str] = None
url: Optional[str] = None url: Optional[str] = None
raw_content: Optional[str] = None
extracted_text: Optional[str] = None
folder: Optional[str] = None folder: Optional[str] = None
tags: Optional[list[str]] = None tags: Optional[list[str]] = None
summary: Optional[str] = None summary: Optional[str] = None