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:
parent
2d44a8d1af
commit
f37cc5bb27
3 changed files with 181 additions and 1 deletions
173
frontend/src/lib/components/traits/WebViewerTrait.svelte
Normal file
173
frontend/src/lib/components/traits/WebViewerTrait.svelte
Normal 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>
|
||||||
|
|
@ -58,6 +58,7 @@ export const TRAIT_PANEL_INFO: Record<string, TraitPanelInfo> = {
|
||||||
mindmap: { title: 'Tankekart', icon: '🧠', defaultWidth: 600, defaultHeight: 500 },
|
mindmap: { title: 'Tankekart', icon: '🧠', defaultWidth: 600, defaultHeight: 500 },
|
||||||
ai: { title: 'AI-verktøy', icon: '🤖', defaultWidth: 420, defaultHeight: 500 },
|
ai: { title: 'AI-verktøy', icon: '🤖', defaultWidth: 420, defaultHeight: 500 },
|
||||||
node_explorer: { title: 'Nodeutforsker', icon: '🔍', defaultWidth: 600, 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 },
|
usage: { title: 'Ressursforbruk', icon: '📊', defaultWidth: 380, defaultHeight: 350 },
|
||||||
storyboard: { title: 'Storyboard', icon: '🎬', defaultWidth: 500, defaultHeight: 450 },
|
storyboard: { title: 'Storyboard', icon: '🎬', defaultWidth: 500, defaultHeight: 450 },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
import AiToolPanel from '$lib/components/AiToolPanel.svelte';
|
import AiToolPanel from '$lib/components/AiToolPanel.svelte';
|
||||||
import NodeUsage from '$lib/components/NodeUsage.svelte';
|
import NodeUsage from '$lib/components/NodeUsage.svelte';
|
||||||
import NodeExplorerTrait from '$lib/components/traits/NodeExplorerTrait.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 { createBlockReceiver, executeTransfer, resolveTransferMode, type DragPayload } from '$lib/transfer';
|
||||||
import type { BlockReceiver } from '$lib/components/blockshell/types';
|
import type { BlockReceiver } from '$lib/components/blockshell/types';
|
||||||
|
|
@ -308,7 +309,7 @@
|
||||||
const knownTraits = new Set([
|
const knownTraits = new Set([
|
||||||
'editor', 'chat', 'kanban', 'podcast', 'publishing',
|
'editor', 'chat', 'kanban', 'podcast', 'publishing',
|
||||||
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer', 'mindmap',
|
'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">
|
<div class="workspace-empty-tools">
|
||||||
{#each [
|
{#each [
|
||||||
['node_explorer', '🔍', 'Nodeutforsker'],
|
['node_explorer', '🔍', 'Nodeutforsker'],
|
||||||
|
['web', '🌐', 'Nettleser'],
|
||||||
['usage', '📊', 'Forbruk'],
|
['usage', '📊', 'Forbruk'],
|
||||||
['ai', '🤖', 'AI-verktøy'],
|
['ai', '🤖', 'AI-verktøy'],
|
||||||
] as [key, icon, label] (key)}
|
] as [key, icon, label] (key)}
|
||||||
|
|
@ -486,6 +488,8 @@
|
||||||
{/if}
|
{/if}
|
||||||
{:else if panel.trait === 'node_explorer'}
|
{:else if panel.trait === 'node_explorer'}
|
||||||
<NodeExplorerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
<NodeExplorerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
||||||
|
{:else if panel.trait === 'web'}
|
||||||
|
<WebViewerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<GenericTrait name={panel.trait} config={{}} />
|
<GenericTrait name={panel.trait} config={{}} />
|
||||||
|
|
@ -554,6 +558,8 @@
|
||||||
{/if}
|
{/if}
|
||||||
{:else if trait === 'node_explorer'}
|
{:else if trait === 'node_explorer'}
|
||||||
<NodeExplorerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
<NodeExplorerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
||||||
|
{:else if trait === 'web'}
|
||||||
|
<WebViewerTrait collection={undefined} config={{}} userId={nodeId} {accessToken} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<GenericTrait name={trait} config={{}} />
|
<GenericTrait name={trait} config={{}} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue