- 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>
1393 lines
52 KiB
Svelte
1393 lines
52 KiB
Svelte
<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>
|