Skill server-admin fra workspace-admin

Server-admin (/server-admin): systemvide innstillinger (AI, kanaler) — kun for owners.
Workspace-admin (/admin): workspace-spesifikke innstillinger (sider, entiteter) — for owner/admin i gjeldende workspace.

- Ny rute /server-admin med egen layout-gate (owner-rolle)
- Flytt AI og kanaler fra /admin til /server-admin
- Workspace-admin gate sjekker nå rolle i gjeldende workspace
- Sidebar: workspace-admin-lenker under separator, server-admin-nav i server-admin-modus
- WorkspaceSwitcher: "Admin (server)" kun for owners
- Kanaler: trekkspill gruppert etter workspace
- Config-API: owners kan endre kanaler på tvers av workspaces
- Migrasjon: ai_prompts-tabell

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 05:19:23 +01:00
parent 3ac9691830
commit aafb121bf2
14 changed files with 594 additions and 115 deletions

View file

@ -0,0 +1,40 @@
-- AI Prompts: redigerbare system-prompts for AI-tekstbehandling
CREATE TABLE ai_prompts (
action TEXT PRIMARY KEY,
system_prompt TEXT NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Seed med nåværende hardkodede prompts
INSERT INTO ai_prompts (action, system_prompt, description) VALUES
('fix_text', 'Fiks denne teksten. Output på norsk.
- Returner KUN den fiksede teksten ingen innledning, kommentar eller meta-tekst
- Fiks skrivefeil og grammatikk
- Start med en kort oppsummering av det viktigste (23 setninger)
- Fjern metainformasjon, navigasjon, annonser og annen støy fra innlimt webinnhold
- Dersom det er tydelig hva kilden er, oppgi den etter innledende oppsummering
- Behold saklig innhold og fakta intakt
- Bruk markdown-formatering der det gir bedre lesbarhet', 'Fikser skrivefeil, fjerner støy, oppsummerer'),
('extract_facts', 'Analyser denne teksten og trekk ut fakta. Output på norsk.
- Returner KUN faktalisten ingen innledning, kommentar eller meta-tekst
- Identifiser konkrete påstander, tall, sitater og fakta
- List dem opp som punktliste
- For hver fakta: noter hvilken person eller organisasjon den gjelder (bruk #Navn-format)
- Ignorer meninger og spekulasjoner kun verifiserbare påstander
- Behold kildehenvisninger der de finnes', 'Trekker ut fakta som punktliste'),
('rewrite', 'Skriv om denne teksten til artikkelformat. Output på norsk.
- Returner KUN artikkelen ingen innledning, kommentar eller meta-tekst
- Lag en tittel som fanger essensen
- Skriv en ingress 23 setninger
- Strukturer resten med mellomtitler der det er naturlig
- Hold deg til fakta fra originalteksten ikke legg til informasjon
- Bruk markdown-formatering', 'Skriver om til artikkelformat'),
('translate', 'Oversett denne teksten til norsk.
- Returner KUN oversettelsen ingen innledning, kommentar eller meta-tekst
- Behold formatering og struktur
- Oversett fagtermer korrekt, behold engelske termer i parentes der det er vanlig
- Behold egennavn uoversatt', 'Oversetter til norsk');

View file

@ -2,18 +2,23 @@
import type { Workspace } from '$lib/server/db';
import type { PageConfig } from '$lib/types/pages';
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
import { page } from '$app/state';
let {
open = $bindable(false),
user,
workspace,
workspaces,
isServerAdmin = false,
isWorkspaceAdmin = false,
authProvider
}: {
open: boolean;
user: { id: string; name: string; email: string; image?: string };
workspace: Workspace | null;
workspaces: Workspace[];
isServerAdmin?: boolean;
isWorkspaceAdmin?: boolean;
authProvider: string;
} = $props();
@ -81,6 +86,9 @@
((workspace?.settings as Record<string, unknown>)?.pages as PageConfig[]) ?? []
);
let isOnAdmin = $derived(page.url.pathname.startsWith('/admin'));
let isOnServerAdmin = $derived(page.url.pathname.startsWith('/server-admin'));
// Beregn transform basert på state
let sidebarTransform = $derived.by(() => {
if (swiping) {
@ -130,22 +138,34 @@
</svg>
</button>
</div>
<WorkspaceSwitcher {workspaces} active={workspace} />
<WorkspaceSwitcher {workspaces} active={workspace} {isServerAdmin} />
</div>
<ul class="nav-links">
<li><a href="/" onclick={() => (open = false)}>Oversikt</a></li>
{#each pages as page}
<li>
<a href="/p/{page.slug}" onclick={() => (open = false)}>
{#if page.icon}<span class="nav-icon">{page.icon}</span>{/if}
{page.title}
</a>
</li>
{/each}
<li class="nav-divider"></li>
<li><a href="/admin/pages" onclick={() => (open = false)}>Rediger sider</a></li>
<li><a href="/admin/channels" onclick={() => (open = false)}>Kanaler</a></li>
{#if isOnServerAdmin}
<li><a href="/server-admin" onclick={() => (open = false)}>Oversikt</a></li>
<li><a href="/server-admin/channels" onclick={() => (open = false)}>Kanaler</a></li>
<li><a href="/server-admin/ai" onclick={() => (open = false)}>AI</a></li>
{:else if isOnAdmin}
<li><a href="/admin" onclick={() => (open = false)}>Oversikt</a></li>
<li><a href="/admin/pages" onclick={() => (open = false)}>Sider</a></li>
<li><a href="/admin/entities" onclick={() => (open = false)}>Entiteter</a></li>
{:else}
<li><a href="/" onclick={() => (open = false)}>Oversikt</a></li>
{#each pages as pg}
<li>
<a href="/p/{pg.slug}" onclick={() => (open = false)}>
{#if pg.icon}<span class="nav-icon">{pg.icon}</span>{/if}
{pg.title}
</a>
</li>
{/each}
{#if isWorkspaceAdmin}
<li class="nav-divider" role="separator"></li>
<li><a href="/admin/pages" onclick={() => (open = false)}>Sider</a></li>
<li><a href="/admin/entities" onclick={() => (open = false)}>Entiteter</a></li>
{/if}
{/if}
</ul>
<div class="sidebar-footer">

View file

@ -1,21 +1,35 @@
<script lang="ts">
import type { Workspace } from '$lib/server/db';
import { page } from '$app/state';
let { workspaces, active }: { workspaces: Workspace[]; active: Workspace | null } = $props();
let { workspaces, active, isServerAdmin = false }: { workspaces: Workspace[]; active: Workspace | null; isServerAdmin?: boolean } = $props();
let open = $state(false);
let isOnAdmin = $derived(page.url.pathname.startsWith('/admin'));
let isOnServerAdmin = $derived(page.url.pathname.startsWith('/server-admin'));
let hasDropdownItems = $derived(isServerAdmin || workspaces.length > 1);
</script>
{#if workspaces.length > 0}
<div class="switcher">
<button class="trigger" onclick={() => (open = !open)}>
<span class="workspace-name">{active?.name ?? 'Velg workspace'}</span>
<span class="workspace-name">{isOnServerAdmin ? 'Admin (server)' : isOnAdmin ? 'Admin' : active?.name ?? 'Velg workspace'}</span>
<span class="chevron">{open ? '▲' : '▼'}</span>
</button>
{#if open && workspaces.length > 1}
{#if open && hasDropdownItems}
<ul class="dropdown">
{#if isServerAdmin && !isOnServerAdmin}
<li>
<a href="/server-admin" onclick={() => (open = false)} class="admin-link">
Admin (server)
</a>
</li>
{#if workspaces.length > 0}
<li class="dropdown-divider"></li>
{/if}
{/if}
{#each workspaces as ws}
{#if ws.id !== active?.id}
{#if isOnAdmin || isOnServerAdmin || ws.id !== active?.id}
<li>
<a
href="/?switch_workspace={ws.id}"
@ -86,4 +100,14 @@
background: #262a3e;
color: #e1e4e8;
}
.admin-link {
color: #7dd3fc !important;
}
.dropdown-divider {
height: 1px;
background: #2d3148;
margin: 0.25rem 0;
}
</style>

View file

@ -9,10 +9,19 @@ export const load: LayoutServerLoad = async ({ locals }) => {
? await getUserWorkspaces(locals.user.id)
: [];
// Server-admin: owner i minst ett workspace (systemvide innstillinger)
const isServerAdmin = workspaces.some(w => w.role === 'owner');
// Workspace-admin: owner eller admin i gjeldende workspace
const currentWs = workspaces.find(w => w.id === locals.workspace?.id);
const isWorkspaceAdmin = currentWs?.role === 'owner' || currentWs?.role === 'admin';
return {
user: locals.user,
workspace: locals.workspace,
workspaces,
isServerAdmin,
isWorkspaceAdmin,
authProvider: isDev ? 'dev-login' : 'authentik'
};
};

View file

@ -5,7 +5,16 @@
let { data, children } = $props<{ data: LayoutData; children: any }>();
import { page } from '$app/state';
let sidebarOpen = $state(false);
let topbarTitle = $derived(
page.url.pathname.startsWith('/server-admin')
? 'Admin (server)'
: page.url.pathname.startsWith('/admin')
? 'Admin'
: data.workspace?.name ?? 'Sidelinja'
);
</script>
<svelte:head>
@ -22,7 +31,7 @@
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<span class="topbar-title">{data.workspace?.name ?? 'Sidelinja'}</span>
<span class="topbar-title">{topbarTitle}</span>
</header>
<Sidebar
@ -30,6 +39,8 @@
user={data.user}
workspace={data.workspace}
workspaces={data.workspaces}
isServerAdmin={data.isServerAdmin}
isWorkspaceAdmin={data.isWorkspaceAdmin}
authProvider={data.authProvider}
/>

View file

@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { sql } from '$lib/server/db';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) throw error(401, 'Ikke innlogget');
if (!locals.workspace) throw error(400, 'Ingen workspace valgt');
// Workspace-admin: må ha owner eller admin-rolle i gjeldende workspace
const rows = await sql`
SELECT 1 FROM workspace_members
WHERE user_id = ${locals.user.id}
AND workspace_id = ${locals.workspace.id}
AND role IN ('owner', 'admin')
LIMIT 1
`;
if (rows.length === 0) {
throw error(403, 'Ingen tilgang til workspace-admin');
}
};

View file

@ -0,0 +1,52 @@
<h2>Workspace-admin</h2>
<div class="admin-grid">
<a href="/admin/pages" class="admin-card">
<span class="admin-card__title">Sider</span>
<span class="admin-card__desc">Rediger workspace-sider og layout</span>
</a>
<a href="/admin/entities" class="admin-card">
<span class="admin-card__title">Entiteter</span>
<span class="admin-card__desc">Kunnskapsgraf-noder og relasjoner</span>
</a>
</div>
<style>
h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
}
.admin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.admin-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: #1e2235;
border: 1px solid #2d3148;
border-radius: 8px;
text-decoration: none;
transition: border-color 0.15s;
}
.admin-card:hover {
border-color: #3b82f6;
}
.admin-card__title {
font-size: 0.95rem;
font-weight: 600;
color: #e1e4e8;
}
.admin-card__desc {
font-size: 0.75rem;
color: #8b92a5;
}
</style>

View file

@ -4,19 +4,34 @@ import { sql } from '$lib/server/db';
/** PATCH /api/channels/:id/config — Oppdater kanal-konfig (merge) */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
if (!locals.user) error(401);
const updates = await request.json();
if (!updates || typeof updates !== 'object') error(400, 'Ugyldig config');
// Verifiser at kanalen tilhører workspace
const [channel] = await sql`
SELECT c.id, c.config
FROM channels c
JOIN nodes n ON n.id = c.id
WHERE c.id = ${params.id}::uuid AND n.workspace_id = ${locals.workspace.id}
// Server-admin (owner) kan endre alle kanaler på tvers av workspaces
const [isOwner] = await sql`
SELECT 1 FROM workspace_members
WHERE user_id = ${locals.user.id} AND role = 'owner'
LIMIT 1
`;
if (!channel) error(404, 'Kanal ikke funnet');
if (isOwner) {
const [channel] = await sql`
SELECT c.id FROM channels c WHERE c.id = ${params.id}::uuid
`;
if (!channel) error(404, 'Kanal ikke funnet');
} else if (locals.workspace) {
// Vanlig bruker: verifiser at kanalen tilhører gjeldende workspace
const [channel] = await sql`
SELECT c.id FROM channels c
JOIN nodes n ON n.id = c.id
WHERE c.id = ${params.id}::uuid AND n.workspace_id = ${locals.workspace.id}
`;
if (!channel) error(404, 'Kanal ikke funnet');
} else {
error(403, 'Ingen tilgang');
}
// Merge oppdateringer inn i eksisterende config
await sql`

View file

@ -0,0 +1,19 @@
import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { sql } from '$lib/server/db';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) throw error(401, 'Ikke innlogget');
// Server-admin: må ha owner-rolle i minst ett workspace
const rows = await sql`
SELECT 1 FROM workspace_members
WHERE user_id = ${locals.user.id}
AND role = 'owner'
LIMIT 1
`;
if (rows.length === 0) {
throw error(403, 'Ingen tilgang til server-admin');
}
};

View file

@ -0,0 +1,52 @@
<h2>Server-admin</h2>
<div class="admin-grid">
<a href="/server-admin/channels" class="admin-card">
<span class="admin-card__title">Kanaler</span>
<span class="admin-card__desc">Warmup-konfigurasjon og kanalinnstillinger</span>
</a>
<a href="/server-admin/ai" class="admin-card">
<span class="admin-card__title">AI</span>
<span class="admin-card__desc">Leverandører, modeller og prompts</span>
</a>
</div>
<style>
h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
}
.admin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.admin-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: #1e2235;
border: 1px solid #2d3148;
border-radius: 8px;
text-decoration: none;
transition: border-color 0.15s;
}
.admin-card:hover {
border-color: #3b82f6;
}
.admin-card__title {
font-size: 0.95rem;
font-weight: 600;
color: #e1e4e8;
}
.admin-card__desc {
font-size: 0.75rem;
color: #8b92a5;
}
</style>

View file

@ -1,10 +1,7 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { sql } from '$lib/server/db';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.workspace) error(404);
export const load: PageServerLoad = async () => {
const aliases = await sql`
SELECT id, alias, description, is_active, created_at
FROM ai_model_aliases
@ -24,6 +21,12 @@ export const load: PageServerLoad = async ({ locals }) => {
ORDER BY r.job_type
`;
const prompts = await sql`
SELECT action, system_prompt, description, updated_at
FROM ai_prompts
ORDER BY action
`;
const usage = await sql`
SELECT
model_alias,
@ -37,5 +40,5 @@ export const load: PageServerLoad = async ({ locals }) => {
ORDER BY total_tokens DESC
`;
return { aliases, providers, routing, usage };
return { aliases, providers, routing, prompts, usage };
};

View file

@ -26,6 +26,13 @@
description: string | null;
}
interface Prompt {
action: string;
system_prompt: string;
description: string | null;
updated_at: string;
}
interface UsageRow {
model_alias: string;
call_count: number;
@ -37,12 +44,15 @@
let aliases = $state<Alias[]>(data.aliases as Alias[]);
let providers = $state<Provider[]>(data.providers as Provider[]);
let routing = $state<Route[]>(data.routing as Route[]);
let prompts = $state<Prompt[]>(data.prompts as Prompt[]);
let usage = $state<UsageRow[]>(data.usage as UsageRow[]);
let saving = $state<string | null>(null);
let saved = $state<string | null>(null);
let errorMsg = $state('');
let configMsg = $state('');
let editingPrompt = $state<string | null>(null);
let editPromptText = $state('');
let expandedAlias = $state<string | null>(null);
// Ny provider-form
@ -201,6 +211,39 @@
}
}
function startEditPrompt(prompt: Prompt) {
editingPrompt = prompt.action;
editPromptText = prompt.system_prompt;
}
function cancelEditPrompt() {
editingPrompt = null;
editPromptText = '';
}
async function savePrompt(prompt: Prompt) {
saving = prompt.action;
errorMsg = '';
try {
const res = await fetch(`/api/admin/ai/prompts/${prompt.action}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system_prompt: editPromptText })
});
if (!res.ok) throw new Error('Feil ved lagring');
const updated = await res.json();
prompt.system_prompt = updated.system_prompt;
prompt.updated_at = updated.updated_at;
editingPrompt = null;
editPromptText = '';
markSaved(prompt.action);
} catch {
errorMsg = 'Kunne ikke lagre prompt';
} finally {
saving = null;
}
}
async function generateConfig() {
configMsg = '';
errorMsg = '';
@ -362,7 +405,55 @@
</div>
</section>
<!-- Seksjon 3: Tokenforbruk -->
<!-- Seksjon 3: System-prompts -->
<section>
<h3>System-prompts</h3>
<div class="table-list">
<div class="table-row table-row--header prompt-row">
<span class="col-action">Action</span>
<span class="col-desc">Beskrivelse</span>
<span class="col-chars">Tegn</span>
<span class="col-updated">Oppdatert</span>
<span class="col-edit"></span>
</div>
{#each prompts as prompt (prompt.action)}
<div class="table-row prompt-row">
<span class="col-action">{prompt.action}</span>
<span class="col-desc">{prompt.description ?? '—'}</span>
<span class="col-chars">{prompt.system_prompt.length}</span>
<span class="col-updated">{new Date(prompt.updated_at).toLocaleDateString('nb-NO')}</span>
<span class="col-edit">
{#if saving === prompt.action}
<span class="status-saving">...</span>
{:else if saved === prompt.action}
<span class="status-saved">OK</span>
{:else}
<button class="toggle-btn" onclick={() => startEditPrompt(prompt)}>Rediger</button>
{/if}
</span>
</div>
{#if editingPrompt === prompt.action}
<div class="prompt-editor">
<textarea
bind:value={editPromptText}
rows="12"
></textarea>
<div class="prompt-editor-footer">
<span class="prompt-char-count">{editPromptText.length} tegn</span>
<div class="prompt-editor-actions">
<button class="toggle-btn" onclick={cancelEditPrompt}>Avbryt</button>
<button class="add-btn" onclick={() => savePrompt(prompt)}>Lagre</button>
</div>
</div>
</div>
{/if}
{/each}
</div>
</section>
<!-- Seksjon 4: Tokenforbruk -->
<section>
<h3>Tokenforbruk (siste 30 dager)</h3>
{#if usage.length === 0}
@ -390,7 +481,7 @@
{/if}
</section>
<!-- Seksjon 4: Konfigurasjon -->
<!-- Seksjon 5: Konfigurasjon -->
<section>
<h3>Konfigurasjon</h3>
<div class="config-box">
@ -665,4 +756,70 @@
font-size: 0.8rem;
font-style: italic;
}
/* Prompt-seksjon */
.prompt-row {
grid-template-columns: 1.5fr 2.5fr 60px 90px 70px;
}
.col-action {
font-family: monospace;
}
.col-chars {
text-align: right;
font-variant-numeric: tabular-nums;
color: #8b92a5;
font-size: 0.75rem;
}
.col-updated {
color: #8b92a5;
font-size: 0.75rem;
}
.col-edit {
text-align: center;
}
.prompt-editor {
background: #0f1117;
padding: 0.75rem;
border-bottom: 1px solid #2d3148;
}
.prompt-editor textarea {
width: 100%;
background: #161822;
border: 1px solid #2d3148;
border-radius: 4px;
color: #e1e4e8;
padding: 0.5rem;
font-size: 0.8rem;
font-family: monospace;
line-height: 1.5;
resize: vertical;
}
.prompt-editor textarea:focus {
outline: none;
border-color: #3b82f6;
}
.prompt-editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.prompt-char-count {
color: #8b92a5;
font-size: 0.75rem;
}
.prompt-editor-actions {
display: flex;
gap: 0.5rem;
}
</style>

View file

@ -1,23 +1,19 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { sql } from '$lib/server/db';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.workspace) error(404);
export const load: PageServerLoad = async () => {
const channels = await sql`
SELECT
c.id,
c.name,
c.config,
pn.node_type AS parent_name,
w.name AS workspace_name,
(SELECT count(*)::int FROM messages m WHERE m.channel_id = c.id) AS message_count,
(SELECT max(m.created_at) FROM messages m WHERE m.channel_id = c.id) AS last_message_at
FROM channels c
JOIN nodes n ON n.id = c.id
LEFT JOIN nodes pn ON pn.id = c.parent_id
WHERE n.workspace_id = ${locals.workspace.id}
ORDER BY c.name
JOIN workspaces w ON w.id = n.workspace_id
ORDER BY w.name, c.name
`;
return { channels };

View file

@ -9,7 +9,7 @@
id: string;
name: string;
config: Record<string, unknown>;
parent_name: string | null;
workspace_name: string;
message_count: number;
last_message_at: string | null;
}
@ -19,6 +19,29 @@
let saved = $state<string | null>(null);
let errorMsg = $state('');
// Grupper kanaler etter workspace
let grouped = $derived.by(() => {
const map = new Map<string, ChannelRow[]>();
for (const ch of channels) {
const list = map.get(ch.workspace_name) ?? [];
list.push(ch);
map.set(ch.workspace_name, list);
}
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b, 'nb'));
});
// Alle workspaces er åpne som standard
let expandedWorkspaces = $state<Set<string>>(new Set(grouped.map(([name]) => name)));
function toggleWorkspace(name: string) {
if (expandedWorkspaces.has(name)) {
expandedWorkspaces.delete(name);
} else {
expandedWorkspaces.add(name);
}
expandedWorkspaces = new Set(expandedWorkspaces);
}
function getWarmupMode(config: Record<string, unknown>): WarmupMode {
return (config.warmup_mode as WarmupMode) ?? 'all';
}
@ -40,15 +63,6 @@
return d.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short', year: 'numeric' });
}
function modeLabel(mode: WarmupMode): string {
switch (mode) {
case 'all': return 'Alt';
case 'messages': return 'Siste N meldinger';
case 'days': return 'Siste N dager';
case 'none': return 'Ingen';
}
}
async function saveConfig(channel: ChannelRow) {
saving = channel.id;
errorMsg = '';
@ -103,62 +117,73 @@
<div class="error-msg">{errorMsg}</div>
{/if}
<div class="channel-list">
<div class="channel-row channel-row--header">
<span class="col-name">Kanal</span>
<span class="col-parent">Tilhører</span>
<span class="col-count">Meldinger</span>
<span class="col-last">Siste aktivitet</span>
<span class="col-warmup">Warmup</span>
<span class="col-value">Verdi</span>
<span class="col-status"></span>
{#each grouped as [wsName, wsChannels] (wsName)}
{@const wsActive = wsChannels.filter(c => getWarmupMode(c.config) !== 'none').length}
<div class="workspace-group">
<button class="workspace-header" onclick={() => toggleWorkspace(wsName)}>
<span class="workspace-chevron">{expandedWorkspaces.has(wsName) ? '▼' : '▶'}</span>
<span class="workspace-name">{wsName}</span>
<span class="workspace-stats">{wsActive}/{wsChannels.length} aktive</span>
</button>
{#if expandedWorkspaces.has(wsName)}
<div class="channel-list">
<div class="channel-row channel-row--header">
<span class="col-name">Kanal</span>
<span class="col-count">Meldinger</span>
<span class="col-last">Siste aktivitet</span>
<span class="col-warmup">Warmup</span>
<span class="col-value">Verdi</span>
<span class="col-status"></span>
</div>
{#each wsChannels as channel (channel.id)}
{@const mode = getWarmupMode(channel.config)}
{@const value = getWarmupValue(channel.config)}
<div class="channel-row" class:channel-row--inactive={mode === 'none'}>
<span class="col-name" title={channel.id}>{channel.name}</span>
<span class="col-count">{channel.message_count}</span>
<span class="col-last">{formatDate(channel.last_message_at)}</span>
<span class="col-warmup">
<select
value={mode}
onchange={(e) => setMode(channel, (e.target as HTMLSelectElement).value as WarmupMode)}
>
<option value="all">Alt</option>
<option value="messages">Siste N meldinger</option>
<option value="days">Siste N dager</option>
<option value="none">Ingen</option>
</select>
</span>
<span class="col-value">
{#if mode === 'messages' || mode === 'days'}
<input
type="number"
min="1"
value={value ?? ''}
onchange={(e) => setValue(channel, parseInt((e.target as HTMLInputElement).value) || 100)}
/>
{:else}
<span class="col-value-placeholder">{mode === 'all' ? 'Alt' : '—'}</span>
{/if}
</span>
<span class="col-status">
{#if saving === channel.id}
<span class="status-saving">...</span>
{:else if saved === channel.id}
<span class="status-saved">OK</span>
{/if}
</span>
</div>
{/each}
</div>
{/if}
</div>
{/each}
{#each channels as channel (channel.id)}
{@const mode = getWarmupMode(channel.config)}
{@const value = getWarmupValue(channel.config)}
<div class="channel-row" class:channel-row--inactive={mode === 'none'}>
<span class="col-name" title={channel.id}>{channel.name}</span>
<span class="col-parent">{channel.parent_name ?? '—'}</span>
<span class="col-count">{channel.message_count}</span>
<span class="col-last">{formatDate(channel.last_message_at)}</span>
<span class="col-warmup">
<select
value={mode}
onchange={(e) => setMode(channel, (e.target as HTMLSelectElement).value as WarmupMode)}
>
<option value="all">Alt</option>
<option value="messages">Siste N meldinger</option>
<option value="days">Siste N dager</option>
<option value="none">Ingen</option>
</select>
</span>
<span class="col-value">
{#if mode === 'messages' || mode === 'days'}
<input
type="number"
min="1"
value={value ?? ''}
onchange={(e) => setValue(channel, parseInt((e.target as HTMLInputElement).value) || 100)}
/>
{:else}
<span class="col-value-placeholder">{mode === 'all' ? 'Alt' : '—'}</span>
{/if}
</span>
<span class="col-status">
{#if saving === channel.id}
<span class="status-saving">...</span>
{:else if saved === channel.id}
<span class="status-saved">OK</span>
{/if}
</span>
</div>
{/each}
{#if channels.length === 0}
<p class="hint">Ingen kanaler funnet i dette workspacet.</p>
{/if}
</div>
{#if channels.length === 0}
<p class="hint">Ingen kanaler funnet.</p>
{/if}
<div class="info-box">
<strong>Warmup-moduser</strong>
@ -203,19 +228,59 @@
font-size: 0.85rem;
}
.workspace-group {
margin-bottom: 0.75rem;
}
.workspace-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.6rem 0.75rem;
background: #1a1d2e;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e1e4e8;
cursor: pointer;
font-size: 0.9rem;
text-align: left;
}
.workspace-header:hover {
border-color: #3b82f6;
}
.workspace-chevron {
font-size: 0.65rem;
color: #8b92a5;
width: 1em;
}
.workspace-name {
font-weight: 600;
flex: 1;
}
.workspace-stats {
font-size: 0.75rem;
color: #8b92a5;
}
.channel-list {
display: flex;
flex-direction: column;
gap: 1px;
background: #2d3148;
border: 1px solid #2d3148;
border-radius: 6px;
border-top: none;
border-radius: 0 0 6px 6px;
overflow: hidden;
}
.channel-row {
display: grid;
grid-template-columns: 2fr 1.5fr 80px 120px 160px 80px 40px;
grid-template-columns: 2fr 80px 120px 160px 80px 40px;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
@ -242,13 +307,6 @@
white-space: nowrap;
}
.col-parent {
color: #8b92a5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-count {
text-align: right;
font-variant-numeric: tabular-nums;
@ -303,6 +361,8 @@
padding: 1.5rem;
text-align: center;
background: #161822;
border: 1px solid #2d3148;
border-radius: 6px;
}
.info-box {
@ -341,7 +401,7 @@
.channel-row--header {
display: none;
}
.col-parent, .col-last {
.col-last {
display: none;
}
}