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:
@@ -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,94 +256,95 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ═══ Search + Item feed ═══ -->
|
<!-- ═══ Search bar ═══ -->
|
||||||
<section class="brain-layout">
|
<section class="search-section">
|
||||||
<div class="brain-main">
|
<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>
|
||||||
<div class="toolbar-card">
|
<input
|
||||||
<div class="toolbar-head">
|
type="text"
|
||||||
<div>
|
class="search-input"
|
||||||
<div class="toolbar-label">Search</div>
|
placeholder="Search your brain..."
|
||||||
<div class="toolbar-title">Find saved items</div>
|
bind:value={searchQuery}
|
||||||
</div>
|
onkeydown={handleSearchKey}
|
||||||
<div class="toolbar-meta">{items.length} item{items.length !== 1 ? 's' : ''}</div>
|
/>
|
||||||
</div>
|
{#if searchQuery}
|
||||||
<div class="search-wrap">
|
<button class="search-clear" onclick={() => { searchQuery = ''; loadItems(); }}>
|
||||||
<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 viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
<input
|
</button>
|
||||||
type="text"
|
|
||||||
class="search-input"
|
|
||||||
placeholder="Search by title, content, tags..."
|
|
||||||
bind:value={searchQuery}
|
|
||||||
onkeydown={handleSearchKey}
|
|
||||||
/>
|
|
||||||
{#if searchQuery}
|
|
||||||
<button class="search-clear" onclick={() => { searchQuery = ''; loadItems(); }}>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="items-card">
|
|
||||||
{#each [1, 2, 3, 4] as _}
|
|
||||||
<div class="item-row skeleton-row" style="height: 80px"></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if items.length === 0}
|
|
||||||
<div class="items-card">
|
|
||||||
<div class="empty">No items yet. Paste a URL or note above to get started.</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="items-card">
|
|
||||||
{#each items as item (item.id)}
|
|
||||||
<button class="item-row" onclick={() => selectedItem = item}>
|
|
||||||
<div class="row-accent" class:processing={item.processing_status === 'processing'} class:failed={item.processing_status === 'failed'}></div>
|
|
||||||
<div class="item-info">
|
|
||||||
<div class="item-name">{item.title || 'Processing...'}</div>
|
|
||||||
<div class="item-meta">
|
|
||||||
<span>{formatDate(item.created_at)}</span>
|
|
||||||
{#if item.folder}<span class="meta-folder">{item.folder}</span>{/if}
|
|
||||||
{#if item.url}<span class="meta-url">{new URL(item.url).hostname}</span>{/if}
|
|
||||||
{#each (item.tags || []).slice(0, 2) as tag}
|
|
||||||
<span class="meta-tag">{tag}</span>
|
|
||||||
{/each}
|
|
||||||
</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}
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Masonry card grid ═══ -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="masonry">
|
||||||
|
{#each [1, 2, 3, 4, 5, 6] as _}
|
||||||
|
<div class="card skeleton-card"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if items.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<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>
|
||||||
|
{:else}
|
||||||
|
<div class="masonry">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<button class="card" class:is-note={item.type === 'note'} class:is-processing={item.processing_status !== 'ready'} onclick={() => selectedItem = item}>
|
||||||
|
<!-- Screenshot for links -->
|
||||||
|
{#if item.type === 'link' && item.assets?.some(a => a.asset_type === 'screenshot')}
|
||||||
|
<div class="card-thumb">
|
||||||
|
<img src="/api/brain/storage/{item.id}/screenshot/screenshot.png" alt="" loading="lazy" />
|
||||||
|
{#if item.processing_status !== 'ready'}
|
||||||
|
<div class="card-processing-overlay">
|
||||||
|
<span class="processing-dot"></span>
|
||||||
|
Processing...
|
||||||
|
</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}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
</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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user