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:
@@ -443,8 +443,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ Detail sheet ═══ -->
|
<!-- ═══ PDF/Image full-screen viewer ═══ -->
|
||||||
{#if selectedItem}
|
{#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 -->
|
<!-- 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-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-sheet">
|
||||||
@@ -458,28 +534,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Screenshot/image preview -->
|
|
||||||
{#if selectedItem.type === 'link' && selectedItem.assets?.some(a => a.asset_type === 'screenshot')}
|
{#if selectedItem.type === 'link' && selectedItem.assets?.some(a => a.asset_type === 'screenshot')}
|
||||||
<a class="detail-screenshot" href={selectedItem.url} target="_blank" rel="noopener">
|
<a class="detail-screenshot" href={selectedItem.url} target="_blank" rel="noopener">
|
||||||
<img src="/api/brain/storage/{selectedItem.id}/screenshot/screenshot.png" alt="" />
|
<img src="/api/brain/storage/{selectedItem.id}/screenshot/screenshot.png" alt="" />
|
||||||
</a>
|
</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}
|
||||||
|
|
||||||
{#if selectedItem.url}
|
{#if selectedItem.url}
|
||||||
@@ -490,15 +548,6 @@
|
|||||||
<div class="detail-summary">{selectedItem.summary}</div>
|
<div class="detail-summary">{selectedItem.summary}</div>
|
||||||
{/if}
|
{/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'}
|
{#if selectedItem.type === 'note'}
|
||||||
<div class="detail-content">
|
<div class="detail-content">
|
||||||
{#if editingNote}
|
{#if editingNote}
|
||||||
@@ -856,7 +905,70 @@
|
|||||||
.empty-title { font-size: 1.2rem; font-weight: 700; color: #1e1812; margin-bottom: 6px; }
|
.empty-title { font-size: 1.2rem; font-weight: 700; color: #1e1812; margin-bottom: 6px; }
|
||||||
.empty-desc { font-size: 0.95rem; color: #6a5d50; }
|
.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 {
|
.detail-overlay {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background: rgba(17,13,10,0.42); z-index: 60;
|
background: rgba(17,13,10,0.42); z-index: 60;
|
||||||
@@ -996,6 +1108,7 @@
|
|||||||
.signal-strip { grid-template-columns: 1fr 1fr; }
|
.signal-strip { grid-template-columns: 1fr 1fr; }
|
||||||
.masonry { columns: 1; }
|
.masonry { columns: 1; }
|
||||||
.detail-sheet { width: 100%; padding: 20px; }
|
.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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user