Files
platform/frontend-v2/src/lib/components/trips/PdfInlinePreview.svelte
Yusuf Suleman 2072c359aa feat: wire brain service to platform gateway
- Gateway proxies /api/brain/* to brain-api:8200/api/* via pangolin network
- User identity injected via X-Gateway-User-Id header
- Brain app registered in gateway database (sort_order 9)
- Added to GATEWAY_KEY_SERVICES for dashboard integration
- Tested: health, config, list, create all working through gateway

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

591 lines
12 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, tick } from 'svelte';
let {
url,
name = 'Document',
onDelete = () => {},
deleting = false
}: {
url: string;
name?: string;
onDelete?: () => void;
deleting?: boolean;
} = $props();
let inlinePagesHost = $state<HTMLDivElement | null>(null);
let pagesHost = $state<HTMLDivElement | null>(null);
let loading = $state(true);
let expandedLoading = $state(false);
let error = $state('');
let isExpanded = $state(false);
let isInlineOpen = $state(false);
let pageCount = $state(0);
let pdfModulePromise: Promise<any> | null = null;
let pdfDocumentPromise: Promise<any> | null = null;
async function getPdfModule() {
if (!pdfModulePromise) {
pdfModulePromise = import('pdfjs-dist').then((mod) => {
const pdfjs = mod.default ?? mod;
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
}
return pdfjs;
});
}
return pdfModulePromise;
}
async function getPdfDocument() {
if (!pdfDocumentPromise) {
pdfDocumentPromise = getPdfModule().then((pdfjs) =>
pdfjs.getDocument({
url,
withCredentials: true
}).promise
);
}
return pdfDocumentPromise;
}
async function renderPageToCanvas(canvas: HTMLCanvasElement, pageNumber: number, width: number) {
const pdf = await getPdfDocument();
const page = await pdf.getPage(pageNumber);
const baseViewport = page.getViewport({ scale: 1 });
const scale = width / baseViewport.width;
const viewport = page.getViewport({ scale });
const context = canvas.getContext('2d');
const pixelRatio = typeof window !== 'undefined' ? Math.max(window.devicePixelRatio || 1, 1) : 1;
if (!context) return;
canvas.width = Math.floor(viewport.width * pixelRatio);
canvas.height = Math.floor(viewport.height * pixelRatio);
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
await page.render({
canvasContext: context,
viewport
}).promise;
}
async function renderPages(host: HTMLDivElement, width: number, pageClass = 'expanded-page', canvasClass = 'expanded-canvas') {
host.replaceChildren();
const pdf = await getPdfDocument();
pageCount = pdf.numPages;
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
const wrapper = document.createElement('div');
wrapper.className = pageClass;
const label = document.createElement('div');
label.className = 'expanded-page-label';
label.textContent = `Page ${pageNumber}`;
const canvas = document.createElement('canvas');
canvas.className = canvasClass;
wrapper.append(label, canvas);
host.appendChild(wrapper);
await renderPageToCanvas(canvas, pageNumber, width);
}
}
async function renderInline() {
if (!inlinePagesHost) return;
loading = true;
error = '';
try {
await renderPages(inlinePagesHost, 420, 'inline-page', 'inline-canvas');
} catch (err) {
console.error('PDF preview failed', err);
error = 'Preview unavailable';
} finally {
loading = false;
}
}
async function openInline() {
isInlineOpen = true;
await tick();
await renderInline();
}
function closeInline() {
isInlineOpen = false;
}
async function openExpanded() {
isExpanded = true;
expandedLoading = true;
error = '';
await tick();
if (!pagesHost) {
expandedLoading = false;
return;
}
try {
const hostWidth = Math.max(280, Math.min(860, pagesHost.clientWidth - 8 || 860));
await renderPages(pagesHost, hostWidth);
} catch (err) {
console.error('Expanded PDF render failed', err);
error = 'Unable to load PDF';
} finally {
expandedLoading = false;
}
}
function closeExpanded() {
isExpanded = false;
}
onMount(() => {
renderInline();
});
</script>
<div class="pdf-preview-shell">
<div class="preview-frame">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="preview-head"
role="button"
tabindex="0"
onclick={isInlineOpen ? closeInline : openInline}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (isInlineOpen) closeInline();
else openInline();
}
}}
>
<div class="preview-docline">
<svg class="preview-docicon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
<div class="preview-name">{name}</div>
</div>
<div class="preview-head-actions">
<button class="doc-delete" onclick={(e) => { e.stopPropagation(); onDelete(); }} disabled={deleting}>{deleting ? '…' : '×'}</button>
</div>
</div>
{#if isInlineOpen}
<div class="preview-topline">
<div class="preview-badge">PDF preview</div>
<div class="preview-actions">
<button class="preview-action" onclick={openExpanded}>Full view</button>
<a class="preview-action preview-link" href={url} target="_blank" rel="noreferrer">New tab</a>
</div>
</div>
<div class="preview-card">
{#if error}
<div class="preview-state preview-error">{error}</div>
{:else}
<div class="preview-stage">
<div bind:this={inlinePagesHost} class="inline-pages"></div>
{#if loading}
<div class="preview-loading">Rendering pages…</div>
{/if}
</div>
<div class="preview-footer">
<div class="preview-pages">{pageCount} page{pageCount === 1 ? '' : 's'}</div>
</div>
{/if}
</div>
{:else}
<div class="preview-collapsed-meta">
<div class="preview-pages">{pageCount > 0 ? `${pageCount} page${pageCount === 1 ? '' : 's'}` : 'PDF document'}</div>
<a class="preview-inline-link" href={url} target="_blank" rel="noreferrer">New tab</a>
</div>
{/if}
</div>
</div>
{#if isExpanded}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="expanded-overlay" onclick={closeExpanded}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="expanded-sheet" onclick={(e: MouseEvent) => e.stopPropagation()}>
<div class="expanded-head">
<div class="expanded-copy">
<div class="expanded-kicker">Inline document</div>
<div class="expanded-title">{name}</div>
</div>
<div class="expanded-head-actions">
<a class="preview-action preview-link" href={url} target="_blank" rel="noreferrer">Open</a>
<button class="expanded-close" onclick={closeExpanded}>Close</button>
</div>
</div>
<div class="expanded-body">
{#if expandedLoading}
<div class="expanded-state">Loading full document…</div>
{:else if error}
<div class="expanded-state preview-error">{error}</div>
{/if}
<div bind:this={pagesHost} class="expanded-pages"></div>
</div>
</div>
</div>
{/if}
<style>
.pdf-preview-shell {
display: block;
}
.preview-frame {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 6px;
border-top: 1px solid rgba(127, 101, 74, 0.12);
}
.preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0;
border: none;
background: none;
cursor: pointer;
text-align: left;
}
.preview-docline {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.preview-docicon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: rgba(111, 88, 64, 0.7);
}
.preview-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.preview-badge {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.7);
}
.preview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview-action {
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(123, 97, 71, 0.16);
background: rgba(255, 251, 246, 0.9);
color: #5d4737;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
}
.preview-card {
display: flex;
flex-direction: column;
gap: 10px;
}
.preview-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
border-radius: 16px;
background: rgba(247, 239, 229, 0.78);
color: rgba(90, 71, 54, 0.8);
font-size: 0.92rem;
}
.preview-stage {
position: relative;
border-radius: 16px;
overflow: hidden;
background: rgba(250, 243, 235, 0.72);
border: 1px solid rgba(127, 101, 74, 0.14);
}
.preview-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(247, 239, 229, 0.82);
color: rgba(90, 71, 54, 0.8);
font-size: 0.92rem;
}
.preview-error {
color: #8c3c2d;
}
.inline-pages {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 520px;
overflow: auto;
padding: 10px;
}
.preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.preview-name {
font-size: 0.92rem;
font-weight: 600;
color: #2f2116;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-pages {
font-size: 0.82rem;
color: rgba(93, 72, 55, 0.76);
}
.preview-collapsed-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.preview-inline-link {
font-size: 0.82rem;
font-weight: 600;
color: #5d4737;
text-decoration: none;
}
.doc-delete {
background: none;
border: none;
color: rgba(112, 86, 62, 0.64);
cursor: pointer;
font-size: 1.1rem;
padding: 2px 4px;
}
.doc-delete:hover {
color: #8f3928;
}
.expanded-overlay {
position: fixed;
inset: 0;
z-index: 85;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(19, 13, 10, 0.46);
backdrop-filter: blur(10px);
}
.expanded-sheet {
width: min(980px, 100%);
max-height: min(92vh, 100%);
display: flex;
flex-direction: column;
border-radius: 28px;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 250, 242, 0.98), rgba(247, 239, 229, 0.98));
box-shadow: 0 28px 64px rgba(35, 24, 15, 0.28);
}
.expanded-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 22px 24px 18px;
border-bottom: 1px solid rgba(123, 97, 71, 0.12);
}
.expanded-copy {
display: flex;
flex-direction: column;
gap: 6px;
}
.expanded-kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.72);
}
.expanded-title {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: -0.03em;
color: #27190f;
}
.expanded-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.expanded-close {
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(123, 97, 71, 0.16);
background: rgba(255, 251, 246, 0.9);
color: #5d4737;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
}
.expanded-body {
flex: 1;
overflow: auto;
padding: 18px 24px 24px;
}
.expanded-state {
padding: 24px;
border-radius: 18px;
background: rgba(247, 239, 229, 0.74);
color: rgba(90, 71, 54, 0.8);
text-align: center;
}
.expanded-pages {
display: flex;
flex-direction: column;
gap: 20px;
}
:global(.inline-page) {
display: flex;
flex-direction: column;
gap: 8px;
}
:global(.inline-canvas) {
display: block;
max-width: 100%;
height: auto;
border-radius: 14px;
background: white;
border: 1px solid rgba(111, 89, 65, 0.12);
box-shadow: 0 10px 20px rgba(63, 42, 22, 0.05);
}
:global(.expanded-page) {
display: flex;
flex-direction: column;
gap: 10px;
}
:global(.expanded-page-label) {
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(122, 94, 66, 0.68);
}
:global(.expanded-canvas) {
display: block;
max-width: 100%;
height: auto;
border-radius: 18px;
background: white;
border: 1px solid rgba(111, 89, 65, 0.12);
box-shadow: 0 14px 28px rgba(63, 42, 22, 0.06);
}
@media (max-width: 768px) {
.preview-topline {
align-items: flex-start;
flex-direction: column;
}
.preview-actions {
width: 100%;
}
.preview-action {
flex: 1;
text-align: center;
}
.expanded-overlay {
padding: 10px;
}
.expanded-sheet {
max-height: 96vh;
border-radius: 22px;
}
.expanded-head,
.expanded-body {
padding-left: 16px;
padding-right: 16px;
}
.expanded-head {
flex-direction: column;
}
}
</style>