feat: brain full-screen PDF/image viewer with sidebar details

- PDFs open in full-screen split layout: PDF viewer (left) + metadata sidebar (right)
- Uses native browser PDF viewer (iframe) for full rendering
- Images open in centered viewer with dark background
- Sidebar shows title, summary, tags, folder, extracted text, download button
- Mobile: stacks vertically (viewer top, sidebar bottom)
- Links and notes still use the slide-over sheet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 19:14:59 -05:00
parent 783faa0abd
commit 840c7d6ea7

View File

@@ -443,8 +443,84 @@
</div>
</div>
<!-- ═══ Detail sheet ═══ -->
{#if selectedItem}
<!-- ═══ PDF/Image full-screen viewer ═══ -->
{#if selectedItem && (selectedItem.type === 'pdf' || selectedItem.type === 'image')}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="viewer-overlay" 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}"
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">
@@ -458,28 +534,10 @@
</button>
</div>
<!-- Screenshot/image preview -->
{#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>
{:else if selectedItem.type === 'pdf'}
{@const pdfAsset = selectedItem.assets?.find(a => a.asset_type === 'original_upload')}
{#if pdfAsset}
<PdfInlinePreview
url="/api/brain/storage/{selectedItem.id}/original_upload/{pdfAsset.filename}"
name={selectedItem.title || pdfAsset.filename}
/>
{:else if selectedItem.assets?.some(a => a.asset_type === 'screenshot')}
<div class="detail-screenshot">
<img src="/api/brain/storage/{selectedItem.id}/screenshot/screenshot.png" alt="" />
</div>
{/if}
{:else if selectedItem.type === 'image' && selectedItem.assets?.some(a => a.asset_type === 'original_upload')}
{@const imgAsset = selectedItem.assets.find(a => a.asset_type === 'original_upload')}
<div class="detail-screenshot">
<img src="/api/brain/storage/{selectedItem.id}/original_upload/{imgAsset?.filename}" alt="" />
</div>
{/if}
{#if selectedItem.url}
@@ -490,15 +548,6 @@
<div class="detail-summary">{selectedItem.summary}</div>
{/if}
<!-- Extracted text preview for PDFs/docs -->
{#if selectedItem.extracted_text && selectedItem.type !== 'note' && selectedItem.type !== 'link'}
<div class="detail-extracted">
<div class="extracted-label">Extracted text</div>
<div class="extracted-body">{selectedItem.extracted_text.slice(0, 500)}{selectedItem.extracted_text.length > 500 ? '...' : ''}</div>
</div>
{/if}
<!-- Note: editable content -->
{#if selectedItem.type === 'note'}
<div class="detail-content">
{#if editingNote}
@@ -856,7 +905,70 @@
.empty-title { font-size: 1.2rem; font-weight: 700; color: #1e1812; margin-bottom: 6px; }
.empty-desc { font-size: 0.95rem; color: #6a5d50; }
/* ═══ Detail sheet ═══ */
/* ═══ Full-screen document viewer ═══ */
.viewer-overlay {
position: fixed; inset: 0; z-index: 60;
background: rgba(17,13,10,0.92);
animation: fadeIn 200ms ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.viewer-layout {
display: grid;
grid-template-columns: 1fr 340px;
height: 100vh;
}
.viewer-main {
overflow: auto;
background: #2a2420;
display: flex;
align-items: center;
justify-content: center;
}
.viewer-iframe {
width: 100%;
height: 100%;
border: none;
background: white;
}
.viewer-image-wrap {
padding: 24px;
display: flex;
align-items: center;
justify-content: center;
max-height: 100%;
overflow: auto;
}
.viewer-image {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.viewer-sidebar {
background: linear-gradient(180deg, #f8f1e8 0%, #f3eadc 100%);
border-left: 1px solid rgba(35,26,17,0.08);
padding: 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.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;
@@ -996,6 +1108,7 @@
.signal-strip { grid-template-columns: 1fr 1fr; }
.masonry { columns: 1; }
.detail-sheet { width: 100%; padding: 20px; }
/* detail meta grid removed */
.viewer-layout { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
.viewer-sidebar { max-height: 40vh; border-left: none; border-top: 1px solid rgba(35,26,17,0.08); }
}
</style>