- 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>
591 lines
12 KiB
Svelte
591 lines
12 KiB
Svelte
<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>
|