Files
platform/frontend-v2/src/lib/pages/brain/AtelierBrainPage.svelte
Yusuf Suleman af1765bd8e fix: mobile nav spacing + safe area, PDF viewer mobile layout
- Mobile nav: tighter spacing, safe-area-inset-bottom padding
- Nav items closer together so bottom section stays visible
- Nav sheet scrollable if content overflows
- PDF viewer mobile: 55vh for PDF, rest for sidebar (scrollable)
- Full screen on mobile (no border-radius, no padding)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:27:16 -05:00

1393 lines
52 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy } from 'svelte';
import PdfInlinePreview from '$lib/components/trips/PdfInlinePreview.svelte';
interface BrainItem {
id: string;
type: string;
title: string | null;
url: string | null;
raw_content: string | null;
extracted_text: string | null;
folder: string | null;
tags: string[] | null;
summary: string | null;
confidence: number | null;
processing_status: string;
processing_error: string | null;
metadata_json: any;
created_at: string;
updated_at: string;
assets: { id: string; asset_type: string; filename: string; content_type: string | null }[];
}
interface SidebarFolder { id: string; name: string; slug: string; color?: string; icon?: string; is_active: boolean; item_count: number; }
interface SidebarTag { id: string; name: string; slug: string; color?: string; icon?: string; is_active: boolean; item_count: number; }
let loading = $state(true);
let items = $state<BrainItem[]>([]);
let total = $state(0);
let activeFolder = $state<string | null>(null);
let activeFolderId = $state<string | null>(null);
let activeTag = $state<string | null>(null);
let activeTagId = $state<string | null>(null);
let searchQuery = $state('');
let searching = $state(false);
// Sidebar
let sidebarFolders = $state<SidebarFolder[]>([]);
let sidebarTags = $state<SidebarTag[]>([]);
let sidebarView = $state<'folders' | 'tags'>('folders');
let mobileSidebarOpen = $state(false);
let showManage = $state(false);
let newTaxName = $state('');
// Capture
let captureInput = $state('');
let capturing = $state(false);
let uploading = $state(false);
let fileInput: HTMLInputElement;
// Detail
let selectedItem = $state<BrainItem | null>(null);
let editingNote = $state(false);
let editNoteContent = $state('');
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/brain${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
async function loadSidebar() {
try {
const data = await api('/taxonomy/sidebar');
sidebarFolders = data.folders || [];
sidebarTags = data.tags || [];
total = data.total_items || 0;
} catch { /* silent */ }
}
async function loadItems() {
loading = true;
try {
const params = new URLSearchParams({ limit: '50' });
if (activeFolder) params.set('folder', activeFolder);
if (activeTag) params.set('tag', activeTag);
const data = await api(`/items?${params}`);
items = data.items || [];
total = data.total || 0;
} catch { /* silent */ }
loading = false;
}
async function addTaxonomy() {
if (!newTaxName.trim()) return;
try {
const endpoint = sidebarView === 'folders' ? '/taxonomy/folders' : '/taxonomy/tags';
await api(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newTaxName.trim() }),
});
newTaxName = '';
await loadSidebar();
} catch { /* silent */ }
}
async function deleteTaxonomy(id: string) {
try {
const endpoint = sidebarView === 'folders' ? `/taxonomy/folders/${id}` : `/taxonomy/tags/${id}`;
await api(endpoint, { method: 'DELETE' });
await loadSidebar();
await loadItems();
} catch { /* silent */ }
}
function selectFolder(folder: SidebarFolder | null) {
if (folder) {
activeFolder = folder.name;
activeFolderId = folder.id;
} else {
activeFolder = null;
activeFolderId = null;
}
activeTag = null;
activeTagId = null;
loadItems();
}
function selectTag(tag: SidebarTag | null) {
if (tag) {
activeTag = tag.name;
activeTagId = tag.id;
} else {
activeTag = null;
activeTagId = null;
}
activeFolder = null;
activeFolderId = null;
loadItems();
}
async function capture() {
if (!captureInput.trim()) return;
capturing = true;
try {
const isUrl = captureInput.trim().match(/^https?:\/\//);
const body: any = isUrl
? { type: 'link', url: captureInput.trim() }
: { type: 'note', raw_content: captureInput.trim() };
await api('/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
captureInput = '';
await loadItems();
} catch { /* silent */ }
capturing = false;
}
async function search() {
if (!searchQuery.trim()) {
await loadItems();
return;
}
searching = true;
try {
const data = await api('/search/hybrid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q: searchQuery, folder: activeFolder, limit: 30 }),
});
items = data.items || [];
total = data.total || 0;
} catch { /* silent */ }
searching = false;
}
async function uploadFile(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length) return;
uploading = true;
try {
for (const file of input.files) {
const formData = new FormData();
formData.append('file', file);
await fetch('/api/brain/items/upload', {
method: 'POST',
credentials: 'include',
body: formData,
});
}
input.value = '';
await loadItems();
} catch { /* silent */ }
uploading = false;
}
function startEditNote() {
if (!selectedItem) return;
editNoteContent = selectedItem.raw_content || '';
editingNote = true;
}
async function saveNote() {
if (!selectedItem) return;
try {
await api(`/items/${selectedItem.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ raw_content: editNoteContent }),
});
selectedItem.raw_content = editNoteContent;
editingNote = false;
await loadItems();
} catch { /* silent */ }
}
async function deleteItem(id: string) {
try {
await api(`/items/${id}`, { method: 'DELETE' });
selectedItem = null;
await loadItems();
} catch { /* silent */ }
}
async function reprocessItem(id: string) {
try {
await api(`/items/${id}/reprocess`, { method: 'POST' });
await loadItems();
} catch { /* silent */ }
}
function formatDate(d: string): string {
try {
const date = new Date(d);
const now = new Date();
const diff = Math.round((now.getTime() - date.getTime()) / 86400000);
if (diff === 0) return 'Today';
if (diff === 1) return 'Yesterday';
if (diff < 7) return `${diff}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
} catch { return ''; }
}
function getScreenshot(item: BrainItem): string | null {
const asset = item.assets?.find(a => a.asset_type === 'screenshot');
return asset ? `/api/brain/storage/${item.id}/screenshot/${asset.filename}` : null;
}
function typeIcon(type: string): string {
switch (type) {
case 'link': return '🔗';
case 'note': return '📝';
case 'pdf': return '📄';
case 'image': return '🖼';
default: return '📎';
}
}
function handleCaptureKey(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); capture(); }
}
let searchTimer: ReturnType<typeof setTimeout>;
function handleSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
search();
}, 300);
}
// Poll for pending items
let pollTimer: ReturnType<typeof setInterval> | null = null;
function startPolling() {
stopPolling();
pollTimer = setInterval(async () => {
const pendingIds = items
.filter(i => i.processing_status === 'pending' || i.processing_status === 'processing')
.map(i => i.id);
if (pendingIds.length === 0) {
stopPolling();
return;
}
// Poll each pending item individually instead of reloading everything
for (const id of pendingIds) {
try {
const updated = await api(`/items/${id}`);
const idx = items.findIndex(i => i.id === id);
if (idx !== -1) {
items[idx] = updated;
items = items; // trigger reactivity
}
if (selectedItem?.id === id) {
selectedItem = updated;
}
} catch { /* item might be gone */ }
}
}, 4000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
// Start polling when pending items exist
$effect(() => {
const hasPending = items.some(i => i.processing_status === 'pending' || i.processing_status === 'processing');
if (hasPending && !pollTimer) startPolling();
});
onMount(async () => {
// Read filters from URL params (set by AppShell sidebar links)
const urlFolder = new URL(window.location.href).searchParams.get('folder');
const urlTag = new URL(window.location.href).searchParams.get('tag');
if (urlFolder) {
activeFolder = urlFolder;
}
if (urlTag) {
activeTag = urlTag;
}
await loadSidebar();
await loadItems();
});
onDestroy(stopPolling);
</script>
<div class="brain-layout">
<!-- Second sidebar (like Reader) -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#if mobileSidebarOpen}
<div class="mobile-sidebar-overlay" onclick={() => mobileSidebarOpen = false}></div>
{/if}
<aside class="brain-sidebar" class:mobile-open={mobileSidebarOpen}>
<div class="sidebar-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><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>
<span class="sidebar-title">Brain</span>
<button class="sidebar-settings" onclick={() => showManage = !showManage} title={showManage ? 'Done editing' : 'Manage'}>
{#if showManage}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
{/if}
</button>
</div>
<nav class="sidebar-nav">
<button class="nav-item" class:active={!activeFolder && !activeTag} onclick={() => { activeFolder = null; activeTag = null; activeFolderId = null; activeTagId = null; mobileSidebarOpen = false; loadItems(); }}>
<span class="nav-label">All items</span>
<span class="nav-count">{total}</span>
</button>
</nav>
<!-- Folders -->
<div class="sidebar-separator"></div>
<div class="sidebar-section-head">
<div class="sidebar-section-label">Folders</div>
</div>
{#if showManage}
<div class="sidebar-add-row">
<input class="sidebar-add-input" placeholder="New folder..." bind:value={newTaxName} onkeydown={(e) => { if (e.key === 'Enter') { sidebarView = 'folders'; addTaxonomy(); } }} />
<button class="sidebar-add-go" onclick={() => { sidebarView = 'folders'; addTaxonomy(); }}>+</button>
</div>
{/if}
<nav class="sidebar-nav">
{#each sidebarFolders.filter(f => f.is_active) as folder}
<button class="nav-item" class:active={activeFolder === folder.name} onclick={() => { activeFolder = folder.name; activeFolderId = folder.id; activeTag = null; activeTagId = null; mobileSidebarOpen = false; loadItems(); }}>
{#if folder.color}<span class="nav-dot" style="background: {folder.color}"></span>{/if}
<span class="nav-label">{folder.name}</span>
{#if showManage}
<!-- svelte-ignore a11y_no_static_element_interactions --><span class="nav-delete" onclick={(e) => { e.stopPropagation(); if (confirm(`Delete "${folder.name}"? Items will be moved.`)) deleteTaxonomy(folder.id); }}>×</span>
{:else if folder.item_count > 0}
<span class="nav-count">{folder.item_count}</span>
{/if}
</button>
{/each}
</nav>
<!-- Tags -->
<div class="sidebar-separator"></div>
<div class="sidebar-section-head">
<div class="sidebar-section-label">Tags</div>
</div>
{#if showManage}
<div class="sidebar-add-row">
<input class="sidebar-add-input" placeholder="New tag..." bind:value={newTaxName} onkeydown={(e) => { if (e.key === 'Enter') { sidebarView = 'tags'; addTaxonomy(); } }} />
<button class="sidebar-add-go" onclick={() => { sidebarView = 'tags'; addTaxonomy(); }}>+</button>
</div>
<nav class="sidebar-nav">
{#each sidebarTags.filter(t => t.is_active) as tag}
<button class="nav-item" class:active={activeTag === tag.name} onclick={() => { activeTag = tag.name; activeTagId = tag.id; activeFolder = null; activeFolderId = null; mobileSidebarOpen = false; loadItems(); }}>
<span class="nav-label">{tag.name}</span>
<!-- svelte-ignore a11y_no_static_element_interactions --><span class="nav-delete" onclick={(e) => { e.stopPropagation(); if (confirm(`Delete tag "${tag.name}"?`)) deleteTaxonomy(tag.id); }}>×</span>
</button>
{/each}
</nav>
{:else}
<nav class="sidebar-nav">
{#each sidebarTags.filter(t => t.is_active && t.item_count > 0) as tag}
<button class="nav-item" class:active={activeTag === tag.name} onclick={() => { activeTag = tag.name; activeTagId = tag.id; activeFolder = null; activeFolderId = null; mobileSidebarOpen = false; loadItems(); }}>
<span class="nav-label">{tag.name}</span>
<span class="nav-count">{tag.item_count}</span>
</button>
{/each}
</nav>
{/if}
</aside>
<!-- Main content -->
<div class="brain-content">
<!-- Hero -->
<section class="brain-command reveal">
<div class="command-copy">
<div class="command-label">Second brain</div>
<h1>Brain</h1>
<p>Save anything. Links, notes, files. AI classifies everything automatically.</p>
</div>
<div class="command-actions">
<div class="command-kicker">Collection</div>
<div class="command-stats">{total} saved</div>
</div>
</section>
<!-- Capture bar -->
<section class="capture-card reveal">
<div class="capture-wrap">
<svg class="capture-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<input class="capture-input" placeholder="Paste a URL or type a note to save..." bind:value={captureInput} onkeydown={handleCaptureKey} disabled={capturing} />
<button class="upload-btn" onclick={() => fileInput?.click()} disabled={uploading} title="Upload file">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</button>
{#if captureInput.trim()}
<button class="capture-btn" onclick={capture} disabled={capturing}>{capturing ? 'Saving...' : 'Save'}</button>
{/if}
</div>
<input bind:this={fileInput} type="file" class="hidden-input" accept=".pdf,.png,.jpg,.jpeg,.gif,.webp,.txt,.md,.csv" onchange={uploadFile} multiple />
{#if uploading}<div class="upload-status">Uploading...</div>{/if}
</section>
<!-- Mobile: horizontal pill filters -->
<div class="mobile-pills">
<button class="pill" class:active={!activeFolder && !activeTag} onclick={() => { activeFolder = null; activeTag = null; activeFolderId = null; activeTagId = null; loadItems(); }}>
All
</button>
{#each sidebarFolders.filter(f => f.is_active) as folder}
<button class="pill" class:active={activeFolder === folder.name} onclick={() => { activeFolder = folder.name; activeFolderId = folder.id; activeTag = null; activeTagId = null; loadItems(); }}
style={folder.color ? `--pill-color: ${folder.color}` : ''}>
{#if folder.color}<span class="pill-dot" style="background: {folder.color}"></span>{/if}
{folder.name}
{#if folder.item_count > 0}<span class="pill-count">{folder.item_count}</span>{/if}
</button>
{/each}
<span class="pill-separator"></span>
{#each sidebarTags.filter(t => t.is_active) as tag}
<button class="pill pill-tag" class:active={activeTag === tag.name} onclick={() => { activeTag = tag.name; activeTagId = tag.id; activeFolder = null; activeFolderId = null; loadItems(); }}>
#{tag.name}
</button>
{/each}
</div>
<!-- Active filter indicator -->
{#if activeFolder || activeTag}
<div class="active-filter">
<span class="filter-label">Filtered by {activeFolder ? 'folder' : 'tag'}:</span>
<span class="filter-tag">{activeFolder || activeTag}</span>
<button class="filter-clear" onclick={() => { activeFolder = null; activeTag = null; activeFolderId = null; activeTagId = null; mobileSidebarOpen = false; loadItems(); }}>
<svg width="14" height="14" 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>
Clear
</button>
</div>
{/if}
<!-- Search -->
<div class="search-section">
<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>
<input type="text" class="search-input" placeholder="Search your brain..." bind:value={searchQuery} oninput={handleSearchInput} />
{#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>
<!-- 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)}
<div class="card" class:is-note={item.type === 'note'} class:is-processing={item.processing_status !== 'ready'}>
<!-- Thumbnail: screenshot for links/PDFs, original image for images -->
{#if item.type === 'link' && item.assets?.some(a => a.asset_type === 'screenshot')}
<a class="card-thumb" href={item.url} target="_blank" rel="noopener">
<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}
</a>
{:else if item.type === 'pdf' && item.assets?.some(a => a.asset_type === 'screenshot')}
<button class="card-thumb" onclick={() => { selectedItem = item; editingNote = false; }}>
<img src="/api/brain/storage/{item.id}/screenshot/screenshot.png" alt="" loading="lazy" />
<div class="card-type-badge">PDF</div>
</button>
{:else if item.type === 'image' && item.assets?.some(a => a.asset_type === 'original_upload')}
{@const imgAsset = item.assets.find(a => a.asset_type === 'original_upload')}
<button class="card-thumb" onclick={() => { selectedItem = item; editingNote = false; }}>
<img src="/api/brain/storage/{item.id}/original_upload/{imgAsset?.filename}" alt="" loading="lazy" />
</button>
{:else if item.type === 'note'}
<button class="card-note-body" onclick={() => { selectedItem = item; editingNote = false; }}>
{(item.raw_content || '').slice(0, 200)}{(item.raw_content || '').length > 200 ? '...' : ''}
</button>
{:else if item.processing_status !== 'ready'}
<div class="card-placeholder">
<span class="processing-dot"></span>
<span>Processing...</span>
</div>
{/if}
<!-- Card content — click opens detail -->
<button class="card-content" onclick={() => { selectedItem = item; editingNote = false; }}>
<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>
{:else if item.type === 'pdf'}
<div class="card-domain">PDF document{item.metadata_json?.page_count ? ` · ${item.metadata_json.page_count} page${item.metadata_json.page_count !== 1 ? 's' : ''}` : ''}</div>
{:else if item.type === 'image'}
<div class="card-domain">Image</div>
{/if}
{#if item.type === 'pdf' && item.extracted_text}
<div class="card-summary">{item.extracted_text.slice(0, 120)}{item.extracted_text.length > 120 ? '...' : ''}</div>
{:else 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}
<button class="card-tag" onclick={(e) => { e.stopPropagation(); activeTag = tag; activeFolder = null; loadItems(); }}>{tag}</button>
{/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>
</button>
</div>
{/each}
</div>
{/if}
</div><!-- .brain-content -->
</div><!-- .brain-layout -->
<!-- ═══ PDF/Image full-screen viewer ═══ -->
{#if selectedItem && (selectedItem.type === 'pdf' || selectedItem.type === 'image')}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="viewer-overlay" onclick={(e) => { if (e.target === e.currentTarget) selectedItem = null; }} onkeydown={(e) => { if (e.key === 'Escape') selectedItem = null; }}>
<div class="viewer-layout">
<!-- Main viewer area -->
<div class="viewer-main">
{#if selectedItem.type === 'pdf'}
{@const pdfAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
{#if pdfAsset}
<iframe
src="/api/brain/storage/{selectedItem.id}/original_upload/{pdfAsset.filename}#zoom=page-fit"
title={selectedItem.title || 'PDF'}
class="viewer-iframe"
></iframe>
{/if}
{:else if selectedItem.type === 'image'}
{@const imgAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
{#if imgAsset}
<div class="viewer-image-wrap">
<img src="/api/brain/storage/{selectedItem.id}/original_upload/{imgAsset.filename}" alt={selectedItem.title || ''} class="viewer-image" />
</div>
{/if}
{/if}
</div>
<!-- Sidebar with details -->
<div class="viewer-sidebar">
<div class="viewer-sidebar-header">
<div>
<div class="detail-type">{selectedItem.type === 'pdf' ? 'PDF Document' : 'Image'}</div>
<h2 class="detail-title">{selectedItem.title || 'Untitled'}</h2>
</div>
<button class="close-btn" onclick={() => selectedItem = null}>
<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>
</div>
{#if selectedItem.summary}
<div class="detail-summary">{selectedItem.summary}</div>
{/if}
{#if selectedItem.tags && selectedItem.tags.length > 0}
<div class="detail-tags">
{#each selectedItem.tags as tag}
<button class="detail-tag" onclick={() => { selectedItem = null; activeTag = tag; activeFolder = null; loadItems(); }}>{tag}</button>
{/each}
</div>
{/if}
<div class="detail-meta-line">
{#if selectedItem.folder}<span class="meta-folder-pill">{selectedItem.folder}</span>{/if}
<span>{formatDate(selectedItem.created_at)}</span>
{#if selectedItem.metadata_json?.page_count}
<span>{selectedItem.metadata_json.page_count} page{selectedItem.metadata_json.page_count !== 1 ? 's' : ''}</span>
{/if}
</div>
{#if selectedItem.extracted_text}
<div class="detail-extracted">
<div class="extracted-label">Extracted text</div>
<div class="extracted-body">{selectedItem.extracted_text.slice(0, 1000)}{selectedItem.extracted_text.length > 1000 ? '...' : ''}</div>
</div>
{/if}
<div class="detail-actions">
{#if selectedItem.assets?.some(a => a.asset_type === 'original_upload')}
<a class="action-btn" href="/api/brain/storage/{selectedItem.id}/original_upload/{selectedItem.assets.find(a => a.asset_type === 'original_upload')?.filename}" target="_blank" rel="noopener">Download</a>
{/if}
<button class="action-btn ghost" onclick={() => reprocessItem(selectedItem.id)}>Reclassify</button>
<button class="action-btn ghost" onclick={() => { if (confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
</div>
</div>
</div>
</div>
<!-- ═══ Detail sheet for links/notes ═══ -->
{:else if selectedItem}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-overlay" onclick={(e) => { if (e.target === e.currentTarget) selectedItem = null; }} onkeydown={(e) => { if (e.key === 'Escape') selectedItem = null; }}>
<div class="detail-sheet">
<div class="detail-header">
<div>
<div class="detail-type">{selectedItem.type}</div>
<h2 class="detail-title">{selectedItem.title || 'Untitled'}</h2>
</div>
<button class="close-btn" onclick={() => selectedItem = null}>
<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>
</div>
{#if selectedItem.type === 'link' && selectedItem.assets?.some(a => a.asset_type === 'screenshot')}
<a class="detail-screenshot" href={selectedItem.url} target="_blank" rel="noopener">
<img src="/api/brain/storage/{selectedItem.id}/screenshot/screenshot.png" alt="" />
</a>
{/if}
{#if selectedItem.url}
<a class="detail-url" href={selectedItem.url} target="_blank" rel="noopener">{selectedItem.url}</a>
{/if}
{#if selectedItem.summary && selectedItem.type !== 'note'}
<div class="detail-summary">{selectedItem.summary}</div>
{/if}
{#if selectedItem.type === 'note'}
<div class="detail-content">
{#if editingNote}
<textarea
class="note-editor"
bind:value={editNoteContent}
onkeydown={(e) => { if (e.key === 'Escape') editingNote = false; }}
></textarea>
<div class="note-editor-actions">
<button class="action-btn" onclick={saveNote}>Save</button>
<button class="action-btn ghost" onclick={() => editingNote = false}>Cancel</button>
</div>
{:else}
<button class="content-body clickable" onclick={startEditNote}>
{selectedItem.raw_content || 'Empty note — click to edit'}
</button>
{/if}
</div>
{/if}
{#if selectedItem.tags && selectedItem.tags.length > 0}
<div class="detail-tags">
{#each selectedItem.tags as tag}
<button class="detail-tag" onclick={() => { selectedItem = null; activeTag = tag; activeFolder = null; loadItems(); }}>{tag}</button>
{/each}
</div>
{/if}
<div class="detail-meta-line">
{#if selectedItem.folder}<span class="meta-folder-pill">{selectedItem.folder}</span>{/if}
<span>{formatDate(selectedItem.created_at)}</span>
</div>
<div class="detail-actions">
{#if selectedItem.url}
<a class="action-btn" href={selectedItem.url} target="_blank" rel="noopener">Open original</a>
{/if}
<button class="action-btn ghost" onclick={() => reprocessItem(selectedItem.id)}>Reclassify</button>
<button class="action-btn ghost" onclick={() => { if (confirm('Delete this item?')) deleteItem(selectedItem.id); }}>Delete</button>
</div>
</div>
</div>
{/if}
<style>
/* No .page wrapper — brain-layout is the root */
/* ═══ Command hero ═══ */
.brain-command {
display: flex;
justify-content: space-between;
align-items: end;
gap: 24px;
padding: 8px 0 18px;
}
.command-copy { max-width: 640px; }
.command-label {
font-size: 11px; text-transform: uppercase;
letter-spacing: 0.14em; color: #8c7b69; margin-bottom: 8px;
}
.command-copy h1 {
margin: 0; font-size: clamp(2.1rem, 4.1vw, 3.5rem);
line-height: 0.94; letter-spacing: -0.065em; color: #17120d;
}
.command-copy p {
margin: 12px 0 0; max-width: 46ch;
color: #5c5046; font-size: 1rem; line-height: 1.55;
}
.command-actions { min-width: 140px; display: grid; justify-items: end; gap: 6px; }
.command-kicker { font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; color: #8c7b69; }
.command-stats { font-size: 1.15rem; letter-spacing: -0.04em; color: #221a13; }
/* ═══ Capture bar ═══ */
.capture-card {
border-radius: 28px; border: 1px solid rgba(35,26,17,0.08);
background: rgba(255,252,248,0.68); backdrop-filter: blur(14px);
padding: 14px 16px; margin-bottom: 18px;
}
.capture-wrap { display: flex; align-items: center; gap: 10px; }
.capture-icon { width: 18px; height: 18px; color: #7f7365; flex-shrink: 0; }
.capture-input {
flex: 1; padding: 10px 0; border: none; background: none;
color: #1e1812; font-size: 1rem; font-family: var(--font); outline: none;
}
.capture-input::placeholder { color: #8b7b6a; }
.upload-btn {
flex-shrink: 0;
width: 36px; height: 36px;
border-radius: 10px;
border: 1px solid rgba(35,26,17,0.1);
background: rgba(255,255,255,0.6);
color: #7f7365;
display: flex; align-items: center; justify-content: center;
transition: all 160ms;
}
.upload-btn:hover { background: rgba(255,255,255,0.9); color: #1e1812; border-color: rgba(35,26,17,0.2); }
.upload-btn:active { transform: scale(0.95); }
.hidden-input { display: none; }
.upload-status {
font-size: 0.82rem; color: #8c7b69;
margin-top: 8px; padding-left: 28px;
}
.capture-btn {
padding: 8px 18px; border-radius: 999px;
background: #1e1812; color: white; border: none;
font-size: 0.88rem; font-weight: 600; font-family: var(--font);
transition: opacity 160ms;
}
.capture-btn:hover { opacity: 0.9; }
/* ═══ Layout: Sidebar + Content (Reader pattern) ═══ */
.brain-layout {
display: flex;
min-height: calc(100vh - 60px);
}
.brain-sidebar {
width: 248px;
flex-shrink: 0;
min-height: 0;
background: rgba(245, 237, 228, 0.82);
border-right: 1px solid rgba(35,26,17,0.1);
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 16px 0 12px;
backdrop-filter: blur(16px);
}
.sidebar-header { display: flex; align-items: center; gap: 8px; padding: 0 18px 14px; }
.sidebar-header > svg { color: #7f5f3d; }
.sidebar-title { font-size: 1rem; font-weight: 700; color: #1f1811; letter-spacing: -0.03em; flex: 1; }
.sidebar-settings {
width: 28px; height: 28px; border-radius: 8px; border: none;
background: none; color: #8c7b69; display: flex; align-items: center;
justify-content: center; cursor: pointer; transition: all 140ms; flex-shrink: 0;
}
.sidebar-settings:hover { background: rgba(255,248,241,0.72); color: #1f1811; }
.sidebar-nav { display: flex; flex-direction: column; gap: 2px; padding: 0 12px; }
.nav-item {
display: flex; align-items: center; gap: var(--sp-2, 8px);
padding: 10px 12px; border-radius: 14px; background: none; border: none;
font-size: var(--text-sm, 0.85rem); color: #65584c; cursor: pointer;
transition: all var(--transition, 160ms); text-align: left; width: 100%; font-family: var(--font);
}
.nav-item:hover { background: rgba(255,248,241,0.72); color: #1f1811; }
.nav-item.active { background: rgba(255,248,241,0.92); color: #1f1811; font-weight: 600; box-shadow: inset 0 0 0 1px rgba(35,26,17,0.08); }
.nav-label { flex: 1; }
.nav-count { font-size: var(--text-xs, 0.72rem); color: #8a7a68; font-family: var(--mono); }
.nav-item.active .nav-count { color: #7f5f3d; opacity: 0.8; }
.sidebar-separator { height: 1px; background: rgba(35,26,17,0.08); margin: 12px 18px; }
.sidebar-section-head {
display: flex; align-items: center; justify-content: space-between;
padding: 4px 18px 6px;
}
.sidebar-section-label {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.14em; color: #8c7b69;
}
.sidebar-section-add {
width: 20px; height: 20px; border-radius: 6px;
border: none; background: rgba(35,26,17,0.06);
color: #7f5f3d; font-size: 0.85rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 140ms;
}
.sidebar-section-add:hover { background: rgba(35,26,17,0.12); }
.sidebar-add-row {
display: flex; gap: 4px; padding: 0 12px; margin-bottom: 6px;
}
.sidebar-add-input {
flex: 1; min-width: 0; padding: 6px 8px; border-radius: 10px;
border: 1px solid rgba(35,26,17,0.12); background: rgba(255,255,255,0.7);
font-size: 0.78rem; font-family: var(--font); color: #1e1812; outline: none;
}
.sidebar-add-input:focus { border-color: rgba(179,92,50,0.4); }
.sidebar-add-input::placeholder { color: #8c7b69; }
.sidebar-add-go {
flex-shrink: 0; padding: 6px 8px; border-radius: 10px; border: none;
background: #1e1812; color: white; font-size: 0.72rem; font-weight: 600;
font-family: var(--font); cursor: pointer;
}
.nav-delete {
width: 22px; height: 22px; border-radius: 6px;
border: none; background: none; color: #8c7b69;
font-size: 0.9rem; display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 140ms; flex-shrink: 0;
}
.nav-delete:hover { background: rgba(220,38,38,0.1); color: #DC2626; }
/* manage button removed — settings icon in header */
.brain-content {
flex: 1;
min-width: 0;
padding: 16px 28px 28px;
overflow-x: hidden;
}
/* ═══ Mobile pills ═══ */
.mobile-pills {
display: none;
gap: 6px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: 4px;
margin-bottom: 12px;
}
.mobile-pills::-webkit-scrollbar { display: none; }
.pill {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(35,26,17,0.1);
background: rgba(255,252,248,0.7);
color: #5c5046;
font-size: 0.78rem;
font-weight: 500;
font-family: var(--font);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: all 160ms;
}
.pill:hover { background: rgba(255,248,241,0.9); }
.pill.active {
background: rgba(255,248,241,0.95);
color: #1e1812;
font-weight: 600;
border-color: var(--pill-color, rgba(35,26,17,0.2));
box-shadow: inset 0 0 0 1px var(--pill-color, transparent);
}
.pill-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.pill-count {
font-size: 0.65rem; font-family: var(--mono); color: #8c7b69;
}
.pill-separator {
width: 1px; height: 20px; background: rgba(35,26,17,0.12); flex-shrink: 0; align-self: center;
}
.pill-tag { color: #7d6f61; font-style: italic; }
.nav-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
@media (max-width: 768px) {
.mobile-pills { display: flex; }
}
/* ═══ Active filter ═══ */
.active-filter {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
margin-bottom: 12px;
border-radius: 999px;
background: rgba(179,92,50,0.08);
border: 1px solid rgba(179,92,50,0.15);
width: fit-content;
}
.filter-label { font-size: 0.82rem; color: #7d6f61; }
.filter-tag { font-size: 0.82rem; font-weight: 700; color: #1e1812; }
.filter-clear {
display: flex; align-items: center; gap: 3px;
font-size: 0.78rem; color: #8c7b69; background: none; border: none;
font-family: var(--font); transition: color 160ms;
}
.filter-clear:hover { color: #1e1812; }
/* ═══ Search ═══ */
.search-section { margin-bottom: 18px; }
.search-wrap { position: relative; }
.search-icon { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: #7f7365; pointer-events: none; }
.search-input {
width: 100%; padding: 14px 40px 14px 46px;
border-radius: 28px; border: 1px solid rgba(35,26,17,0.08);
background: rgba(255,252,248,0.68); backdrop-filter: blur(14px);
color: #1e1812; font-size: 1rem; font-family: var(--font);
}
.search-input::placeholder { color: #8b7b6a; }
.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: 14px; top: 50%; transform: translateY(-50%); background: none; border: none; color: #7f7365; }
.search-clear svg { width: 16px; height: 16px; }
/* ═══ Masonry grid ═══ */
.masonry {
columns: 3;
column-gap: 14px;
}
.card {
break-inside: avoid;
display: flex;
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;
}
.card:hover {
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); }
.card.is-processing { opacity: 0.7; }
/* Card thumbnail — link opens URL */
.card-thumb {
display: block;
width: 100%;
position: relative;
overflow: hidden;
background: rgba(244,237,229,0.6);
cursor: pointer;
}
.card-thumb img {
width: 100%;
height: auto;
display: block;
object-fit: cover;
object-position: top;
max-height: 240px;
}
.card-type-badge {
position: absolute;
top: 10px;
right: 10px;
background: rgba(30,24,18,0.75);
color: white;
font-size: 0.7rem;
font-weight: 700;
padding: 3px 8px;
border-radius: 6px;
letter-spacing: 0.04em;
backdrop-filter: blur(4px);
}
.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; } }
/* Note card body — click opens detail */
.card-note-body {
padding: 18px 18px 0;
font-size: 0.92rem;
color: #3d342c;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
background: none;
border: none;
text-align: left;
font-family: var(--font);
cursor: pointer;
width: 100%;
}
.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 — button for detail click */
.card-content {
padding: 14px 18px 16px;
background: none;
border: none;
text-align: left;
font-family: var(--font);
cursor: pointer;
width: 100%;
transition: background 160ms;
}
.card-content:hover { background: rgba(255,248,242,0.5); }
.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;
border: none;
font-family: var(--font);
cursor: pointer;
transition: background 160ms, color 160ms;
}
.card-tag:hover {
background: rgba(179,92,50,0.12);
color: #1e1812;
}
.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; }
/* ═══ Document viewer popup ═══ */
.viewer-overlay {
position: fixed; inset: 0; z-index: 60;
background: rgba(17,13,10,0.5);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
animation: fadeIn 200ms ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.viewer-layout {
display: grid;
grid-template-columns: 1fr 440px;
width: 94vw;
max-width: 1300px;
height: 85vh;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 32px 80px rgba(18,13,10,0.3);
animation: sheetIn 220ms ease;
}
.viewer-main {
overflow: auto;
background: #f5f0ea;
}
.viewer-iframe {
width: 100%;
height: 100%;
border: none;
background: white;
}
.viewer-image-wrap {
padding: 24px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
overflow: auto;
}
.viewer-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
}
.viewer-sidebar {
background: linear-gradient(180deg, #f8f1e8 0%, #f3eadc 100%);
border-left: 1px solid rgba(35,26,17,0.08);
padding: 28px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.viewer-sidebar-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
/* ═══ Detail sheet (links/notes) ═══ */
.detail-overlay {
position: fixed; inset: 0;
background: rgba(17,13,10,0.42); z-index: 60;
display: flex; justify-content: flex-end;
backdrop-filter: blur(12px);
}
.detail-sheet {
width: 560px; max-width: 100%; height: 100%;
background: linear-gradient(180deg, #f8f1e8 0%, #f3eadc 100%);
overflow-y: auto; padding: 28px;
box-shadow: -20px 0 50px rgba(18,13,10,0.16);
border-left: 1px solid rgba(35,26,17,0.08);
color: #1e1812;
animation: sheetIn 220ms ease;
}
.detail-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 16px; }
.detail-type { font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; color: #8c7b69; margin-bottom: 6px; }
.detail-title { font-size: 1.5rem; font-weight: 700; color: #1e1812; line-height: 1.15; letter-spacing: -0.04em; margin: 0; }
.close-btn { background: none; border: none; color: #7d6f61; padding: 4px; border-radius: 8px; }
.close-btn:hover { background: rgba(35,26,17,0.08); color: #1e1812; }
.close-btn svg { width: 20px; height: 20px; }
.detail-screenshot {
display: block; border-radius: 14px; overflow: hidden;
margin-bottom: 16px; border: 1px solid rgba(35,26,17,0.08);
}
.detail-screenshot img {
width: 100%; height: auto; display: block; max-height: 300px; object-fit: cover;
}
.detail-screenshot:hover { opacity: 0.95; }
.detail-url {
display: block; font-size: 0.85rem; color: #8c7b69;
margin-bottom: 14px; word-break: break-all;
text-decoration: none;
}
.detail-url:hover { color: #1e1812; text-decoration: underline; }
.meta-folder-pill {
background: rgba(35,26,17,0.06); padding: 2px 10px;
border-radius: 999px; font-weight: 600;
}
.detail-content {
margin-bottom: 20px;
}
.content-body {
display: block; width: 100%; text-align: left;
font-size: 1rem; color: #1e1812; line-height: 1.7;
white-space: pre-wrap; word-break: break-word;
background: none; border: none; font-family: var(--font);
padding: 14px 16px; border-radius: 14px;
transition: background 160ms;
}
.content-body.clickable { cursor: text; }
.content-body.clickable:hover { background: rgba(255,255,255,0.6); }
.note-editor {
width: 100%; min-height: 200px; padding: 14px 16px;
border-radius: 14px; border: 1.5px solid rgba(179,92,50,0.3);
background: rgba(255,255,255,0.8); color: #1e1812;
font-size: 1rem; font-family: var(--font); line-height: 1.7;
resize: vertical; outline: none;
}
.note-editor:focus { border-color: rgba(179,92,50,0.5); box-shadow: 0 0 0 4px rgba(179,92,50,0.06); }
.note-editor-actions { display: flex; gap: 8px; margin-top: 10px; }
.detail-summary {
font-size: 0.92rem; color: #5c5046; line-height: 1.6;
margin-bottom: 16px;
font-style: italic;
}
.detail-extracted {
margin-bottom: 16px;
padding: 14px 16px;
background: rgba(255,255,255,0.5);
border-radius: 14px;
border: 1px solid rgba(35,26,17,0.06);
}
.extracted-label {
font-size: 10px; text-transform: uppercase;
letter-spacing: 0.1em; color: #8c7b69;
margin-bottom: 8px;
}
.extracted-body {
font-size: 0.88rem; color: #3d342c; line-height: 1.6;
white-space: pre-wrap; word-break: break-word;
font-family: var(--mono);
}
.detail-meta-line {
display: flex; gap: 12px; font-size: 0.8rem; color: #8c7b69;
margin-bottom: 16px;
}
/* meta grid removed — folder/date shown inline */
.detail-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
.detail-tag {
background: rgba(35,26,17,0.06); padding: 4px 12px;
border-radius: 999px; font-size: 0.85rem; color: #5d5248;
border: none; font-family: var(--font); cursor: pointer;
transition: background 160ms;
}
.detail-tag:hover { background: rgba(179,92,50,0.12); color: #1e1812; }
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.action-btn {
display: flex; align-items: center; gap: 5px;
padding: 8px 14px; border-radius: 10px;
background: rgba(255,255,255,0.8); border: 1px solid rgba(35,26,17,0.1);
font-size: 0.88rem; color: #1e1812; text-decoration: none;
font-family: var(--font); transition: background 160ms;
}
.action-btn:hover { background: rgba(255,255,255,0.95); }
.action-btn.ghost { background: none; color: #6b6256; }
/* ═══ Animations ═══ */
.reveal { animation: riseIn 280ms ease; }
.stagger .signal-card:nth-child(1) { animation: riseIn 220ms ease; }
.stagger .signal-card:nth-child(2) { animation: riseIn 260ms ease; }
.stagger .signal-card:nth-child(3) { animation: riseIn 300ms ease; }
.stagger .signal-card:nth-child(4) { animation: riseIn 340ms ease; }
.stagger .signal-card:nth-child(5) { animation: riseIn 380ms ease; }
.stagger .signal-card:nth-child(6) { animation: riseIn 420ms ease; }
@keyframes riseIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes sheetIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
/* ═══ Mobile ═══ */
@media (max-width: 1100px) {
.masonry { columns: 2; }
}
@media (max-width: 768px) {
.brain-command { display: grid; gap: 14px; }
.command-actions { justify-items: start; }
.brain-sidebar {
display: none;
position: fixed; left: 0; top: 0; bottom: 0; z-index: 50;
width: 280px; box-shadow: 8px 0 24px rgba(0,0,0,0.1);
}
.brain-sidebar.mobile-open { display: flex; }
.mobile-sidebar-overlay {
position: fixed; inset: 0; z-index: 49;
background: rgba(17,13,10,0.3);
}
.brain-layout { margin: 0; }
.masonry { columns: 1; }
.detail-sheet { width: 100%; padding: 20px; }
.viewer-overlay { padding: 0; }
.viewer-layout { grid-template-columns: 1fr; grid-template-rows: 55vh 1fr; width: 100%; height: 100vh; border-radius: 0; max-width: 100%; }
.viewer-sidebar { max-height: none; border-left: none; border-top: 1px solid rgba(35,26,17,0.08); overflow-y: auto; }
}
</style>