Web Viewer: nettleser-panel i arbeidsflaten

Vis en URL i en iframe inne i en BlockShell. URL-bar med
navigasjon (tilbake/frem/oppdater), bokmerker for vanlige
sider (admin, auth, git, synops.no). Sandbox for sikkerhet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-20 04:08:40 +00:00
parent 2d44a8d1af
commit f37cc5bb27
3 changed files with 181 additions and 1 deletions

View file

@ -0,0 +1,173 @@
<script lang="ts">
/**
* Web Viewer — vis en URL eller node-HTML i en BlockShell.
*
* Bruksområder:
* - Preview av publiserte artikler
* - Ekstern referanse mens du jobber
* - Admin-sider (Authentik, Forgejo, gamle /admin/*)
* - Dokumentasjon, API-referanser
*/
import type { Node } from '$lib/realtime';
interface Props {
collection: Node | undefined;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
}
let { collection, config, userId, accessToken }: Props = $props();
let url = $state((config.url as string) ?? '');
let inputUrl = $state(url);
let history = $state<string[]>([]);
let historyIndex = $state(-1);
function navigate(newUrl: string) {
if (!newUrl.trim()) return;
// Legg til https:// hvis mangler
let resolved = newUrl.trim();
if (!resolved.startsWith('http://') && !resolved.startsWith('https://')) {
resolved = 'https://' + resolved;
}
if (resolved !== url) {
// Legg til i historikk
if (url) {
history = [...history.slice(0, historyIndex + 1), url];
historyIndex = history.length;
}
url = resolved;
inputUrl = resolved;
}
}
function goBack() {
if (historyIndex < 0 || history.length === 0) return;
url = history[historyIndex];
inputUrl = url;
historyIndex--;
}
function goForward() {
if (historyIndex >= history.length - 1) return;
historyIndex++;
url = history[historyIndex + 1] ?? url;
inputUrl = url;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
navigate(inputUrl);
}
}
function refresh() {
const current = url;
url = '';
requestAnimationFrame(() => { url = current; });
}
// Hurtiglenker
const bookmarks = [
{ label: 'Hjem', url: 'https://ws.synops.no' },
{ label: 'Admin', url: 'https://ws.synops.no/admin' },
{ label: 'Helse', url: 'https://ws.synops.no/admin/health' },
{ label: 'Jobber', url: 'https://ws.synops.no/admin/jobs' },
{ label: 'Forbruk', url: 'https://ws.synops.no/admin/usage' },
{ label: 'Auth', url: 'https://auth.synops.no' },
{ label: 'Git', url: 'https://git.synops.no' },
{ label: 'Synops.no', url: 'https://synops.no' },
];
</script>
<div class="wv">
<!-- URL-bar -->
<div class="wv-bar">
<button class="wv-nav-btn" onclick={goBack} disabled={historyIndex < 0} title="Tilbake"></button>
<button class="wv-nav-btn" onclick={goForward} disabled={historyIndex >= history.length - 1} title="Frem"></button>
<button class="wv-nav-btn" onclick={refresh} title="Oppdater"></button>
<input
class="wv-url"
type="text"
bind:value={inputUrl}
onkeydown={handleKeydown}
placeholder="Skriv inn URL..."
/>
<button class="wv-go-btn" onclick={() => navigate(inputUrl)}>Gå</button>
</div>
<!-- Bokmerker -->
<div class="wv-bookmarks">
{#each bookmarks as bm (bm.url)}
<button
class="wv-bookmark"
class:wv-bookmark-active={url === bm.url}
onclick={() => navigate(bm.url)}
>{bm.label}</button>
{/each}
</div>
<!-- Innhold -->
{#if url}
<iframe
class="wv-frame"
src={url}
title="Web Viewer"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
></iframe>
{:else}
<div class="wv-empty">
<p>Skriv inn en URL eller velg et bokmerke</p>
</div>
{/if}
</div>
<style>
.wv { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.wv-bar {
display: flex; gap: 4px; padding: 6px 8px;
border-bottom: 1px solid var(--color-border, #2a2a2e);
align-items: center;
}
.wv-nav-btn {
border: none; background: transparent; cursor: pointer;
font-size: 14px; color: var(--color-text-muted, #8a8a96);
padding: 2px 6px; border-radius: 4px; line-height: 1;
}
.wv-nav-btn:hover:not(:disabled) { background: var(--color-surface-hover, #242428); color: var(--color-text, #e8e8ec); }
.wv-nav-btn:disabled { opacity: 0.3; cursor: default; }
.wv-url {
flex: 1; padding: 4px 8px; border-radius: 4px; font-size: 12px;
font-family: monospace;
}
.wv-go-btn {
border: none; background: var(--color-accent, #6366f1); color: white;
padding: 4px 10px; border-radius: 4px; font-size: 12px; cursor: pointer;
}
.wv-go-btn:hover { background: var(--color-accent-hover, #7577f5); }
.wv-bookmarks {
display: flex; gap: 2px; padding: 4px 8px; overflow-x: auto;
border-bottom: 1px solid var(--color-border, #2a2a2e);
}
.wv-bookmark {
border: none; background: transparent; cursor: pointer;
font-size: 11px; color: var(--color-text-dim, #5a5a66);
padding: 2px 8px; border-radius: 3px; white-space: nowrap;
}
.wv-bookmark:hover { background: var(--color-surface-hover, #242428); color: var(--color-text-muted, #8a8a96); }
.wv-bookmark-active { color: var(--color-accent, #6366f1); background: var(--color-accent-glow, rgba(99,102,241,0.15)); }
.wv-frame {
flex: 1; border: none; width: 100%; min-height: 0;
background: white;
}
.wv-empty {
flex: 1; display: flex; align-items: center; justify-content: center;
color: var(--color-text-dim, #5a5a66); font-size: 13px;
}
</style>

View file

@ -58,6 +58,7 @@ export const TRAIT_PANEL_INFO: Record<string, TraitPanelInfo> = {
mindmap: { title: 'Tankekart', icon: '🧠', defaultWidth: 600, defaultHeight: 500 },
ai: { title: 'AI-verktøy', icon: '🤖', defaultWidth: 420, defaultHeight: 500 },
node_explorer: { title: 'Nodeutforsker', icon: '🔍', defaultWidth: 600, defaultHeight: 500 },
web: { title: 'Nettleser', icon: '🌐', defaultWidth: 700, defaultHeight: 500 },
usage: { title: 'Ressursforbruk', icon: '📊', defaultWidth: 380, defaultHeight: 350 },
storyboard: { title: 'Storyboard', icon: '🎬', defaultWidth: 500, defaultHeight: 450 },
};

View file

@ -43,6 +43,7 @@
import AiToolPanel from '$lib/components/AiToolPanel.svelte';
import NodeUsage from '$lib/components/NodeUsage.svelte';
import NodeExplorerTrait from '$lib/components/traits/NodeExplorerTrait.svelte';
import WebViewerTrait from '$lib/components/traits/WebViewerTrait.svelte';
import { createBlockReceiver, executeTransfer, resolveTransferMode, type DragPayload } from '$lib/transfer';
import type { BlockReceiver } from '$lib/components/blockshell/types';
@ -308,7 +309,7 @@
const knownTraits = new Set([
'editor', 'chat', 'kanban', 'podcast', 'publishing',
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'mindmap',
'ai', 'usage', 'node_explorer'
'ai', 'usage', 'node_explorer', 'web'
]);
// =========================================================================
@ -403,6 +404,7 @@
<div class="workspace-empty-tools">
{#each [
['node_explorer', '🔍', 'Nodeutforsker'],
['web', '🌐', 'Nettleser'],
['usage', '📊', 'Forbruk'],
['ai', '🤖', 'AI-verktøy'],
] as [key, icon, label] (key)}
@ -486,6 +488,8 @@
{/if}
{:else if panel.trait === 'node_explorer'}
<NodeExplorerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
{:else if panel.trait === 'web'}
<WebViewerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
{/if}
{:else}
<GenericTrait name={panel.trait} config={{}} />
@ -554,6 +558,8 @@
{/if}
{:else if trait === 'node_explorer'}
<NodeExplorerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
{:else if trait === 'web'}
<WebViewerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
{/if}
{:else}
<GenericTrait name={trait} config={{}} />