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:
parent
3ac9691830
commit
aafb121bf2
14 changed files with 594 additions and 115 deletions
40
migrations/0008_ai_prompts.sql
Normal file
40
migrations/0008_ai_prompts.sql
Normal 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 (2–3 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 på 2–3 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');
|
||||||
|
|
@ -2,18 +2,23 @@
|
||||||
import type { Workspace } from '$lib/server/db';
|
import type { Workspace } from '$lib/server/db';
|
||||||
import type { PageConfig } from '$lib/types/pages';
|
import type { PageConfig } from '$lib/types/pages';
|
||||||
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
open = $bindable(false),
|
open = $bindable(false),
|
||||||
user,
|
user,
|
||||||
workspace,
|
workspace,
|
||||||
workspaces,
|
workspaces,
|
||||||
|
isServerAdmin = false,
|
||||||
|
isWorkspaceAdmin = false,
|
||||||
authProvider
|
authProvider
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
user: { id: string; name: string; email: string; image?: string };
|
user: { id: string; name: string; email: string; image?: string };
|
||||||
workspace: Workspace | null;
|
workspace: Workspace | null;
|
||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
|
isServerAdmin?: boolean;
|
||||||
|
isWorkspaceAdmin?: boolean;
|
||||||
authProvider: string;
|
authProvider: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|
@ -81,6 +86,9 @@
|
||||||
((workspace?.settings as Record<string, unknown>)?.pages as PageConfig[]) ?? []
|
((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
|
// Beregn transform basert på state
|
||||||
let sidebarTransform = $derived.by(() => {
|
let sidebarTransform = $derived.by(() => {
|
||||||
if (swiping) {
|
if (swiping) {
|
||||||
|
|
@ -130,22 +138,34 @@
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<WorkspaceSwitcher {workspaces} active={workspace} />
|
<WorkspaceSwitcher {workspaces} active={workspace} {isServerAdmin} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
|
{#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>
|
<li><a href="/" onclick={() => (open = false)}>Oversikt</a></li>
|
||||||
{#each pages as page}
|
{#each pages as pg}
|
||||||
<li>
|
<li>
|
||||||
<a href="/p/{page.slug}" onclick={() => (open = false)}>
|
<a href="/p/{pg.slug}" onclick={() => (open = false)}>
|
||||||
{#if page.icon}<span class="nav-icon">{page.icon}</span>{/if}
|
{#if pg.icon}<span class="nav-icon">{pg.icon}</span>{/if}
|
||||||
{page.title}
|
{pg.title}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
<li class="nav-divider"></li>
|
{#if isWorkspaceAdmin}
|
||||||
<li><a href="/admin/pages" onclick={() => (open = false)}>Rediger sider</a></li>
|
<li class="nav-divider" role="separator"></li>
|
||||||
<li><a href="/admin/channels" onclick={() => (open = false)}>Kanaler</a></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>
|
</ul>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,35 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Workspace } from '$lib/server/db';
|
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 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>
|
</script>
|
||||||
|
|
||||||
{#if workspaces.length > 0}
|
{#if workspaces.length > 0}
|
||||||
<div class="switcher">
|
<div class="switcher">
|
||||||
<button class="trigger" onclick={() => (open = !open)}>
|
<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>
|
<span class="chevron">{open ? '▲' : '▼'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if open && workspaces.length > 1}
|
{#if open && hasDropdownItems}
|
||||||
<ul class="dropdown">
|
<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}
|
{#each workspaces as ws}
|
||||||
{#if ws.id !== active?.id}
|
{#if isOnAdmin || isOnServerAdmin || ws.id !== active?.id}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/?switch_workspace={ws.id}"
|
href="/?switch_workspace={ws.id}"
|
||||||
|
|
@ -86,4 +100,14 @@
|
||||||
background: #262a3e;
|
background: #262a3e;
|
||||||
color: #e1e4e8;
|
color: #e1e4e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-link {
|
||||||
|
color: #7dd3fc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #2d3148;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,19 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
? await getUserWorkspaces(locals.user.id)
|
? 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 {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
workspace: locals.workspace,
|
workspace: locals.workspace,
|
||||||
workspaces,
|
workspaces,
|
||||||
|
isServerAdmin,
|
||||||
|
isWorkspaceAdmin,
|
||||||
authProvider: isDev ? 'dev-login' : 'authentik'
|
authProvider: isDev ? 'dev-login' : 'authentik'
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,16 @@
|
||||||
|
|
||||||
let { data, children } = $props<{ data: LayoutData; children: any }>();
|
let { data, children } = $props<{ data: LayoutData; children: any }>();
|
||||||
|
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let sidebarOpen = $state(false);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -22,7 +31,7 @@
|
||||||
<line x1="3" y1="18" x2="21" y2="18" />
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="topbar-title">{data.workspace?.name ?? 'Sidelinja'}</span>
|
<span class="topbar-title">{topbarTitle}</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
|
@ -30,6 +39,8 @@
|
||||||
user={data.user}
|
user={data.user}
|
||||||
workspace={data.workspace}
|
workspace={data.workspace}
|
||||||
workspaces={data.workspaces}
|
workspaces={data.workspaces}
|
||||||
|
isServerAdmin={data.isServerAdmin}
|
||||||
|
isWorkspaceAdmin={data.isWorkspaceAdmin}
|
||||||
authProvider={data.authProvider}
|
authProvider={data.authProvider}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
21
web/src/routes/admin/+layout.server.ts
Normal file
21
web/src/routes/admin/+layout.server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
52
web/src/routes/admin/+page.svelte
Normal file
52
web/src/routes/admin/+page.svelte
Normal 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>
|
||||||
|
|
@ -4,19 +4,34 @@ import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
/** PATCH /api/channels/:id/config — Oppdater kanal-konfig (merge) */
|
/** PATCH /api/channels/:id/config — Oppdater kanal-konfig (merge) */
|
||||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
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();
|
const updates = await request.json();
|
||||||
if (!updates || typeof updates !== 'object') error(400, 'Ugyldig config');
|
if (!updates || typeof updates !== 'object') error(400, 'Ugyldig config');
|
||||||
|
|
||||||
// Verifiser at kanalen tilhører workspace
|
// 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 (isOwner) {
|
||||||
const [channel] = await sql`
|
const [channel] = await sql`
|
||||||
SELECT c.id, c.config
|
SELECT c.id FROM channels c WHERE c.id = ${params.id}::uuid
|
||||||
FROM channels c
|
`;
|
||||||
|
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
|
JOIN nodes n ON n.id = c.id
|
||||||
WHERE c.id = ${params.id}::uuid AND n.workspace_id = ${locals.workspace.id}
|
WHERE c.id = ${params.id}::uuid AND n.workspace_id = ${locals.workspace.id}
|
||||||
`;
|
`;
|
||||||
if (!channel) error(404, 'Kanal ikke funnet');
|
if (!channel) error(404, 'Kanal ikke funnet');
|
||||||
|
} else {
|
||||||
|
error(403, 'Ingen tilgang');
|
||||||
|
}
|
||||||
|
|
||||||
// Merge oppdateringer inn i eksisterende config
|
// Merge oppdateringer inn i eksisterende config
|
||||||
await sql`
|
await sql`
|
||||||
|
|
|
||||||
19
web/src/routes/server-admin/+layout.server.ts
Normal file
19
web/src/routes/server-admin/+layout.server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
52
web/src/routes/server-admin/+page.svelte
Normal file
52
web/src/routes/server-admin/+page.svelte
Normal 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>
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { sql } from '$lib/server/db';
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async () => {
|
||||||
if (!locals.workspace) error(404);
|
|
||||||
|
|
||||||
const aliases = await sql`
|
const aliases = await sql`
|
||||||
SELECT id, alias, description, is_active, created_at
|
SELECT id, alias, description, is_active, created_at
|
||||||
FROM ai_model_aliases
|
FROM ai_model_aliases
|
||||||
|
|
@ -24,6 +21,12 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||||
ORDER BY r.job_type
|
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`
|
const usage = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
model_alias,
|
model_alias,
|
||||||
|
|
@ -37,5 +40,5 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||||
ORDER BY total_tokens DESC
|
ORDER BY total_tokens DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return { aliases, providers, routing, usage };
|
return { aliases, providers, routing, prompts, usage };
|
||||||
};
|
};
|
||||||
|
|
@ -26,6 +26,13 @@
|
||||||
description: string | null;
|
description: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Prompt {
|
||||||
|
action: string;
|
||||||
|
system_prompt: string;
|
||||||
|
description: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface UsageRow {
|
interface UsageRow {
|
||||||
model_alias: string;
|
model_alias: string;
|
||||||
call_count: number;
|
call_count: number;
|
||||||
|
|
@ -37,12 +44,15 @@
|
||||||
let aliases = $state<Alias[]>(data.aliases as Alias[]);
|
let aliases = $state<Alias[]>(data.aliases as Alias[]);
|
||||||
let providers = $state<Provider[]>(data.providers as Provider[]);
|
let providers = $state<Provider[]>(data.providers as Provider[]);
|
||||||
let routing = $state<Route[]>(data.routing as Route[]);
|
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 usage = $state<UsageRow[]>(data.usage as UsageRow[]);
|
||||||
|
|
||||||
let saving = $state<string | null>(null);
|
let saving = $state<string | null>(null);
|
||||||
let saved = $state<string | null>(null);
|
let saved = $state<string | null>(null);
|
||||||
let errorMsg = $state('');
|
let errorMsg = $state('');
|
||||||
let configMsg = $state('');
|
let configMsg = $state('');
|
||||||
|
let editingPrompt = $state<string | null>(null);
|
||||||
|
let editPromptText = $state('');
|
||||||
let expandedAlias = $state<string | null>(null);
|
let expandedAlias = $state<string | null>(null);
|
||||||
|
|
||||||
// Ny provider-form
|
// 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() {
|
async function generateConfig() {
|
||||||
configMsg = '';
|
configMsg = '';
|
||||||
errorMsg = '';
|
errorMsg = '';
|
||||||
|
|
@ -362,7 +405,55 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h3>Tokenforbruk (siste 30 dager)</h3>
|
<h3>Tokenforbruk (siste 30 dager)</h3>
|
||||||
{#if usage.length === 0}
|
{#if usage.length === 0}
|
||||||
|
|
@ -390,7 +481,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Seksjon 4: Konfigurasjon -->
|
<!-- Seksjon 5: Konfigurasjon -->
|
||||||
<section>
|
<section>
|
||||||
<h3>Konfigurasjon</h3>
|
<h3>Konfigurasjon</h3>
|
||||||
<div class="config-box">
|
<div class="config-box">
|
||||||
|
|
@ -665,4 +756,70 @@
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-style: italic;
|
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>
|
</style>
|
||||||
|
|
@ -1,23 +1,19 @@
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { sql } from '$lib/server/db';
|
import { sql } from '$lib/server/db';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async () => {
|
||||||
if (!locals.workspace) error(404);
|
|
||||||
|
|
||||||
const channels = await sql`
|
const channels = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
c.id,
|
c.id,
|
||||||
c.name,
|
c.name,
|
||||||
c.config,
|
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 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
|
(SELECT max(m.created_at) FROM messages m WHERE m.channel_id = c.id) AS last_message_at
|
||||||
FROM channels c
|
FROM channels c
|
||||||
JOIN nodes n ON n.id = c.id
|
JOIN nodes n ON n.id = c.id
|
||||||
LEFT JOIN nodes pn ON pn.id = c.parent_id
|
JOIN workspaces w ON w.id = n.workspace_id
|
||||||
WHERE n.workspace_id = ${locals.workspace.id}
|
ORDER BY w.name, c.name
|
||||||
ORDER BY c.name
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return { channels };
|
return { channels };
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
parent_name: string | null;
|
workspace_name: string;
|
||||||
message_count: number;
|
message_count: number;
|
||||||
last_message_at: string | null;
|
last_message_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +19,29 @@
|
||||||
let saved = $state<string | null>(null);
|
let saved = $state<string | null>(null);
|
||||||
let errorMsg = $state('');
|
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 {
|
function getWarmupMode(config: Record<string, unknown>): WarmupMode {
|
||||||
return (config.warmup_mode as WarmupMode) ?? 'all';
|
return (config.warmup_mode as WarmupMode) ?? 'all';
|
||||||
}
|
}
|
||||||
|
|
@ -40,15 +63,6 @@
|
||||||
return d.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short', year: 'numeric' });
|
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) {
|
async function saveConfig(channel: ChannelRow) {
|
||||||
saving = channel.id;
|
saving = channel.id;
|
||||||
errorMsg = '';
|
errorMsg = '';
|
||||||
|
|
@ -103,10 +117,19 @@
|
||||||
<div class="error-msg">{errorMsg}</div>
|
<div class="error-msg">{errorMsg}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#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-list">
|
||||||
<div class="channel-row channel-row--header">
|
<div class="channel-row channel-row--header">
|
||||||
<span class="col-name">Kanal</span>
|
<span class="col-name">Kanal</span>
|
||||||
<span class="col-parent">Tilhører</span>
|
|
||||||
<span class="col-count">Meldinger</span>
|
<span class="col-count">Meldinger</span>
|
||||||
<span class="col-last">Siste aktivitet</span>
|
<span class="col-last">Siste aktivitet</span>
|
||||||
<span class="col-warmup">Warmup</span>
|
<span class="col-warmup">Warmup</span>
|
||||||
|
|
@ -114,12 +137,11 @@
|
||||||
<span class="col-status"></span>
|
<span class="col-status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each channels as channel (channel.id)}
|
{#each wsChannels as channel (channel.id)}
|
||||||
{@const mode = getWarmupMode(channel.config)}
|
{@const mode = getWarmupMode(channel.config)}
|
||||||
{@const value = getWarmupValue(channel.config)}
|
{@const value = getWarmupValue(channel.config)}
|
||||||
<div class="channel-row" class:channel-row--inactive={mode === 'none'}>
|
<div class="channel-row" class:channel-row--inactive={mode === 'none'}>
|
||||||
<span class="col-name" title={channel.id}>{channel.name}</span>
|
<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-count">{channel.message_count}</span>
|
||||||
<span class="col-last">{formatDate(channel.last_message_at)}</span>
|
<span class="col-last">{formatDate(channel.last_message_at)}</span>
|
||||||
<span class="col-warmup">
|
<span class="col-warmup">
|
||||||
|
|
@ -154,11 +176,14 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
{#if channels.length === 0}
|
|
||||||
<p class="hint">Ingen kanaler funnet i dette workspacet.</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if channels.length === 0}
|
||||||
|
<p class="hint">Ingen kanaler funnet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<strong>Warmup-moduser</strong>
|
<strong>Warmup-moduser</strong>
|
||||||
|
|
@ -203,19 +228,59 @@
|
||||||
font-size: 0.85rem;
|
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 {
|
.channel-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: #2d3148;
|
background: #2d3148;
|
||||||
border: 1px solid #2d3148;
|
border: 1px solid #2d3148;
|
||||||
border-radius: 6px;
|
border-top: none;
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-row {
|
.channel-row {
|
||||||
display: grid;
|
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;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
|
@ -242,13 +307,6 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-parent {
|
|
||||||
color: #8b92a5;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-count {
|
.col-count {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
@ -303,6 +361,8 @@
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #161822;
|
background: #161822;
|
||||||
|
border: 1px solid #2d3148;
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
|
|
@ -341,7 +401,7 @@
|
||||||
.channel-row--header {
|
.channel-row--header {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.col-parent, .col-last {
|
.col-last {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue