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>
This commit is contained in:
590
frontend-v2/src/lib/components/trips/PdfInlinePreview.svelte
Normal file
590
frontend-v2/src/lib/components/trips/PdfInlinePreview.svelte
Normal file
@@ -0,0 +1,590 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user