From 00755c88a90f723fac080fb18e265cb7de6665f6 Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Wed, 27 May 2026 13:36:53 +0200 Subject: [PATCH 1/3] feat: add AI Bibliotek research project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared library where Danish public authorities publish and "hjemtage" AI assistants (initially OpenWebUI-based) so local use cases scale nationally. Adapted from the Dansk Viden til Dansk AI SPA with a teal reskin: katalog with facets (kommune, sprogmodel, rammeværk, datafølsomhed), assistant detail with modelkort/readme/vidensopskrift and versioned JSON export, and a "del assistent" flow. Includes report, phased estimeringsnotat with a drift/driftsomkostninger note, and 10 seed assistants (2 from Aarhus). Co-authored-by: Claude --- CHANGELOG.md | 5 + CLAUDE.md | 1 + docs/.vitepress/sidebar.mts | 12 + docs/index.md | 4 + .../projects/ai-bibliotek/estimeringsnotat.md | 54 + docs/projects/ai-bibliotek/index.md | 129 ++ docs/projects/ai-bibliotek/mocks.md | 8 + .../ai-bibliotek/mocks/css/styles.css | 1347 +++++++++++++++++ .../mocks/data/seed-assistants.js | 570 +++++++ .../projects/ai-bibliotek/mocks/index.html | 61 + .../projects/ai-bibliotek/mocks/js/app.js | 131 ++ .../projects/ai-bibliotek/mocks/js/auth.js | 60 + .../projects/ai-bibliotek/mocks/js/catalog.js | 99 ++ .../ai-bibliotek/mocks/js/collections.js | 71 + .../projects/ai-bibliotek/mocks/js/icons.js | 32 + .../projects/ai-bibliotek/mocks/js/store.js | 108 ++ .../projects/ai-bibliotek/mocks/js/util.js | 111 ++ .../mocks/js/views/_assistant-card.js | 70 + .../mocks/js/views/_collection-modal.js | 93 ++ .../mocks/js/views/collections.js | 170 +++ .../ai-bibliotek/mocks/js/views/detail.js | 272 ++++ .../ai-bibliotek/mocks/js/views/favorites.js | 35 + .../ai-bibliotek/mocks/js/views/home.js | 153 ++ .../ai-bibliotek/mocks/js/views/login.js | 97 ++ .../ai-bibliotek/mocks/js/views/search.js | 246 +++ .../ai-bibliotek/mocks/js/views/upload.js | 324 ++++ .../ai-bibliotek/mocks/js/views/uploads.js | 44 + 27 files changed, 4307 insertions(+) create mode 100644 docs/projects/ai-bibliotek/estimeringsnotat.md create mode 100644 docs/projects/ai-bibliotek/index.md create mode 100644 docs/projects/ai-bibliotek/mocks.md create mode 100644 docs/public/projects/ai-bibliotek/mocks/css/styles.css create mode 100644 docs/public/projects/ai-bibliotek/mocks/data/seed-assistants.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/index.html create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/app.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/auth.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/catalog.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/collections.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/icons.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/store.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/util.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/views/_assistant-card.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/views/_collection-modal.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/views/collections.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/views/detail.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/views/favorites.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/views/home.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/views/login.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/views/search.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/views/upload.js create mode 100644 docs/public/projects/ai-bibliotek/mocks/js/views/uploads.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa2cae..59dc550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added — AI Bibliotek Project +- Prototype for a shared library where Danish public authorities publish and "hjemtage" (take home) AI assistants — initially OpenWebUI-based — so local use cases can scale nationally +- Single-page mock adapted from the Dansk Viden til Dansk AI SPA: self-signup, katalog with search and facets (kommune, sprogmodel, rammeværk, datafølsomhed), assistant detail with modelkort/readme/vidensopskrift and versioned JSON export, plus a "del assistent" flow — `localStorage` backend, teal primary color +- Report (`index.md`), estimeringsnotat with phased estimate and a drift/driftsomkostninger note, and mocks listing + ### Added — Solceller i Universitetsparken hearing detail mock (deltag-aarhus) - Second interactive prototype for deltag.aarhus.dk under `docs/public/projects/deltag-aarhus/mocks/uniparken/` — Lokalplan nr. 1245 om et solcelleanlæg på Universitetsparkens fællesplæne, med samme funktionalitet som Vosnæs-prototypen (784 høringssvar, åben/afsluttet variant, alle modaler, kort, statistik) - Introduced a per-mock config layer (`mocks/js/config.js` + per-mock `window.DeltagMock.config` override) so plan number, deadlines, map center/clusters and active dataset are swappable without duplicating shared CSS/JS. The Vosnæs mock continues to use the file's defaults. diff --git a/CLAUDE.md b/CLAUDE.md index 93078cd..ce5d42b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ Taskfile.yml # Task automation (dev, build, lint | `opkraevningsoverblik` | Opkrævningsoverblik | No | | `roboway` | Roboway | No | | `dansk-viden-til-dansk-ai` | Dansk Viden til Dansk AI | No | +| `ai-bibliotek` | AI Bibliotek | No | ## Conventions diff --git a/docs/.vitepress/sidebar.mts b/docs/.vitepress/sidebar.mts index ef8e5e7..c07ede6 100644 --- a/docs/.vitepress/sidebar.mts +++ b/docs/.vitepress/sidebar.mts @@ -108,6 +108,17 @@ const danskVidenTilDanskAi: DefaultTheme.SidebarItem[] = [ }, ] +const aiBibliotek: DefaultTheme.SidebarItem[] = [ + { + text: 'AI Bibliotek', + items: [ + { text: 'Overview', link: '/projects/ai-bibliotek/' }, + { text: 'Estimeringsnotat', link: '/projects/ai-bibliotek/estimeringsnotat' }, + { text: 'Interactive Mocks', link: '/projects/ai-bibliotek/mocks' }, + ], + }, +] + const designSystem: DefaultTheme.SidebarItem[] = [ { text: 'Design System', @@ -134,6 +145,7 @@ export function sidebar(): DefaultTheme.Sidebar { '/projects/opkraevningsoverblik/': opkraevningsoverblik, '/projects/roboway/': roboway, '/projects/dansk-viden-til-dansk-ai/': danskVidenTilDanskAi, + '/projects/ai-bibliotek/': aiBibliotek, '/projects/design-system/': designSystem, } } diff --git a/docs/index.md b/docs/index.md index b030444..00307a3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,4 +46,8 @@ features: details: Fælles offentlig service til indsamling og deling af danske publikationer som grundlag for dansk AI — med AI-katalogisering, syv rettighedsniveauer og delbare samlinger. link: /projects/dansk-viden-til-dansk-ai/ linkText: View project + - title: AI Bibliotek + details: Fælles bibliotek hvor danske myndigheder kan dele og hjemtage AI-assistenter — med katalog, søgning, modelkort, vidensopskrift og versioneret JSON-eksport, så lokale use cases kan skaleres nationalt. + link: /projects/ai-bibliotek/ + linkText: View project --- diff --git a/docs/projects/ai-bibliotek/estimeringsnotat.md b/docs/projects/ai-bibliotek/estimeringsnotat.md new file mode 100644 index 0000000..c6b0cb0 --- /dev/null +++ b/docs/projects/ai-bibliotek/estimeringsnotat.md @@ -0,0 +1,54 @@ +**Project:** AI Bibliotek · **Status:** Estimat (pitch) + +# AI Bibliotek — Estimeringsnotat + +**Dato:** maj 2026 +**Projekt:** AI Bibliotek — deling af AI-assistenter på tværs af offentlige myndigheder +**Fase:** Pitch / oplæg (uge 23) + +::: info Bemærkning +Notatet er bevidst holdt på et overordnet niveau. Det er et oplæg, ikke et tilbud — formålet er at give en størrelsesorden og efterlade manøvrerum i den videre proces. Estimaterne er grove og afhænger af de afklaringer, der er beskrevet i [hovednotatet](./). +::: + +--- + +## Overordnet estimat + +Estimatet er delt i faser, så projektet kan startes småt og udvides, efterhånden som koncept og behov afklares. + +| Fase | Indhold | Estimat | Tidshorisont | +|---|---|---|---| +| **Fase 0 — Afklaring** | Standard for assistent-format, governance, hvad er åbent vs. bag login, rettigheder til delt JSON | 60–120 t. | 3–5 uger | +| **Fase 1 — MVP (v0.1)** | Webapp, simpel brugerstyring, katalog med søgning/filtre, assistent-side, JSON-eksport, del/upload-flow, OpenWebUI-tag | 350–550 t. | 2–3 mdr. | +| **Fase 2 — Udvidelser** | Flere versioner i drift, AI-genererede tags, forbedret datagrundlags-opskrift, flere rammeværk | 250–450 t. | 2–3 mdr. | +| **Fase 3 — Økosystem (på sigt)** | Tools & skills, ratings, API, abonnement/advisering, testcases | 400–800 t. | Løbende | +| **Sum (fase 0–2)** | | **660–1.120 t.** | **ca. 5–8 mdr.** | + +Fase 3 er medtaget for fuldstændighedens skyld og er ikke en del af det anbefalede første skridt. + +### Anbefalet første skridt + +Fase 0 + Fase 1 leverer en brugbar MVP, der kan opfylde succeskriteriet: to AarhusAI-assistenter delt i biblioteket og inviterede OS2ai-testkommuner. Estimat **410–670 t.**, tidshorisont **ca. 3–4 måneder**. + +--- + +## Opmærksomhedspunkt: drift og driftsomkostninger + +Et bibliotek af denne type er ikke et "byg og glem"-projekt. Den væsentligste langsigtede omkostning er **drift**, ikke selve udviklingen. Følgende skal afklares tidligt: + +- **Hosting og ejerskab.** Hvem driver og betaler for platformen? OS2/OS2ai-fællesskab, en værtskommune, eller en central aktør? Driften skal have en fast ejer, ellers forfalder biblioteket. +- **Løbende vedligehold.** Sikkerhedsopdateringer, afhængigheder, brugerstyring og support koster typisk **15–25 % af udviklingsestimatet pr. år**. For fase 0–2 svarer det groft til **100–280 t./år**. +- **Indholdsmoderering og kvalitet.** Delte assistenter skal kunne reviewes — er JSON'en trygt at dele, er metadata korrekt, er datafølsomheden vurderet rigtigt? Det kræver en governance-proces og dermed tid, ikke kun teknik. +- **Versionering over tid.** Når en kommune opdaterer en delt assistent, skal abonnenter adviseres og gamle versioner håndteres. Det vokser med antallet af assistenter og kommuner. +- **Compute.** Selve assistenterne kører lokalt i den enkelte kommune — biblioteket hoster ikke modeller. Driftsomkostningen til AI-inferens ligger derfor hos den hjemtagende kommune, ikke hos biblioteket. Det holder bibliotekets egne driftsomkostninger lave og forudsigelige. + +**Konklusion:** udviklingsomkostningen er en engangsinvestering; driften er en løbende forpligtelse, der skal have et budget og en ejer fra dag ét. + +--- + +## Forudsætninger og forbehold + +- Estimaterne forudsætter genbrug af eksisterende ITKdev-fundament (auth, design, hosting-mønstre). +- De forudsætter, at OpenWebUIs eksportformat kan bruges som udgangspunkt for assistent-JSON. +- Større usikkerheder: standardisering på tværs af rammeværk, juridisk afklaring af deling af systemprompter/datagrundlag, og governance-modellen. +- Tallene er grove pitch-estimater og skal kvalificeres i fase 0. diff --git a/docs/projects/ai-bibliotek/index.md b/docs/projects/ai-bibliotek/index.md new file mode 100644 index 0000000..e1f2132 --- /dev/null +++ b/docs/projects/ai-bibliotek/index.md @@ -0,0 +1,129 @@ +**Project:** AI Bibliotek · **Status:** Prototype · **Date:** May 2026 + +# AI Bibliotek + +**Et fælles bibliotek hvor danske myndigheder kan dele og hjemtage AI-assistenter — så lokale use cases kan skaleres op på nationalt niveau.** + +--- + +## Baggrund + +I Storskalaprojektet udvikles AI-assistenter til konkrete kommunale opgaver — borgerservice, sagsbehandling, journalisering, mødereferater og meget mere. Assistenterne bygges i dag typisk lokalt i den enkelte kommune, ofte oven på OpenWebUI, og bliver sjældent delt på tværs. Det betyder, at den samme assistent reelt udvikles flere gange parallelt i forskellige kommuner. + +Samtidig efterspørger OS2ai-kommunerne en måde at genbruge hinandens arbejde på: hvis Aarhus har bygget en velfungerende FAQ-assistent til borgerservice, bør Odense kunne hjemtage den, tilpasse den lokalt og tage den i brug — uden at starte forfra. + +Et AI-bibliotek kan udstille assistenterne ét sted, så de kan fremsøges, vurderes og hjemtages. På sigt kan det samme bibliotek rumme tools, skills og hele økosystemet omkring assistenterne — og måske brede sig ud over kommunegrænserne til hele det offentlige Danmark. + +## Formål + +Prototypen undersøger spørgsmålet: **Hvordan kan et fælles bibliotek for delte AI-assistenter se ud i praksis — så en assistent udviklet i én kommune kan hjemtages og anvendes i en anden?** + +Biblioteket skal understøtte to bevægelser: + +- **Dele** — en kommune udstiller en assistent, den har udviklet lokalt, sammen med dens datagrundlag (i opskriftsform), modelkort og readme +- **Hjemtage** — en anden kommune fremsøger, vurderer og henter assistenten (eksporterer dens JSON) og bygger sit eget lokale datagrundlag efter opskriften + +## Hvad prototypen viser + +Prototypen er en single-page application, der bruger `localStorage` som backend og simulerer AI-understøttet metadata med en kort spinner. Den er et **visuelt og funktionelt diskussionsgrundlag** — teksten er illustrativ (lorem-agtig, men realistisk), netop fordi det er en visualisering af et koncept og ikke en færdig løsning. + +### Forsiden + +Hero med søgefelt, kort introduktion og statistik (antal assistenter, deltagende kommuner, antal sprogmodeller). En rail med "Senest opdateret" og en sektion der **teaser fremtidige muligheder** (tools, skills, ratings, API, abonnement, testcases) som "kommer snart". + +### Registrering og login + +Simpel brugerflade hvor en medarbejder kan oprette en konto (i første omgang opret sig selv) eller logge ind. Brugere gemmes i `localStorage`. Ingen reel auth — kun til demoformål. + +### Katalog og søgning + +Fritekstsøgning kombineret med facetter: **oprindelseskommune, sprogmodel, rammeværk** (i dag OpenWebUI) og **datafølsomhed** (almindelige personoplysninger / fortrolige / personfølsomme). Resultater vises som kort med badges. + +### Assistent-side + +Detaljevisning af en enkelt assistent med adskilte visninger: + +- **Beskrivelse** — assistentens formål +- **Modelkort** — hvilken sprogmodel, kontekstvindue, parametre og hvilke hensyn der er til assistenten +- **Readme** — praktisk dokumentation +- **Viden** — *i opskriftsform*: hvad den enkelte kommune selv skal levere af datagrundlag lokalt (de faktiske vidensfiler kan sjældent deles på tværs af kommuner) +- **JSON** — vælg blandt **flere versioner** af assistenten og **eksportér** den valgte version som en JSON-fil (datagrundlag svarende til OpenWebUIs eksportformat) + +Metadata i sidepanelet: oprindelseskommune, sprogmodel, rammeværk, datafølsomhed, dato for oprettelse og opdatering samt antal versioner. En **OpenWebUI-tag** markerer rammeværket, så biblioteket med tiden kan rumme assistenter til flere typer rammeværk. + +### Del assistent + +Et flow hvor en kommune udstiller en assistent: indsæt eller upload assistentens OWUI-JSON → systemet foreslår metadata (navn, beskrivelse, tags, sprogmodel) → kommunen gennemgår, vælger datafølsomhed og udgiver. Kvittering med permalink. + +### Mine assistenter & favoritter + +Personligt overblik over egne delte assistenter samt en favoritliste, begge gemt i `localStorage`. + +--- + +## Krav (v0.1) + +- Webapplikation med simpel brugerstyring (opret sig selv) +- En assistent består af — og skal kunne udvides uendeligt med — mindst: eksporterbar **JSON** (datagrundlag svarende til OpenWebUI), **viden/filer i opskriftsform**, **modelkort**, **readme** og en **beskrivelse** +- Metadata: sprogmodel, viden, dato for oprettelse/opdatering, oprindelseskommune samt om assistenten er beregnet til personfølsomme / fortrolige / almindelige personoplysninger +- Flere versioner af JSON på samme assistent +- Det skal være muligt at tilføje nye entiteter (fx tools og skills) på sigt +- En assistent skal kunne tagges med sit rammeværk (OpenWebUI i dag), så biblioteket senere kan rumme flere rammeværk +- AI-genererede tags (evt.) +- Forskellige filtrerings- og søgemuligheder + +--- + +## Afklarende spørgsmål + +Prototypen er et diskussionsgrundlag, ikke en implementeringsklar løsning. En række forhold skal afklares inden et reelt system bygges. + +### Hvad må udstilles åbent — og hvad skal bag login? + +I prototypen er **kataloget offentligt at browse**, mens det at **dele og eksportere** kræver login. Det er en antagelse, ikke en beslutning. Skal selve eksistensen af en assistent (navn, formål, kommune) være åben, mens JSON og modelkort er bag login? Eller skal hele biblioteket være lukket for ikke-myndigheder? + +### Hvad er der i JSON-filerne? + +Kan vi ukritisk dele indholdet af en OWUI-assistents JSON? Systemprompter kan indeholde interne formuleringer, henvisninger til konkrete sager eller forudsætninger, der ikke bør deles bredt. Der skal tages stilling til, hvad der reviewes inden deling. + +### Hvordan fungerer download og upload på tværs af forskellige løsninger? + +Biblioteket forudsætter, at en assistent eksporteret ét sted kan importeres et andet sted. Hvor ens skal afsender- og modtagermiljøet være? Hvad sker der, når en kommune kører en anden version af OpenWebUI — eller et helt andet rammeværk? + +### Er der en standard for assistenter? + +Findes der en standard (JSON, XML eller andet) for, hvordan en assistent beskrives og overdrages? OpenWebUIs eksportformat er udgangspunktet, men en bibliotek-standard på tværs af rammeværk vil kræve en aftalt struktur. + +### Datagrundlaget + +De faktiske vidensfiler kan sjældent deles på tværs af kommuner (interne data, persondata, rettigheder). Prototypen løser det ved at beskrive datagrundlaget **i opskriftsform**, så den enkelte kommune selv kan opbygge sit lokale grundlag. Er opskriftsformen tilstrækkelig, eller skal der mere standardisering til? + +--- + +## På sigt + +Følgende er ikke en del af v0.1, men biblioteket skal kunne rumme det. I prototypen er mulighederne teaset som "kommer snart": + +- Deling af **tools** og **skills** +- Mulighed for at **rate** assistenter, tools og skills +- **API** så man kan oprette og vedligeholde assistenter fra egen løsning +- Mulighed for at **abonnere** på en assistent og få besked ved ændringer (og dermed hente ny version) +- Deling af **testcases og resultater** på en assistent + +--- + +## Succeskriterie + +At der er delt **to AI-assistenter fra AarhusAI** i AI-biblioteket. Derudover inviteres andre OS2ai-kommuner til at være testkommuner — både til at dele lokalt udviklede assistenter og til at tage delte assistenter i anvendelse lokalt. + +--- + +## Drift og økonomi + +Se [Estimeringsnotat](./estimeringsnotat) for økonomisk estimat, tidshorisont og opmærksomhedspunkter om drift og driftsomkostninger. + +--- + +## Interaktiv prototype + +Åbn prototypen ↗ diff --git a/docs/projects/ai-bibliotek/mocks.md b/docs/projects/ai-bibliotek/mocks.md new file mode 100644 index 0000000..c755d91 --- /dev/null +++ b/docs/projects/ai-bibliotek/mocks.md @@ -0,0 +1,8 @@ +**Project:** AI Bibliotek + +# Interaktive Mocks + +--- + +**AI Bibliotek — prototype ↗** +Single-page prototype hvor danske myndigheder kan dele og hjemtage AI-assistenter. Katalog med søgning og facetter (kommune, sprogmodel, rammeværk, datafølsomhed), assistent-side med modelkort, readme, vidensopskrift og versioneret JSON-eksport, samt et flow til at dele en assistent. Bruger `localStorage` som backend. diff --git a/docs/public/projects/ai-bibliotek/mocks/css/styles.css b/docs/public/projects/ai-bibliotek/mocks/css/styles.css new file mode 100644 index 0000000..d90adf0 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/css/styles.css @@ -0,0 +1,1347 @@ +/* AI Bibliotek – prototype styles */ + +:root { + --color-bg: #f4efe7; + --color-surface: #ffffff; + --color-surface-2: #fbf9f5; + --color-border: #e3ddd1; + --color-border-strong: #c8bea9; + --color-line: #ddd5c4; + --color-ink: #11192a; + --color-text: #1c2433; + --color-text-muted: #5b6478; + --color-primary: #0f766e; + --color-primary-hover: #0c5e57; + --color-primary-ink: #ffffff; + --color-accent: #c89b3c; + --color-accent-soft: #f0e3c2; + --color-accent-deep: #8a6a1f; + --color-danger: #a4302a; + --color-warn: #b27a14; + --color-ok: #2f6b3a; + --color-focus: #2f6fb6; + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 20px; + --shadow-sm: 0 1px 0 var(--color-line); + --shadow-md: 0 12px 32px -16px rgba(17, 25, 42, 0.18); + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 48px; + --space-8: 64px; + --gutter: clamp(20px, 4vw, 56px); + + --font-display: "Fraunces", "Source Serif Pro", "Iowan Old Style", Georgia, serif; + --font-sans: "Geist", "IBM Plex Sans", -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --dur-fast: 120ms; + --dur: 220ms; + + --container-max: 1180px; + --container-wide: 1600px; +} + +* { box-sizing: border-box; } + +html, body { height: 100%; } + +body { + margin: 0; + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.6; + color: var(--color-text); + background: var(--color-bg); + display: flex; + flex-direction: column; + min-height: 100vh; + font-feature-settings: "ss01", "ss02"; +} + +a { color: var(--color-primary); } +a:hover { color: var(--color-primary-hover); } + +h1, h2, h3, h4 { + font-family: var(--font-display); + font-weight: 500; + line-height: 1.15; + letter-spacing: -0.01em; + margin: 0 0 var(--space-3); + color: var(--color-ink); + font-variation-settings: "opsz" 36, "SOFT" 30; +} +h1 { font-size: clamp(2rem, 3vw, 2.6rem); letter-spacing: -0.02em; font-variation-settings: "opsz" 96, "SOFT" 40; } +h2 { font-size: 1.55rem; } +h3 { font-size: 1.2rem; } + +p { margin: 0 0 var(--space-3); } + +.container { + width: 100%; + max-width: var(--container-max); + margin: 0 auto; + padding: 0 var(--gutter); +} +.container-wide { + width: 100%; + max-width: var(--container-wide); + margin: 0 auto; + padding: 0 var(--gutter); +} + +main.container, +main.container-wide { + flex: 1 0 auto; + padding-top: clamp(24px, 4vw, 56px); + padding-bottom: clamp(32px, 5vw, 72px); +} + +/* Reusable display utilities */ +.eyebrow { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-family: var(--font-sans); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.16em; + word-spacing: 0.2em; + white-space: nowrap; + text-transform: uppercase; + color: var(--color-accent-deep); +} +.eyebrow::before { + content: ""; + display: inline-block; + width: 24px; + height: 1px; + background: var(--color-accent-deep); +} +.display { font-family: var(--font-display); font-weight: 500; color: var(--color-ink); letter-spacing: -0.01em; } +.serif { font-family: var(--font-display); } +.mono { font-family: var(--font-mono); } +.lede { font-size: 1.125rem; line-height: 1.6; color: var(--color-text); max-width: 60ch; } + +/* Page-enter motion — staggered fade-up on the first few children of the view */ +@keyframes fade-up { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +#view-root > * { + animation: fade-up var(--dur) var(--ease-out) both; +} +#view-root > *:nth-child(1) { animation-delay: 0ms; } +#view-root > *:nth-child(2) { animation-delay: 40ms; } +#view-root > *:nth-child(3) { animation-delay: 80ms; } +#view-root > *:nth-child(4) { animation-delay: 120ms; } +@media (prefers-reduced-motion: reduce) { + #view-root > * { animation: none; } +} + +/* Skip link */ +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--color-primary); + color: var(--color-primary-ink); + padding: var(--space-2) var(--space-3); +} +.skip-link:focus { left: var(--space-3); top: var(--space-3); z-index: 100; } + +/* Header */ +.site-header { + background: var(--color-surface); + border-bottom: 1px solid var(--color-line); + position: sticky; + top: 32px; /* clear the shared mock banner */ + z-index: 20; +} +.header-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--space-5); + min-height: 64px; +} +.brand { + display: flex; + align-items: center; + gap: var(--space-3); + text-decoration: none; + color: var(--color-ink); +} +.brand-mark { + width: 32px; height: 32px; + background: var(--color-primary); + color: var(--color-primary-ink); + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + font-family: var(--font-display); + font-weight: 600; + font-size: 1.1rem; + line-height: 1; +} +.brand-text { display: flex; flex-direction: column; line-height: 1.05; } +.brand-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.15rem; + letter-spacing: -0.01em; + color: var(--color-ink); + border-bottom: 1px solid var(--color-accent-deep); + padding-bottom: 2px; + align-self: flex-start; +} +.brand-sub { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--color-text-muted); + margin-top: 4px; +} + +.site-nav { + display: flex; + gap: var(--space-6); + justify-content: center; +} +.site-nav a { + text-decoration: none; + color: var(--color-text); + font-weight: 500; + font-size: 0.95rem; + padding: var(--space-2) 0; + position: relative; +} +.site-nav a::after { + content: ""; + position: absolute; + left: 50%; + bottom: 2px; + width: 100%; + height: 1px; + background: var(--color-ink); + transform: translateX(-50%) scaleX(0); + transform-origin: center; + transition: transform var(--dur) var(--ease-out); +} +.site-nav a:hover { color: var(--color-ink); } +.site-nav a:hover::after { transform: translateX(-50%) scaleX(1); } +.site-nav a.active { color: var(--color-ink); } +.site-nav a.active::after { + content: ""; + width: 4px; height: 4px; + border-radius: 50%; + background: var(--color-accent-deep); + position: absolute; + bottom: -4px; + left: 50%; + transform: translateX(-50%); +} + +.nav-toggle { + display: none; + width: 40px; height: 40px; + background: transparent; + border: 1px solid var(--color-line); + border-radius: var(--radius-sm); + cursor: pointer; + padding: 0; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; +} +.nav-toggle-bar { + display: block; + width: 18px; + height: 1.5px; + background: var(--color-ink); + transition: transform var(--dur) var(--ease-out), opacity var(--dur) var(--ease-out); +} +.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(1) { transform: translateY(5.5px) rotate(45deg); } +.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(2) { opacity: 0; } +.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(3) { transform: translateY(-5.5px) rotate(-45deg); } + +.user-area { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: 0.95rem; + justify-self: end; +} +.user-area .user-name { + color: var(--color-text); + padding: var(--space-1) 0; + font-weight: 500; +} + +@media (max-width: 1020px) { + .header-row { grid-template-columns: auto auto 1fr; } + .nav-toggle { display: inline-flex; order: 2; justify-self: end; } + .user-area { justify-self: end; } + .site-nav { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--color-surface); + border-bottom: 1px solid var(--color-line); + flex-direction: column; + gap: 0; + padding: var(--space-3) var(--gutter) var(--space-4); + display: none; + } + .site-nav.is-open { display: flex; } + .site-nav a { padding: var(--space-3) 0; border-bottom: 1px solid var(--color-line); } + .site-nav a:last-child { border-bottom: none; } + .site-nav a::after, .site-nav a.active::after { display: none; } +} + +/* Footer */ +.site-footer { + background: var(--color-surface); + border-top: 1px solid var(--color-line); + padding: var(--space-5) 0; + margin-top: var(--space-7); + font-size: 0.875rem; + color: var(--color-text-muted); +} +.footer-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + flex-wrap: wrap; +} +.footer-meta { margin: 0; } +.footer-link { text-decoration: none; color: var(--color-text-muted); } +.footer-link:hover { color: var(--color-ink); } + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + border: 1px solid transparent; + background: var(--color-primary); + color: var(--color-primary-ink); + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: none; + line-height: 1.2; + transition: background 0.15s ease, transform 0.05s ease; +} +.btn:hover, a.btn:hover { background: var(--color-primary-hover); color: var(--color-primary-ink); } +.btn:active { transform: translateY(1px); } +.btn[disabled] { opacity: 0.5; cursor: not-allowed; } +.btn-secondary { + background: var(--color-surface); + color: var(--color-text); + border-color: var(--color-border-strong); +} +.btn-secondary:hover, a.btn-secondary:hover { background: var(--color-surface-2); color: var(--color-text); } +.btn-ghost { background: transparent; color: var(--color-primary); } +.btn-ghost:hover, a.btn-ghost:hover { background: var(--color-surface-2); color: var(--color-primary); } +.btn-danger { background: var(--color-danger); } +.btn-danger:hover, a.btn-danger:hover { background: #842722; color: var(--color-primary-ink); } +.btn-sm { padding: var(--space-2) var(--space-3); font-size: 0.9rem; } +.btn-icon { + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-text); + width: 36px; height: 36px; + padding: 0; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; +} +.btn-icon:hover { background: var(--color-surface-2); } +.btn-icon.is-on { + background: #e3f0ee; + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* Icons */ +.icon { flex-shrink: 0; vertical-align: -3px; } +.btn .icon, .btn-icon .icon { vertical-align: 0; } + +/* Forms */ +.form-grid { display: grid; gap: var(--space-4); } +.form-grid.cols-2 { grid-template-columns: 1fr 1fr; } +@media (max-width: 700px) { .form-grid.cols-2 { grid-template-columns: 1fr; } } + +label.field { display: flex; flex-direction: column; gap: var(--space-1); font-size: 0.9rem; color: var(--color-text-muted); } +label.field .hint { font-size: 0.8rem; color: var(--color-text-muted); } +input[type="text"], input[type="email"], input[type="password"], input[type="number"], +input[type="search"], input[type="date"], input[type="url"], input[type="tel"], +textarea, select { + font: inherit; + padding: var(--space-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text); + width: 100%; + line-height: 1.4; +} +input[type="date"] { min-height: 45px; appearance: none; -webkit-appearance: none; } +input[type="date"]::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.6; + filter: invert(0.3); +} +input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; } + +select { + appearance: none; + -webkit-appearance: none; + padding-right: calc(var(--space-3) + 24px); + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right var(--space-3) center; + background-size: 12px 12px; +} +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; } +textarea { resize: vertical; min-height: 96px; } +input:focus, textarea:focus, select:focus { + outline: 2px solid var(--color-focus); + outline-offset: 1px; + border-color: var(--color-focus); +} + +/* Cards */ +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-5); + box-shadow: var(--shadow-sm); +} +.card h2, .card h3 { margin-top: 0; } +.card + .card { margin-top: var(--space-4); } +/* Inside the detail grid, cards sit side-by-side at >=900px — kill the + stacking margin so the aside aligns with the main card's top. The margin + comes back below the breakpoint where the grid collapses to one column. */ +@media (min-width: 900px) { + .detail-grid > .card + .card { margin-top: 0; } +} + +/* Hero — editorial */ +.hero { + padding: 0 0 var(--space-7); + max-width: none; +} +.hero .eyebrow { margin-bottom: var(--space-4); } +.hero-title { + font-family: var(--font-display); + font-weight: 400; + font-size: clamp(2.4rem, 7vw, 5.6rem); + line-height: 1.02; + letter-spacing: -0.025em; + color: var(--color-ink); + margin: 0 0 var(--space-5); + max-width: 22ch; + font-variation-settings: "opsz" 144, "SOFT" 50; +} +.hero-title em { + font-style: italic; + color: var(--color-accent-deep); + font-variation-settings: "opsz" 144, "SOFT" 100; +} +.hero .lede { margin-bottom: var(--space-6); } + +/* Home search — sits above the hero, full width */ +.home-search { + padding: 0 0 var(--space-6); + margin-bottom: var(--space-7); +} +.home-search .eyebrow { margin-bottom: var(--space-3); } +.home-search-form { + display: flex; + gap: var(--space-4); + align-items: center; + border-bottom: 1px solid var(--color-ink); + padding-bottom: var(--space-3); +} +.home-search-form input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + font-family: var(--font-display); + font-size: clamp(1.25rem, 2vw, 1.6rem); + font-weight: 400; + color: var(--color-ink); + padding: var(--space-2) 0; + border-radius: 0; + font-variation-settings: "opsz" 48, "SOFT" 30; +} +.home-search-form input::placeholder { color: var(--color-text-muted); font-style: italic; } +.home-search-form input:focus { outline: none; } +.home-search-form .btn { + border-radius: 999px; + padding: var(--space-3) var(--space-5); + flex-shrink: 0; +} + +.hero-stats { + display: flex; + gap: clamp(var(--space-5), 5vw, var(--space-7)); + margin: 0; + flex-wrap: wrap; + padding-top: var(--space-5); + border-top: 1px solid var(--color-line); +} +.hero-stats div { display: flex; flex-direction: column; gap: 2px; } +.hero-stats dt { + order: 2; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-text-muted); +} +.hero-stats dd { + order: 1; + margin: 0; + font-family: var(--font-display); + font-weight: 500; + font-size: clamp(2rem, 3.5vw, 2.8rem); + line-height: 1; + color: var(--color-ink); + font-variant-numeric: lining-nums tabular-nums; + font-variation-settings: "opsz" 96, "SOFT" 30; +} + +/* Recent rail section */ +.recent-section { margin-bottom: var(--space-7); } +.section-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-5); + flex-wrap: wrap; +} +.section-link { + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + color: var(--color-text); +} +.section-link:hover { color: var(--color-primary); } + +.recent-rail-wrap { position: relative; } +.rail-nav-bar { display: none; } +.rail-nav { display: inline-flex; } + +.recent-rail { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-4); +} +@media (max-width: 1180px) { + .recent-rail { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .recent-rail .rail-card:nth-child(n+7) { display: none; } +} +@media (max-width: 900px) { + .recent-rail { + grid-template-columns: none; + grid-auto-flow: column; + grid-auto-columns: 80%; + overflow-x: auto; + scroll-snap-type: x mandatory; + scrollbar-width: none; + /* Bleed past the wide container's gutters on both sides so off-screen + cards fade out at the actual viewport edges. The first card's + leading padding inside the scroller keeps it aligned with the + eyebrow above at the resting position. */ + margin-inline: calc(var(--gutter) * -1); + padding: 0 var(--gutter) var(--space-3); + gap: var(--space-3); + scroll-padding-left: var(--gutter); + } + .recent-rail::-webkit-scrollbar { display: none; } + .recent-rail .rail-card { scroll-snap-align: start; } + .recent-rail .rail-card:nth-child(n+7) { display: flex; } + + .rail-nav-bar { + display: flex; + justify-content: flex-end; + gap: var(--space-3); + margin-top: var(--space-3); + } + .rail-nav { + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--color-surface); + border: 1px solid var(--color-line); + color: var(--color-ink); + cursor: pointer; + } + .rail-nav:hover { background: var(--color-surface-2); } +} +.rail-card { + background: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-lg); + padding: var(--space-5); + text-decoration: none; + color: var(--color-ink); + display: flex; + flex-direction: column; + gap: var(--space-2); + transition: transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out); +} +.rail-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: var(--color-border-strong); +} +.rail-meta { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-text-muted); +} +.rail-title { + font-family: var(--font-display); + font-weight: 500; + font-size: 1.15rem; + line-height: 1.2; + color: var(--color-ink); +} +.rail-summary { + font-size: 0.92rem; + line-height: 1.55; + color: var(--color-text); +} + +@media (max-width: 700px) { + .recent-rail { grid-auto-columns: 80%; } +} + +/* "Sådan virker det" card */ +.how-it-works .eyebrow { margin-bottom: var(--space-4); } +.steps-list { + list-style: none; + counter-reset: step; + margin: 0; + padding: 0; + display: grid; + gap: 0; +} +.steps-list li { + counter-increment: step; + display: grid; + grid-template-columns: clamp(48px, 5vw, 72px) minmax(0, 60ch); + gap: var(--space-4); + align-items: start; + padding: var(--space-4) 0; + border-bottom: 1px solid var(--color-line); + line-height: 1.55; +} +.steps-list li:first-child { padding-top: 0; } +.steps-list li:last-child { border-bottom: none; padding-bottom: 0; } +.steps-list li::before { + content: counter(step, decimal-leading-zero); + font-family: var(--font-display); + font-weight: 500; + font-size: 1.5rem; + line-height: 1.55; + color: var(--color-accent-deep); + font-variant-numeric: lining-nums tabular-nums; + font-variation-settings: "opsz" 48, "SOFT" 30; +} +.steps-list .step-body { display: block; min-width: 0; } + +/* Search layout */ +.search-layout { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: var(--space-6); + align-items: start; +} +@media (min-width: 1280px) { + .search-layout { grid-template-columns: 260px minmax(0, 1fr) 280px; } +} +@media (max-width: 900px) { + .search-layout { grid-template-columns: 1fr; gap: var(--space-4); } +} +.facet-group { + margin: 0; + border-bottom: 1px solid var(--color-line); +} +.facet-group:last-of-type { border-bottom: none; } +.facet-group > summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-3) 0; + user-select: none; +} +.facet-group > summary::-webkit-details-marker { display: none; } +.facet-group h4 { + margin: 0; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--color-text-muted); + font-weight: 600; + font-family: var(--font-sans); +} +.facet-chevron { + width: 8px; + height: 8px; + border-right: 1.5px solid var(--color-text-muted); + border-bottom: 1.5px solid var(--color-text-muted); + transform: rotate(45deg); + transition: transform var(--dur) var(--ease-out); + margin-right: 4px; +} +.facet-group[open] > summary .facet-chevron { transform: rotate(-135deg); margin-top: 4px; } +.facet-group > summary:hover h4 { color: var(--color-ink); } +.facet-group ul { list-style: none; margin: 0; padding: 0 0 var(--space-3); } +.facet-group li label { + display: flex; align-items: center; gap: var(--space-2); + padding: var(--space-1) 0; + font-size: 0.92rem; + cursor: pointer; +} +.facet-group .count { color: var(--color-text-muted); font-size: 0.85rem; margin-left: auto; } + +.facet-aside, .context-aside { + background: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-lg); + padding: var(--space-5); +} +.facet-aside { position: sticky; top: 116px; } +/* The context rail is the sticky container; its child asides flow inside it. */ +.context-rail { position: sticky; top: 116px; display: grid; gap: var(--space-4); } +@media (max-width: 900px) { + .facet-aside, .context-rail { position: static; } +} + +.context-aside h3 { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--color-text-muted); + font-family: var(--font-sans); + font-weight: 600; + margin-top: 0; + margin-bottom: var(--space-3); +} +.context-empty { font-size: 0.9rem; color: var(--color-text-muted); margin: 0; } + +.active-filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--color-surface-2); + border: 1px solid var(--color-line); + border-radius: 999px; + font-size: 0.85rem; + cursor: pointer; + font: inherit; + font-size: 0.85rem; + color: var(--color-ink); +} +.filter-chip:hover { background: #f1ebda; } +.filter-chip-x { + display: inline-block; + width: 14px; + height: 14px; + line-height: 12px; + text-align: center; + border-radius: 50%; + background: var(--color-line); + color: var(--color-ink); + font-size: 12px; +} + +.recent-searches { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; } +.recent-searches a { + display: block; + padding: 4px 0; + text-decoration: none; + color: var(--color-text); + font-size: 0.92rem; +} +.recent-searches a:hover { color: var(--color-primary); } + +.results-header { + display: flex; align-items: baseline; justify-content: space-between; + margin-bottom: var(--space-4); + gap: var(--space-4); + flex-wrap: wrap; +} +.results-header .count { color: var(--color-text-muted); } +.results-list { display: grid; gap: var(--space-4); } + +/* Publication card */ +.pub-card { + background: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-lg); + padding: var(--space-5) var(--space-6); + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-5); + transition: transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out); +} +.pub-card:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + border-color: var(--color-border-strong); +} +.pub-card .meta { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-text-muted); + margin-bottom: var(--space-2); +} +.pub-card h3 { + margin: 0 0 var(--space-3); + font-family: var(--font-display); + font-weight: 500; + font-size: 1.35rem; + line-height: 1.2; + font-variation-settings: "opsz" 36, "SOFT" 30; +} +.pub-card h3 a { text-decoration: none; color: var(--color-ink); } +.pub-card h3 a:hover { color: var(--color-primary); } +.pub-card .summary { + margin: 0 0 var(--space-4); + max-width: 72ch; + color: var(--color-text); +} +.badges { display: flex; gap: var(--space-2); flex-wrap: wrap; } +.pub-card .badges { gap: var(--space-3); } +.pub-card .actions { display: flex; flex-direction: column; gap: var(--space-2); align-items: flex-end; } +@media (max-width: 700px) { + .pub-card { grid-template-columns: 1fr; padding: var(--space-4) var(--space-5); } + .pub-card .actions { flex-direction: row; align-items: center; } +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 3px 10px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + border: 1px solid transparent; + white-space: nowrap; + line-height: 1.4; +} +.badge-rights { background: var(--color-surface-2); border-color: var(--color-border-strong); color: var(--color-text); } +.badge-rights[data-level="1"] { background: #f1ece2; color: #5b3e1a; border-color: #d9c89a; } +.badge-rights[data-level="2"], .badge-rights[data-level="3"] { background: #eef2f7; color: #163057; border-color: #b4c4dc; } +.badge-rights[data-level="4"], .badge-rights[data-level="5"] { background: #e6efe4; color: #2f6b3a; border-color: #bdd5b6; } +.badge-rights[data-level="6"], .badge-rights[data-level="7"] { background: #dfeadd; color: #2f6b3a; border-color: #9bc394; } + +.badge-risk::before { + content: ""; + display: inline-block; + width: 6px; height: 6px; border-radius: 50%; + background: currentColor; +} +.badge-risk[data-risk="green"] { color: var(--color-ok); background: #e6efe4; border-color: #bdd5b6; } +.badge-risk[data-risk="yellow"] { color: var(--color-warn); background: #f7eed3; border-color: #e1cd86; } +.badge-risk[data-risk="red"] { color: var(--color-danger); background: #f6e0de; border-color: #e3a9a5; } + +.badge-doctype { + background: transparent; + border-color: transparent; + padding: 3px 0; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.7rem; +} +.badge-doctype::before { + content: ""; + display: inline-block; + width: 4px; height: 4px; border-radius: 50%; + background: var(--color-accent-deep); + margin-right: 2px; +} + +/* Framework badge (rammeværk, e.g. OpenWebUI). Reuses the teal accent. */ +.badge-framework { + background: var(--color-surface-2); + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* Data sensitivity badge (datafølsomhed). Mirrors the old risk colour scale: + almindelige → green, fortrolige → yellow, personfoelsomme → red. */ +.badge-sensitivity::before { + content: ""; + display: inline-block; + width: 6px; height: 6px; border-radius: 50%; + background: currentColor; +} +.badge-sensitivity[data-level="almindelige"] { color: var(--color-ok); background: #e6efe4; border-color: #bdd5b6; } +.badge-sensitivity[data-level="fortrolige"] { color: var(--color-warn); background: #f7eed3; border-color: #e1cd86; } +.badge-sensitivity[data-level="personfoelsomme"] { color: var(--color-danger); background: #f6e0de; border-color: #e3a9a5; } + +/* "Kommer snart" teaser chips on the home page. */ +.coming-soon-grid { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-3); +} +.coming-soon-chip { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 6px 12px; + border-radius: 999px; + border: 1px dashed var(--color-border-strong); + background: var(--color-surface-2); + color: var(--color-text-muted); + font-size: 0.82rem; + cursor: not-allowed; + opacity: 0.75; +} +.coming-soon-chip .cs-tag { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-primary); +} + +/* Breadcrumb above detail/grid layouts */ +.breadcrumb { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.85rem; + color: var(--color-text-muted); + margin-bottom: var(--space-4); +} +.breadcrumb a { text-decoration: none; color: var(--color-text); } +.breadcrumb a:hover { color: var(--color-primary); } +.breadcrumb-sep { color: var(--color-line); } + +/* Detail page */ +.detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: var(--space-6); + align-items: start; +} +@media (min-width: 1280px) { + .detail-grid { + grid-template-columns: minmax(0, 1fr) 280px 240px; + gap: clamp(var(--space-5), 3vw, var(--space-7)); + } +} +@media (max-width: 1023px) { + .detail-grid { + grid-template-columns: 1fr; + gap: var(--space-5); + } +} + +.detail-main h1 { + font-size: clamp(2rem, 4vw, 3rem); + letter-spacing: -0.02em; + font-variation-settings: "opsz" 96, "SOFT" 50; + line-height: 1.05; + margin-bottom: var(--space-3); +} +.detail-main .detail-subtitle { + font-family: var(--font-display); + font-style: italic; + font-size: 1.15rem; + color: var(--color-text-muted); + margin: 0 0 var(--space-5); + font-variation-settings: "opsz" 36, "SOFT" 100; +} +.detail-main .resume-block { font-size: 1.05rem; line-height: 1.65; } +.detail-main .resume-block > p:first-of-type::first-letter { + font-family: var(--font-display); + font-weight: 500; + font-size: 3.6rem; + line-height: 0.9; + float: left; + padding: 6px 10px 0 0; + color: var(--color-accent-deep); + font-variation-settings: "opsz" 144, "SOFT" 50; +} +.detail-main h2 { margin-top: var(--space-6); } +.detail-main h3 { margin-top: var(--space-5); } + +.detail-meta-aside, +.detail-actions-aside { + position: sticky; + top: 84px; +} +.detail-meta-aside .eyebrow { margin-bottom: var(--space-3); display: flex; } + +/* 1024–1279px: two columns. The right column stacks actions then metadata. */ +@media (min-width: 1024px) and (max-width: 1279px) { + .detail-grid { + grid-template-columns: minmax(0, 1fr) 320px; + grid-template-rows: auto 1fr; + } + .detail-main { grid-column: 1; grid-row: 1 / span 2; } + .detail-actions-aside { grid-column: 2; grid-row: 1; position: sticky; top: 116px; } + .detail-meta-aside { grid-column: 2; grid-row: 2; position: static; } +} + +/* < 1024px: single column. Order: report → actions → metadata. */ +@media (max-width: 1023px) { + .detail-meta-aside, + .detail-actions-aside { position: static; } + .detail-grid { + grid-template-columns: 1fr; + grid-template-areas: "main" "actions" "meta"; + } + .detail-main { grid-area: main; } + .detail-actions-aside { grid-area: actions; } + .detail-meta-aside { grid-area: meta; } +} + +.detail-actions { display: grid; gap: var(--space-2); } +.detail-actions .btn { width: 100%; justify-content: center; } +@media (min-width: 1280px) { + /* Narrow rail — let buttons hug content for cleaner reading */ + .detail-actions .btn { justify-content: flex-start; padding-left: var(--space-4); padding-right: var(--space-4); } +} + +.detail-meta { + margin: 0; + display: grid; + gap: 0; +} +.detail-meta dt { + color: var(--color-text-muted); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.14em; + font-weight: 600; + margin: 0; + padding-top: var(--space-3); +} +.detail-meta dd { + margin: 0; + padding: 2px 0 var(--space-3); + border-bottom: 1px solid var(--color-line); + font-size: 0.95rem; + color: var(--color-ink); +} +.detail-meta dd:last-child { border-bottom: none; } + +/* Upload */ +.dropzone { + border: 2px dashed var(--color-border-strong); + border-radius: var(--radius-lg); + padding: var(--space-7); + text-align: center; + background: var(--color-surface); + cursor: pointer; +} +.dropzone.is-dragover { border-color: var(--color-primary); background: var(--color-surface-2); } +.dropzone p { margin: var(--space-2) 0; } + +.spinner { + width: 36px; height: 36px; + border: 3px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto var(--space-3); +} +@keyframes spin { to { transform: rotate(360deg); } } + +.rights-list { display: grid; gap: var(--space-2); } +.rights-list label { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + background: var(--color-surface); +} +.rights-list label:hover { border-color: var(--color-border-strong); } +.rights-list label:has(input:checked) { border-color: var(--color-primary); background: var(--color-surface-2); } +.rights-list .rl-title { font-weight: 600; } +.rights-list .rl-desc { color: var(--color-text-muted); font-size: 0.9rem; } + +.check-list { display: grid; gap: var(--space-2); } +.check-list label { + display: flex; align-items: flex-start; gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +/* Generic page header for non-hero views */ +.page-head { margin-bottom: var(--space-5); max-width: 70ch; } + +/* Alert / notice block */ +.alert { + display: flex; + gap: var(--space-3); + align-items: flex-start; + padding: var(--space-4) var(--space-5); + border-radius: var(--radius-md); + margin-bottom: var(--space-6); +} +.alert p { margin: 0; line-height: 1.5; } +.alert-mark { + display: inline-block; + width: 22px; + height: 22px; + border-radius: 50%; + flex-shrink: 0; + position: relative; + margin-top: 2px; +} +.alert-mark::before { + content: "!"; + font-family: var(--font-display); + font-weight: 600; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.95rem; +} +.alert-rights { + background: var(--color-accent-soft); + border: 1px solid var(--color-accent-deep); + color: var(--color-ink); +} +.alert-rights .alert-mark { background: var(--color-accent-deep); color: #fff; } + +/* Upload page: sidebar steps + step card */ +.upload-layout { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + gap: var(--space-6); + align-items: start; +} +@media (max-width: 1023px) { + .upload-layout { grid-template-columns: 1fr; gap: var(--space-4); } +} +.upload-step { max-width: 880px; min-width: 0; } + +.step-rail { + display: grid; + gap: var(--space-3); + position: sticky; + top: 84px; +} +@media (max-width: 1023px) { + .step-rail { + position: static; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: var(--space-2); + } +} +.step-item { + display: grid; + grid-template-columns: 36px 1fr; + gap: var(--space-3); + padding: var(--space-3) var(--space-3); + border-left: 2px solid var(--color-line); + background: transparent; + color: var(--color-text-muted); +} +@media (max-width: 1023px) { + .step-item { + grid-template-columns: 28px 1fr; + padding: var(--space-2) var(--space-3); + border-left: none; + border-top: 2px solid var(--color-line); + background: var(--color-surface); + border-radius: var(--radius-sm); + } +} +.step-item .step-num { + font-family: var(--font-display); + font-weight: 500; + font-size: 1.1rem; + color: var(--color-text-muted); + font-variant-numeric: lining-nums tabular-nums; + line-height: 1.4; +} +.step-item .step-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.step-item .step-title { font-weight: 600; font-size: 0.95rem; color: var(--color-text); } +.step-item .step-hint { font-size: 0.8rem; color: var(--color-text-muted); } +@media (max-width: 1023px) { + .step-item .step-hint { display: none; } +} + +.step-active { + border-left-color: var(--color-accent-deep); + background: var(--color-surface); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} +@media (max-width: 1023px) { + .step-active { border-top-color: var(--color-accent-deep); } +} +.step-active .step-num { color: var(--color-accent-deep); } +.step-active .step-title { color: var(--color-ink); } + +.step-done .step-num { color: var(--color-ok); display: inline-flex; align-items: center; } +.step-done .step-title { color: var(--color-text-muted); } + +/* Empty states */ +.empty { + text-align: center; + padding: var(--space-7) var(--space-5); + color: var(--color-text-muted); +} +.empty h2 { color: var(--color-text); } + +/* Toast */ +.toast-region { + position: fixed; + bottom: var(--space-5); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: var(--space-2); + z-index: 100; +} +.toast { + background: var(--color-text); + color: #fff; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + font-size: 0.95rem; + animation: toast-in 0.2s ease-out; +} +@keyframes toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Auth forms */ +.auth-card { max-width: 480px; margin: 0 auto; } +.auth-tabs { + display: flex; + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--space-5); +} +.auth-tabs button { + flex: 1; + background: transparent; + border: none; + padding: var(--space-3); + font: inherit; + font-weight: 600; + color: var(--color-text-muted); + cursor: pointer; + border-bottom: 2px solid transparent; +} +.auth-tabs button.is-active { color: var(--color-primary); border-bottom-color: var(--color-primary); } + +/* Tag chips */ +.chips { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.chip { + display: inline-flex; + align-items: center; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + padding: 2px var(--space-2); + border-radius: 999px; + font-size: 0.85rem; +} + +/* Collection sharing notice */ +.share-notice { + background: var(--color-accent-soft); + border: 1px solid var(--color-accent); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + font-size: 0.9rem; + margin-bottom: var(--space-4); +} + +/* Modal */ +.modal-backdrop { + position: fixed; inset: 0; + background: rgba(20, 28, 48, 0.45); + display: flex; align-items: center; justify-content: center; + z-index: 50; + padding: var(--space-4); +} +.modal { + background: var(--color-surface); + border-radius: var(--radius-lg); + padding: var(--space-5); + max-width: 480px; + width: 100%; + box-shadow: var(--shadow-md); +} +.modal h3 { margin-top: 0; } +.modal-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-4); } + +/* Inline utilities */ +.row { display: flex; gap: var(--space-3); align-items: center; } +.row.wrap { flex-wrap: wrap; } +.mt-3 { margin-top: var(--space-3); } +.mt-4 { margin-top: var(--space-4); } +.mt-5 { margin-top: var(--space-5); } +.mb-3 { margin-bottom: var(--space-3); } +.mb-4 { margin-bottom: var(--space-4); } +.muted { color: var(--color-text-muted); } +.right { display: flex; justify-content: flex-end; gap: var(--space-2); } +.divider { height: 1px; background: var(--color-border); margin: var(--space-5) 0; border: 0; } +.text-sm { font-size: 0.9rem; } +.no-wrap { white-space: nowrap; } diff --git a/docs/public/projects/ai-bibliotek/mocks/data/seed-assistants.js b/docs/public/projects/ai-bibliotek/mocks/data/seed-assistants.js new file mode 100644 index 0000000..f1db26b --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/data/seed-assistants.js @@ -0,0 +1,570 @@ +/* Seeded fake AI assistants. + Exposed as window.SEED_ASSISTANTS so the app works when opened + via file:// where fetch() of local JSON is often blocked. + + Each assistant has one or more versions; every version carries an + OpenWebUI-shaped JSON object that can be exported/downloaded. +*/ +window.SEED_ASSISTANTS = [ + { + id: "seed-001", + name: "Borgerservice FAQ-assistent", + tagline: "Svarer borgere på de hyppigste spørgsmål i borgerservice", + description: + "En assistent der besvarer borgernes hyppigste spørgsmål om flytning, pas, kørekort, sundhedskort og ventetider. Bygger på kommunens egne vejledninger og henviser altid til den rette selvbetjeningsløsning. Tænkt som førstelinjehjælp, der aflaster telefonerne i borgerservice.", + originKommune: "Aarhus Kommune", + languageModel: "llama3.1:70b", + framework: "openwebui", + dataSensitivity: "almindelige", + approvedFor: ["almindelige"], + tags: ["borgerservice", "faq", "selvbetjening", "førstelinje"], + aiTags: ["selvbetjening"], + createdAt: "2025-01-14", + updatedAt: "2025-04-02", + readme: + "# Borgerservice FAQ-assistent\n\nDenne assistent er udviklet af AarhusAI til at besvare borgernes hyppigste henvendelser.\n\nAssistenten er trænet til at:\n- svare i et letforståeligt sprog\n- altid henvise til den korrekte selvbetjeningsløsning\n- afvise spørgsmål den ikke har dækning for, frem for at gætte\n\nDen erstatter ikke sagsbehandling, men hjælper borgeren videre til rette sted.", + modelCard: + "Sprogmodel: Llama 3.1 70B (lokal via OWUI).\nKontekstvindue: 8192 tokens.\nTemperatur: 0.3 (lav, for konsistente svar).\n\nHensyn:\n- Assistenten må ikke træffe afgørelser eller love sagsbehandlingstider.\n- Svar bør altid ledsages af et link til officiel selvbetjening.\n- Modellen kan hallucinere ved spørgsmål uden for vidensgrundlaget; system-prompten beder den henvise til en medarbejder i tvivlstilfælde.", + knowledgeRecipe: + "1. Eksportér alle aktive FAQ-artikler fra kommunens hjemmeside som PDF eller Markdown.\n2. Saml gældende vejledninger om flytning, pas, kørekort og sundhedskort i én mappe.\n3. Tilføj en oversigt over lokale åbningstider og kontaktinformation.\n4. Upload filerne som en Knowledge-collection i OpenWebUI med navnet \"Borgerservice\".\n5. Opdatér samlingen kvartalsvist, så svarene følger gældende regler.", + versions: [ + { + version: "1.3.0", + releasedAt: "2025-04-02", + notes: "Tilføjet håndtering af spørgsmål om sundhedskort og opdateret system-prompt.", + json: { + id: "borgerservice-faq", + name: "Borgerservice FAQ-assistent", + object: "model", + base_model_id: "llama3.1:70b", + meta: { + description: "Besvarer hyppige borgerhenvendelser i borgerservice.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "borgerservice" }, { name: "faq" }] + }, + params: { + system: "Du er en hjælpsom assistent for borgerservice i Aarhus Kommune. Svar kort og letforståeligt, og henvis altid til den rette selvbetjeningsløsning. Er du i tvivl, så bed borgeren kontakte en medarbejder.", + temperature: 0.3, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [ + { name: "Borgerservice", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + }, + { + version: "1.0.0", + releasedAt: "2025-01-14", + notes: "Første version delt med kommunenetværket.", + json: { + id: "borgerservice-faq", + name: "Borgerservice FAQ-assistent", + object: "model", + base_model_id: "llama3.1:70b", + meta: { + description: "Besvarer hyppige borgerhenvendelser.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "borgerservice" }] + }, + params: { + system: "Du er en hjælpsom assistent for borgerservice. Henvis til selvbetjening hvor muligt.", + temperature: 0.3, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [ + { name: "Borgerservice", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-002", + name: "Sagsbehandler-medhjælper til byggesager", + tagline: "Hjælper byggesagsbehandlere med at finde regler og fortilfælde", + description: + "En assistent til byggesagsbehandlere, der hurtigt finder relevante bestemmelser i bygningsreglementet, lokalplaner og kommunens egne vejledninger. Den opsummerer komplekse sager og foreslår, hvilke forhold der skal vurderes. Mennesket træffer altid den endelige afgørelse.", + originKommune: "Aarhus Kommune", + languageModel: "gpt-4o", + framework: "openwebui", + dataSensitivity: "fortrolige", + approvedFor: ["almindelige", "fortrolige"], + tags: ["byggesag", "sagsbehandling", "bygningsreglement", "lokalplan"], + aiTags: ["fortilfælde"], + createdAt: "2025-02-20", + updatedAt: "2025-05-09", + readme: + "# Sagsbehandler-medhjælper til byggesager\n\nUnderstøtter byggesagsbehandlere i at navigere i bygningsreglement, lokalplaner og praksis.\n\nAssistenten:\n- finder og citerer relevante bestemmelser\n- opsummerer indkomne ansøgninger\n- peger på forhold, der kræver nærmere vurdering\n\nAfgørelser træffes altid af en sagsbehandler. Assistenten er et opslagsværktøj, ikke en beslutningsmaskine.", + modelCard: + "Sprogmodel: GPT-4o (kan køres mod lokal eller hostet endpoint via OWUI).\nKontekstvindue: 16384 tokens.\nTemperatur: 0.2.\n\nHensyn:\n- Sager kan indeholde fortrolige oplysninger; assistenten må kun anvendes i godkendt miljø.\n- Citater skal verificeres mod den autoritative kilde inden brug i afgørelser.\n- Vidensgrundlaget skal holdes opdateret ved ændringer i bygningsreglementet.", + knowledgeRecipe: + "1. Eksportér gældende bygningsreglement og relevante BR-vejledninger som PDF.\n2. Saml kommunens egne byggesagsvejledninger og standardsvar.\n3. Tilføj de lokalplaner, der oftest er i spil i jeres kommune.\n4. Upload som Knowledge-collection \"Byggesag\" i OpenWebUI.\n5. Markér dokumenter med ikrafttrædelsesdato, så assistenten kan skelne mellem gældende og historisk praksis.", + versions: [ + { + version: "2.1.0", + releasedAt: "2025-05-09", + notes: "Udvidet kontekstvindue og bedre citatformat.", + json: { + id: "byggesag-medhjaelper", + name: "Sagsbehandler-medhjælper til byggesager", + object: "model", + base_model_id: "gpt-4o", + meta: { + description: "Opslag i bygningsreglement og lokalplaner for byggesagsbehandlere.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "byggesag" }, { name: "sagsbehandling" }] + }, + params: { + system: "Du er en faglig medhjælper for byggesagsbehandlere i Aarhus Kommune. Find og citér relevante bestemmelser præcist med kildehenvisning. Træf aldrig afgørelser — peg på forhold der skal vurderes af en sagsbehandler.", + temperature: 0.2, + top_p: 0.9, + num_ctx: 16384 + }, + knowledge: [ + { name: "Byggesag", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + }, + { + version: "2.0.0", + releasedAt: "2025-02-20", + notes: "Skiftet basismodel til GPT-4o.", + json: { + id: "byggesag-medhjaelper", + name: "Sagsbehandler-medhjælper til byggesager", + object: "model", + base_model_id: "gpt-4o", + meta: { + description: "Opslag i bygningsreglement for byggesagsbehandlere.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "byggesag" }] + }, + params: { + system: "Du er en faglig medhjælper for byggesagsbehandlere. Citér kilder og træf ingen afgørelser.", + temperature: 0.2, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [ + { name: "Byggesag", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-003", + name: "Referat-opsummering til møder", + tagline: "Laver strukturerede referater og handlepunkter fra mødenoter", + description: + "Indsæt rå mødenoter eller en transskription, og assistenten leverer et struktureret referat med beslutninger, handlepunkter og ansvarlige. Velegnet til udvalgsmøder, projektmøder og personalemøder.", + originKommune: "Odense Kommune", + languageModel: "mistral-large", + framework: "openwebui", + dataSensitivity: "fortrolige", + approvedFor: ["almindelige", "fortrolige"], + tags: ["referat", "møder", "opsummering", "produktivitet"], + aiTags: ["handlepunkter"], + createdAt: "2025-03-01", + updatedAt: "2025-03-28", + readme: + "# Referat-opsummering\n\nForvandler rå mødenoter til et struktureret referat.\n\nOutput indeholder fast:\n- Deltagere (hvis nævnt)\n- Beslutninger\n- Handlepunkter med ansvarlig og frist\n- Åbne spørgsmål\n\nKontrollér altid referatet inden udsendelse — assistenten kan misforstå forkortelser og uformelt sprog.", + modelCard: + "Sprogmodel: Mistral Large (lokal via OWUI).\nKontekstvindue: 32768 tokens (rummer lange transskriptioner).\nTemperatur: 0.4.\n\nHensyn:\n- Mødenoter kan indeholde personoplysninger om medarbejdere; brug kun i godkendt miljø.\n- Assistenten gætter ikke på navne den ikke er givet.\n- Lange møder bør deles op, hvis de overstiger kontekstvinduet.", + knowledgeRecipe: + "Denne assistent kræver ikke et fast vidensgrundlag — den arbejder på den tekst, du indsætter.\n\n1. Indsæt rå noter eller transskription i chatten.\n2. (Valgfrit) Tilføj en skabelon for jeres referatformat som Knowledge-fil, hvis I ønsker et bestemt layout.\n3. Bed eventuelt om referat på letlæst dansk til bred udsendelse.", + versions: [ + { + version: "1.1.0", + releasedAt: "2025-03-28", + notes: "Tilføjet fast sektion for åbne spørgsmål.", + json: { + id: "referat-opsummering", + name: "Referat-opsummering til møder", + object: "model", + base_model_id: "mistral-large", + meta: { + description: "Laver strukturerede referater fra mødenoter.", + capabilities: { vision: false, citations: false }, + tags: [{ name: "referat" }, { name: "møder" }] + }, + params: { + system: "Du opsummerer mødenoter til et struktureret referat med deltagere, beslutninger, handlepunkter (ansvarlig + frist) og åbne spørgsmål. Gæt ikke på navne eller beslutninger der ikke fremgår.", + temperature: 0.4, + top_p: 0.9, + num_ctx: 32768 + }, + knowledge: [] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-004", + name: "Journaliseringshjælp", + tagline: "Foreslår journalnummer, titel og emneord ved journalisering", + description: + "Hjælper medarbejdere med at journalisere korrekt i ESDH. Ud fra et dokuments indhold foreslår assistenten sagstitel, relevant journalplan-kode og emneord, så journaliseringen bliver ensartet på tværs af afdelinger.", + originKommune: "Aalborg Kommune", + languageModel: "gemma2:27b", + framework: "openwebui", + dataSensitivity: "fortrolige", + approvedFor: ["almindelige", "fortrolige"], + tags: ["journalisering", "esdh", "arkiv", "sagsstyring"], + aiTags: ["journalplan"], + createdAt: "2025-01-30", + updatedAt: "2025-04-18", + readme: + "# Journaliseringshjælp\n\nGiver forslag til journalisering ud fra et dokuments indhold.\n\nAssistenten foreslår:\n- sagstitel\n- journalplan-kode\n- emneord\n\nForslagene skal godkendes af medarbejderen inden journalisering. Den endelige journalplan er kommunens ansvar.", + modelCard: + "Sprogmodel: Gemma 2 27B (lokal via OWUI).\nKontekstvindue: 8192 tokens.\nTemperatur: 0.2.\n\nHensyn:\n- Dokumenter kan være fortrolige; kør kun i godkendt lokalt miljø.\n- Journalplan-koder skal matche kommunens egen plan — derfor leveres planen som lokalt vidensgrundlag.", + knowledgeRecipe: + "1. Eksportér kommunens journalplan (KL-emnesystematik eller lokal variant) som struktureret fil (CSV/Markdown).\n2. Tilføj eksempler på korrekt journaliserede sager som referencer.\n3. Upload som Knowledge-collection \"Journalplan\" i OpenWebUI.\n4. Opdatér ved ændringer i journalplanen.", + versions: [ + { + version: "1.2.0", + releasedAt: "2025-04-18", + notes: "Bedre forslag til emneord og kortere sagstitler.", + json: { + id: "journaliseringshjaelp", + name: "Journaliseringshjælp", + object: "model", + base_model_id: "gemma2:27b", + meta: { + description: "Foreslår sagstitel, journalplan-kode og emneord.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "journalisering" }, { name: "esdh" }] + }, + params: { + system: "Du foreslår journalisering: sagstitel, journalplan-kode og emneord ud fra dokumentets indhold. Brug kun koder fra den vedlagte journalplan. Forslag skal godkendes af en medarbejder.", + temperature: 0.2, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [ + { name: "Journalplan", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-005", + name: "Aktindsigt-screening", + tagline: "Markerer mulige undtagelser i dokumenter til aktindsigt", + description: + "Gennemgår dokumenter forud for aktindsigt og markerer passager, der kan være omfattet af undtagelser i offentlighedsloven — fx personoplysninger, interne arbejdsdokumenter eller forretningshemmeligheder. Et menneske foretager den endelige vurdering og overstregning.", + originKommune: "Vejle Kommune", + languageModel: "llama3.1:70b", + framework: "openwebui", + dataSensitivity: "personfoelsomme", + approvedFor: ["almindelige", "fortrolige", "personfoelsomme"], + tags: ["aktindsigt", "offentlighedsloven", "screening", "gdpr"], + aiTags: ["undtagelser"], + createdAt: "2025-02-11", + updatedAt: "2025-05-15", + readme: + "# Aktindsigt-screening\n\nStøtter sagsbehandlere i at forberede aktindsigt.\n\nAssistenten markerer mulige undtagelser, men:\n- foretager ikke selv overstregning\n- erstatter ikke den juridiske vurdering\n- skal altid efterprøves af en medarbejder\n\nFormålet er at spare tid på den indledende gennemgang.", + modelCard: + "Sprogmodel: Llama 3.1 70B (lokal via OWUI).\nKontekstvindue: 16384 tokens.\nTemperatur: 0.1 (meget lav — forsigtige, konservative forslag).\n\nHensyn:\n- Dokumenterne kan indeholde personfølsomme oplysninger; må kun køres i fuldt isoleret, godkendt miljø.\n- Assistenten skal hellere markere for meget end for lidt; den endelige afgørelse er altid menneskelig.\n- Falske negativer er en risiko — brug aldrig outputtet ukritisk.", + knowledgeRecipe: + "1. Saml kommunens interne vejledning om aktindsigt og relevante afgørelser.\n2. Tilføj en oversigt over typiske undtagelsesgrunde med eksempler.\n3. Upload som Knowledge-collection \"Aktindsigt\".\n4. Det konkrete dokument til screening indsættes i chatten — det skal IKKE gemmes i vidensgrundlaget.", + versions: [ + { + version: "1.4.0", + releasedAt: "2025-05-15", + notes: "Mere konservativ markering og henvisning til paragraf.", + json: { + id: "aktindsigt-screening", + name: "Aktindsigt-screening", + object: "model", + base_model_id: "llama3.1:70b", + meta: { + description: "Markerer mulige undtagelser i dokumenter til aktindsigt.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "aktindsigt" }, { name: "offentlighedsloven" }] + }, + params: { + system: "Du screener dokumenter til aktindsigt. Markér passager der kan være omfattet af undtagelser i offentlighedsloven, og henvis til den mulige paragraf. Foretag aldrig selv overstregning; en medarbejder træffer den endelige afgørelse.", + temperature: 0.1, + top_p: 0.9, + num_ctx: 16384 + }, + knowledge: [ + { name: "Aktindsigt", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-006", + name: "Jobcenter-vejleder", + tagline: "Vejleder om beskæftigelsesindsatser og krav til ledige", + description: + "Hjælper jobcentermedarbejdere og vejledere med at finde de rette beskæftigelsestilbud, frister og rådighedskrav. Assistenten forklarer reglerne i et klart sprog og henviser til den relevante lovgivning.", + originKommune: "Randers Kommune", + languageModel: "mistral-large", + framework: "openwebui", + dataSensitivity: "fortrolige", + approvedFor: ["almindelige", "fortrolige"], + tags: ["jobcenter", "beskæftigelse", "vejledning", "lab-loven"], + aiTags: ["rådighed"], + createdAt: "2025-03-12", + updatedAt: "2025-04-25", + readme: + "# Jobcenter-vejleder\n\nGiver overblik over beskæftigelsesindsatser, frister og rådighedskrav.\n\nAssistenten:\n- forklarer regler i klart sprog\n- henviser til relevant lovgivning (fx LAB-loven)\n- foreslår relevante tilbud ud fra en situationsbeskrivelse\n\nDen erstatter ikke en konkret afgørelse.", + modelCard: + "Sprogmodel: Mistral Large (lokal via OWUI).\nKontekstvindue: 16384 tokens.\nTemperatur: 0.3.\n\nHensyn:\n- Borgersager kan indeholde fortrolige oplysninger; anonymisér før indsættelse hvor muligt.\n- Lovgrundlaget ændres ofte — hold vidensgrundlaget opdateret.", + knowledgeRecipe: + "1. Eksportér gældende vejledninger om beskæftigelsesindsatsen (LAB) og rådighedskrav.\n2. Tilføj kommunens egne tilbudskataloger og kontaktoplysninger.\n3. Upload som Knowledge-collection \"Beskæftigelse\".\n4. Opdatér ved lovændringer.", + versions: [ + { + version: "1.0.0", + releasedAt: "2025-04-25", + notes: "Første offentlige version.", + json: { + id: "jobcenter-vejleder", + name: "Jobcenter-vejleder", + object: "model", + base_model_id: "mistral-large", + meta: { + description: "Vejleder om beskæftigelsesindsatser og rådighedskrav.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "jobcenter" }, { name: "beskæftigelse" }] + }, + params: { + system: "Du vejleder jobcentermedarbejdere om beskæftigelsesindsatser, frister og rådighedskrav. Forklar reglerne klart og henvis til lovgrundlaget. Du træffer ikke afgørelser.", + temperature: 0.3, + top_p: 0.9, + num_ctx: 16384 + }, + knowledge: [ + { name: "Beskæftigelse", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-007", + name: "Forældrekommunikation i dagtilbud", + tagline: "Hjælper pædagoger med klare beskeder til forældre", + description: + "Hjælper personale i dagtilbud med at formulere venlige og klare beskeder til forældre — om udflugter, sygdom, arrangementer og praktiske forhold. Kan oversætte beskeder til letlæst dansk og flere sprog.", + originKommune: "Esbjerg Kommune", + languageModel: "gemma2:27b", + framework: "openwebui", + dataSensitivity: "almindelige", + approvedFor: ["almindelige"], + tags: ["dagtilbud", "kommunikation", "forældre", "skole"], + aiTags: ["letlæst"], + createdAt: "2025-02-28", + updatedAt: "2025-03-30", + readme: + "# Forældrekommunikation i dagtilbud\n\nStøtter personalet i at skrive klare beskeder til forældre.\n\nAssistenten:\n- formulerer venlige, korte beskeder\n- kan oversætte til letlæst dansk\n- kan oversætte til udvalgte sprog\n\nUndgå at indsætte oplysninger om enkelte børn — hold beskederne generelle.", + modelCard: + "Sprogmodel: Gemma 2 27B (lokal via OWUI).\nKontekstvindue: 8192 tokens.\nTemperatur: 0.6 (lidt højere for naturligt sprog).\n\nHensyn:\n- Beskeder bør ikke indeholde personhenførbare oplysninger om enkelte børn.\n- Oversættelser bør kontrolleres ved vigtige beskeder.", + knowledgeRecipe: + "1. (Valgfrit) Saml institutionens standardbeskeder og tone-of-voice som Knowledge-fil.\n2. Upload som collection \"Dagtilbud\" hvis I vil have ensartet sprog.\n3. Ellers fungerer assistenten direkte på den tekst, du indsætter.", + versions: [ + { + version: "1.0.0", + releasedAt: "2025-03-30", + notes: "Første version med oversættelse til letlæst dansk.", + json: { + id: "dagtilbud-foraeldrekommunikation", + name: "Forældrekommunikation i dagtilbud", + object: "model", + base_model_id: "gemma2:27b", + meta: { + description: "Hjælper pædagoger med klare beskeder til forældre.", + capabilities: { vision: false, citations: false }, + tags: [{ name: "dagtilbud" }, { name: "kommunikation" }] + }, + params: { + system: "Du hjælper personale i dagtilbud med at skrive venlige, korte og klare beskeder til forældre. Undgå personhenførbare oplysninger om enkelte børn. Tilbyd at oversætte til letlæst dansk.", + temperature: 0.6, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-008", + name: "Oversæt til letlæst dansk", + tagline: "Gør komplekse myndighedstekster lette at forstå", + description: + "Omskriver komplekse myndighedstekster — afgørelser, breve og vejledninger — til letlæst dansk uden at ændre indholdet. Velegnet til breve, der skal forstås af alle borgere.", + originKommune: "Gentofte Kommune", + languageModel: "gpt-4o", + framework: "openwebui", + dataSensitivity: "almindelige", + approvedFor: ["almindelige"], + tags: ["letlæst", "oversættelse", "tilgængelighed", "kommunikation"], + aiTags: ["sprogforenkling"], + createdAt: "2025-01-08", + updatedAt: "2025-04-10", + readme: + "# Oversæt til letlæst dansk\n\nOmskriver tekster til letlæst dansk uden at ændre betydningen.\n\nAssistenten:\n- bruger korte sætninger og almindelige ord\n- bevarer det faglige indhold\n- markerer hvis et begreb ikke kan forenkles uden at miste mening\n\nKontrollér altid den omskrevne tekst, særligt ved afgørelser.", + modelCard: + "Sprogmodel: GPT-4o.\nKontekstvindue: 16384 tokens.\nTemperatur: 0.3.\n\nHensyn:\n- Forenkling må ikke ændre den juridiske betydning af en afgørelse.\n- Personoplysninger i indsat tekst behandles fortroligt og bør anonymiseres hvor muligt.", + knowledgeRecipe: + "Kræver ikke fast vidensgrundlag.\n\n1. Indsæt teksten der skal forenkles.\n2. (Valgfrit) Tilføj kommunens sprogpolitik som Knowledge-fil, så stilen bliver ensartet.", + versions: [ + { + version: "1.2.0", + releasedAt: "2025-04-10", + notes: "Bedre bevarelse af juridisk betydning.", + json: { + id: "letlaest-dansk", + name: "Oversæt til letlæst dansk", + object: "model", + base_model_id: "gpt-4o", + meta: { + description: "Omskriver myndighedstekster til letlæst dansk.", + capabilities: { vision: false, citations: false }, + tags: [{ name: "letlæst" }, { name: "tilgængelighed" }] + }, + params: { + system: "Du omskriver myndighedstekster til letlæst dansk med korte sætninger og almindelige ord, uden at ændre betydningen. Markér hvis et begreb ikke kan forenkles uden tab af mening.", + temperature: 0.3, + top_p: 0.9, + num_ctx: 16384 + }, + knowledge: [] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-009", + name: "Mødebooking-assistent", + tagline: "Finder ledige tider og udkast til mødeindkaldelser", + description: + "Hjælper medarbejdere med at planlægge møder: foreslår mødetidspunkter ud fra beskrevne begrænsninger og skriver et udkast til en mødeindkaldelse med dagsorden. Integrerer ikke med kalenderen — den arbejder på de oplysninger, du giver.", + originKommune: "Odense Kommune", + languageModel: "gemma2:27b", + framework: "openwebui", + dataSensitivity: "almindelige", + approvedFor: ["almindelige"], + tags: ["møder", "planlægning", "produktivitet", "kommunikation"], + aiTags: ["dagsorden"], + createdAt: "2025-03-18", + updatedAt: "2025-03-18", + readme: + "# Mødebooking-assistent\n\nHjælper med at planlægge møder og skrive indkaldelser.\n\nAssistenten:\n- foreslår mødetidspunkter ud fra dine begrænsninger\n- skriver et udkast til indkaldelse med dagsorden\n\nDen har ikke adgang til din kalender — du oplyser selv ledige tider.", + modelCard: + "Sprogmodel: Gemma 2 27B (lokal via OWUI).\nKontekstvindue: 8192 tokens.\nTemperatur: 0.5.\n\nHensyn:\n- Ingen kalenderintegration; assistenten arbejder kun på de oplysninger, du indtaster.\n- Undgå at indsætte fortrolige deltageroplysninger.", + knowledgeRecipe: + "Kræver ikke fast vidensgrundlag.\n\n1. Beskriv deltagere, varighed og dine ledige tider.\n2. (Valgfrit) Tilføj en standard dagsorden-skabelon som Knowledge-fil.", + versions: [ + { + version: "0.9.0", + releasedAt: "2025-03-18", + notes: "Beta — deles til feedback fra netværket.", + json: { + id: "moedebooking", + name: "Mødebooking-assistent", + object: "model", + base_model_id: "gemma2:27b", + meta: { + description: "Foreslår mødetider og skriver indkaldelser.", + capabilities: { vision: false, citations: false }, + tags: [{ name: "møder" }, { name: "planlægning" }] + }, + params: { + system: "Du hjælper med at planlægge møder. Foreslå tidspunkter ud fra de oplyste begrænsninger og skriv et udkast til en mødeindkaldelse med dagsorden. Du har ingen kalenderadgang.", + temperature: 0.5, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-010", + name: "Tilskuds- og puljevejleder", + tagline: "Hjælper foreninger og borgere med at finde rette pulje", + description: + "Vejleder borgere og foreninger om kommunens tilskuds- og puljemuligheder. Ud fra en beskrivelse af et projekt foreslår assistenten relevante puljer, ansøgningsfrister og krav til ansøgningen.", + originKommune: "Aalborg Kommune", + languageModel: "llama3.1:70b", + framework: "openwebui", + dataSensitivity: "almindelige", + approvedFor: ["almindelige"], + tags: ["tilskud", "puljer", "foreninger", "borgerservice"], + aiTags: ["ansøgningsfrist"], + createdAt: "2025-04-01", + updatedAt: "2025-05-02", + readme: + "# Tilskuds- og puljevejleder\n\nHjælper borgere og foreninger med at finde den rette pulje.\n\nAssistenten:\n- foreslår relevante puljer ud fra projektbeskrivelsen\n- oplyser frister og krav\n- henviser til ansøgningsportalen\n\nDen behandler ikke ansøgninger og kan ikke love bevilling.", + modelCard: + "Sprogmodel: Llama 3.1 70B (lokal via OWUI).\nKontekstvindue: 8192 tokens.\nTemperatur: 0.3.\n\nHensyn:\n- Puljer og frister ændrer sig løbende — hold vidensgrundlaget opdateret.\n- Assistenten må ikke love bevilling eller foregribe en vurdering.", + knowledgeRecipe: + "1. Saml en oversigt over kommunens aktuelle puljer med formål, frister og krav.\n2. Tilføj links til de relevante ansøgningsportaler.\n3. Upload som Knowledge-collection \"Puljer\".\n4. Opdatér mindst hvert kvartal, da frister skifter.", + versions: [ + { + version: "1.0.0", + releasedAt: "2025-05-02", + notes: "Første offentlige version.", + json: { + id: "puljevejleder", + name: "Tilskuds- og puljevejleder", + object: "model", + base_model_id: "llama3.1:70b", + meta: { + description: "Vejleder om kommunens tilskuds- og puljemuligheder.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "tilskud" }, { name: "puljer" }] + }, + params: { + system: "Du vejleder borgere og foreninger om kommunens puljer. Foreslå relevante puljer ud fra projektbeskrivelsen, og oplys frister og krav. Lov aldrig bevilling.", + temperature: 0.3, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [ + { name: "Puljer", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + } +]; diff --git a/docs/public/projects/ai-bibliotek/mocks/index.html b/docs/public/projects/ai-bibliotek/mocks/index.html new file mode 100644 index 0000000..846f366 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/index.html @@ -0,0 +1,61 @@ + + + + + + AI Bibliotek – prototype + + + + + + + + + + + + +
+
+
+ +
+ +
+ +
+ + + + + diff --git a/docs/public/projects/ai-bibliotek/mocks/js/app.js b/docs/public/projects/ai-bibliotek/mocks/js/app.js new file mode 100644 index 0000000..888bf71 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/app.js @@ -0,0 +1,131 @@ +import { el, clear, parseHash, setActiveNav, navigate } from "./util.js"; +import { auth } from "./auth.js"; +import { store } from "./store.js"; + +import * as Home from "./views/home.js"; +import * as Login from "./views/login.js"; +import * as Upload from "./views/upload.js"; +import * as Search from "./views/search.js"; +import * as Detail from "./views/detail.js"; +import * as Favorites from "./views/favorites.js"; +import * as Collections from "./views/collections.js"; +import * as Uploads from "./views/uploads.js"; + +const routes = [ + { test: (p) => p === "/" || p === "", view: Home, public: true, width: "wide" }, + { test: (p) => p === "/login", view: Login, public: true, width: "narrow" }, + { test: (p) => p === "/search", view: Search, public: true, width: "wide" }, + { test: (p) => p === "/upload", view: Upload, public: false, width: "wide" }, + { test: (p) => p === "/uploads", view: Uploads, public: false, width: "wide" }, + { test: (p) => p === "/favorites", view: Favorites, public: false, width: "wide" }, + { test: (p) => p === "/collections", view: Collections, public: false, width: "wide" }, + { + test: (p) => p.startsWith("/assistant/"), + view: Detail, + public: true, + width: "wide", + params: (p) => ({ id: p.replace("/assistant/", "") }) + }, + { + test: (p) => p.startsWith("/collection/"), + view: { render: Collections.renderShared }, + public: true, + width: "narrow", + params: (p) => ({ token: p.replace("/collection/", "") }) + } +]; + +function renderUserArea() { + const area = document.getElementById("user-area"); + if (!area) return; + clear(area); + const user = auth.currentUser(); + if (user) { + area.appendChild(el("span", { class: "user-name", title: user.email }, + user.name + (user.organization ? ` · ${user.organization}` : ""))); + area.appendChild(el("button", { + class: "btn btn-secondary btn-sm", + onclick: () => { + auth.logout(); + renderUserArea(); + renderAuthGuardedLinks(); + navigate("#/"); + } + }, "Log ud")); + } else { + area.appendChild(el("a", { class: "btn btn-secondary btn-sm", href: "#/login" }, "Log ind")); + area.appendChild(el("a", { class: "btn btn-sm", href: "#/login?mode=register" }, "Opret bruger")); + } +} + +function renderAuthGuardedLinks() { + const loggedIn = !!auth.currentUser(); + document.querySelectorAll("[data-auth-required]").forEach(node => { + node.style.display = loggedIn ? "" : "none"; + }); +} + +function route() { + const { path, query } = parseHash(); + const root = document.getElementById("view-root"); + if (!root) return; + + const match = routes.find(r => r.test(path)); + if (!match) { + clear(root); + root.appendChild(el("div", { class: "empty card" }, [ + el("h2", {}, "Siden findes ikke"), + el("a", { class: "btn", href: "#/" }, "Til forsiden") + ])); + return; + } + if (!match.public && !auth.currentUser()) { + navigate("#/login"); + return; + } + + const params = match.params ? match.params(path) : {}; + setActiveNav(`#${path}`); + + // Switch
container width per route. + const mainEl = document.getElementById("main"); + if (mainEl) { + mainEl.classList.toggle("container", match.width === "narrow"); + mainEl.classList.toggle("container-wide", match.width !== "narrow"); + } + + match.view.render(root, query, params); + + // Reset scroll on navigation + window.scrollTo({ top: 0, behavior: "instant" }); +} + +window.addEventListener("hashchange", () => { + renderUserArea(); + renderAuthGuardedLinks(); + route(); +}); + +function wireNavToggle() { + const toggle = document.getElementById("nav-toggle"); + const nav = document.getElementById("primary-nav"); + if (!toggle || !nav) return; + toggle.addEventListener("click", () => { + const open = nav.classList.toggle("is-open"); + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + // Close after navigating + nav.addEventListener("click", (e) => { + if (e.target.tagName === "A") { + nav.classList.remove("is-open"); + toggle.setAttribute("aria-expanded", "false"); + } + }); +} + +window.addEventListener("DOMContentLoaded", () => { + wireNavToggle(); + renderUserArea(); + renderAuthGuardedLinks(); + route(); +}); diff --git a/docs/public/projects/ai-bibliotek/mocks/js/auth.js b/docs/public/projects/ai-bibliotek/mocks/js/auth.js new file mode 100644 index 0000000..a2b3390 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/auth.js @@ -0,0 +1,60 @@ +/* Fake auth — passwords are obfuscated with a trivial hash for demo only. + Do not reuse this anywhere real. */ + +import { store, uid } from "./store.js"; + +function fakeHash(input) { + let h = 0; + for (let i = 0; i < input.length; i++) { + h = (h * 31 + input.charCodeAt(i)) >>> 0; + } + return `h${h.toString(36)}`; +} + +export const auth = { + currentUser() { + const session = store.getSession(); + if (!session) return null; + return store.getUsers().find(u => u.id === session.userId) || null; + }, + + register({ email, name, organization, password }) { + email = email.trim().toLowerCase(); + if (!email || !password || !name) { + throw new Error("Udfyld navn, e-mail og adgangskode."); + } + if (password.length < 4) { + throw new Error("Adgangskoden skal være mindst 4 tegn."); + } + const users = store.getUsers(); + if (users.some(u => u.email === email)) { + throw new Error("En bruger med denne e-mail findes allerede."); + } + const user = { + id: uid("user"), + email, + name: name.trim(), + organization: organization?.trim() || "", + passwordHash: fakeHash(password), + createdAt: new Date().toISOString() + }; + users.push(user); + store.setUsers(users); + store.setSession({ userId: user.id }); + return user; + }, + + login({ email, password }) { + email = email.trim().toLowerCase(); + const user = store.getUsers().find(u => u.email === email); + if (!user || user.passwordHash !== fakeHash(password)) { + throw new Error("Forkert e-mail eller adgangskode."); + } + store.setSession({ userId: user.id }); + return user; + }, + + logout() { + store.setSession(null); + } +}; diff --git a/docs/public/projects/ai-bibliotek/mocks/js/catalog.js b/docs/public/projects/ai-bibliotek/mocks/js/catalog.js new file mode 100644 index 0000000..aeed212 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/catalog.js @@ -0,0 +1,99 @@ +/* Catalog: merges seeded assistants with user-shared assistants, plus search/filter. */ + +import { store } from "./store.js"; + +/* Framework labels. Designed so more frameworks can be added later. */ +export const FRAMEWORK_LABEL = { + openwebui: "OpenWebUI" +}; + +/* Data sensitivity levels — what the assistant is intended/approved for. + Replaces the old green/yellow/red risk concept. */ +export const DATA_SENSITIVITY = [ + { + level: "almindelige", + title: "Almindelige personoplysninger", + description: "Beregnet til almindelige personoplysninger og ikke-følsomt indhold." + }, + { + level: "fortrolige", + title: "Fortrolige data", + description: "Beregnet til fortrolige data — kræver passende organisatoriske foranstaltninger." + }, + { + level: "personfoelsomme", + title: "Personfølsomme data", + description: "Beregnet til personfølsomme oplysninger (særlige kategorier) — kræver skærpet beskyttelse." + } +]; + +export function dataSensitivityInfo(level) { + return DATA_SENSITIVITY.find(d => d.level === level) || DATA_SENSITIVITY[0]; +} + +export function getAllAssistants() { + const seeded = window.SEED_ASSISTANTS || []; + const uploads = store.getUploads(); + return [...uploads, ...seeded]; +} + +export function getAssistant(id) { + return getAllAssistants().find(a => a.id === id) || null; +} + +/* Return the most recent version of an assistant (versions are stored newest-first). */ +export function latestVersion(assistant) { + if (!assistant?.versions?.length) return null; + return assistant.versions[0]; +} + +/* Search + filter + filters: { + q: string, + kommuner: Set, + languageModels: Set, + frameworks: Set, + sensitivities: Set + } +*/ +export function searchAssistants(filters) { + const q = (filters.q || "").trim().toLowerCase(); + const tokens = q ? q.split(/\s+/).filter(Boolean) : []; + + return getAllAssistants().filter(a => { + if (tokens.length) { + const hay = [ + a.name, a.tagline, a.description, a.originKommune, + a.languageModel, + (a.tags || []).join(" ") + ].filter(Boolean).join(" ").toLowerCase(); + if (!tokens.every(t => hay.includes(t))) return false; + } + if (filters.kommuner?.size && !filters.kommuner.has(a.originKommune)) return false; + if (filters.languageModels?.size && !filters.languageModels.has(a.languageModel)) return false; + if (filters.frameworks?.size && !filters.frameworks.has(a.framework)) return false; + if (filters.sensitivities?.size && !filters.sensitivities.has(a.dataSensitivity)) return false; + return true; + }); +} + +/* Build facet counts based on a base set (pre-filtered or all). */ +export function buildFacets(base) { + const facets = { + kommuner: new Map(), + languageModels: new Map(), + frameworks: new Map(), + sensitivities: new Map() + }; + for (const a of base) { + inc(facets.kommuner, a.originKommune); + inc(facets.languageModels, a.languageModel); + inc(facets.frameworks, a.framework); + inc(facets.sensitivities, a.dataSensitivity); + } + return facets; +} +function inc(map, key) { + if (key === undefined || key === null || key === "") return; + map.set(key, (map.get(key) || 0) + 1); +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/collections.js b/docs/public/projects/ai-bibliotek/mocks/js/collections.js new file mode 100644 index 0000000..9cf2c04 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/collections.js @@ -0,0 +1,71 @@ +/* Collections + share-URL encoding. + Because the prototype has no backend, a share token alone can't transfer + the collection between browsers. To make sharing actually demo-able, we + base64-encode the collection payload (with assistant snapshots) into + the URL after the token. The token still uniquely identifies the + collection for the owner. */ + +import { store, uid } from "./store.js"; +import { getAssistant } from "./catalog.js"; +import { b64encodeObject, b64decodeObject } from "./util.js"; + +export function createCollection(ownerId, name, description = "") { + const collection = { + id: uid("col"), + name: name.trim(), + description: description.trim(), + ownerId, + assistantIds: [], + createdAt: new Date().toISOString(), + shareToken: Math.random().toString(36).slice(2, 10) + }; + store.addCollection(collection); + return collection; +} + +export function addAssistantToCollection(collectionId, assistantId) { + const all = store.getCollections(); + const c = all.find(x => x.id === collectionId); + if (!c) return null; + if (!c.assistantIds.includes(assistantId)) { + c.assistantIds.push(assistantId); + store.setCollections(all); + } + return c; +} + +export function removeAssistantFromCollection(collectionId, assistantId) { + const all = store.getCollections(); + const c = all.find(x => x.id === collectionId); + if (!c) return null; + c.assistantIds = c.assistantIds.filter(id => id !== assistantId); + store.setCollections(all); + return c; +} + +/* Encode a snapshot of the collection (with the actual assistants) + into a base64 string suitable for embedding in the URL hash. */ +export function encodeShareablePayload(collection) { + const assistants = collection.assistantIds + .map(id => getAssistant(id)) + .filter(Boolean); + return b64encodeObject({ + id: collection.id, + name: collection.name, + description: collection.description, + shareToken: collection.shareToken, + createdAt: collection.createdAt, + assistants + }); +} + +export function decodeShareablePayload(encoded) { + return b64decodeObject(encoded); +} + +export function buildShareUrl(collection) { + const payload = encodeShareablePayload(collection); + // Use full URL so user can copy and open in a fresh browser + const base = window.location.href.split("#")[0]; + return `${base}#/collection/${collection.shareToken}?d=${payload}`; +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/icons.js b/docs/public/projects/ai-bibliotek/mocks/js/icons.js new file mode 100644 index 0000000..a4b5ec1 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/icons.js @@ -0,0 +1,32 @@ +/* Minimal inline SVG line icons. Stroke uses currentColor so icons inherit + the button/text color. */ + +const PATHS = { + heart: '', + "heart-filled": '', + download: '', + upload: '', + link: '', + arrowLeft:'', + arrowRight:'', + check: '', + document: '', + plus: '', + code: '' +}; + +/** Return an HTML string for an icon. Use inside el({ html: ... }) or for + * composing with adjacent text via innerHTML. */ +export function iconHtml(name, { size = 18, stroke = 2 } = {}) { + const body = PATHS[name]; + if (!body) return ""; + return ``; +} + +/** Return an SVG element node. */ +export function icon(name, opts) { + const wrap = document.createElement("span"); + wrap.style.display = "inline-flex"; + wrap.innerHTML = iconHtml(name, opts); + return wrap.firstChild; +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/store.js b/docs/public/projects/ai-bibliotek/mocks/js/store.js new file mode 100644 index 0000000..8d2a6cd --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/store.js @@ -0,0 +1,108 @@ +/* localStorage-backed store. All state lives under the "ab:" prefix. */ + +const KEYS = { + users: "ab:users", + session: "ab:session", + assistants: "ab:assistants", + favorites: "ab:favorites", + collections: "ab:collections" +}; + +function read(key, fallback) { + try { + const raw = localStorage.getItem(key); + if (!raw) return fallback; + return JSON.parse(raw); + } catch { + return fallback; + } +} + +function write(key, value) { + localStorage.setItem(key, JSON.stringify(value)); +} + +export const store = { + getUsers() { return read(KEYS.users, []); }, + setUsers(users) { write(KEYS.users, users); }, + + getSession() { return read(KEYS.session, null); }, + setSession(session) { + if (session) write(KEYS.session, session); + else localStorage.removeItem(KEYS.session); + }, + + getUploads() { return read(KEYS.assistants, []); }, + setUploads(assistants) { write(KEYS.assistants, assistants); }, + addUpload(assistant) { + const all = store.getUploads(); + all.push(assistant); + store.setUploads(all); + }, + deleteUpload(assistantId) { + store.setUploads(store.getUploads().filter(a => a.id !== assistantId)); + // Also clean up any favorites and collection references to the dead assistant. + store.setFavorites(store.getFavorites().filter(f => f.assistantId !== assistantId)); + const collections = store.getCollections(); + let changed = false; + for (const c of collections) { + const before = c.assistantIds.length; + c.assistantIds = c.assistantIds.filter(id => id !== assistantId); + if (c.assistantIds.length !== before) changed = true; + } + if (changed) store.setCollections(collections); + }, + uploadsByUser(userId) { + return store.getUploads().filter(a => a.uploadedBy === userId); + }, + + getFavorites() { return read(KEYS.favorites, []); }, + setFavorites(favs) { write(KEYS.favorites, favs); }, + toggleFavorite(userId, assistantId) { + const all = store.getFavorites(); + const idx = all.findIndex(f => f.userId === userId && f.assistantId === assistantId); + if (idx >= 0) { + all.splice(idx, 1); + } else { + all.push({ userId, assistantId, addedAt: new Date().toISOString() }); + } + store.setFavorites(all); + return idx < 0; + }, + isFavorite(userId, assistantId) { + if (!userId) return false; + return store.getFavorites().some(f => f.userId === userId && f.assistantId === assistantId); + }, + favoritesForUser(userId) { + return store.getFavorites().filter(f => f.userId === userId); + }, + + getCollections() { return read(KEYS.collections, []); }, + setCollections(cs) { write(KEYS.collections, cs); }, + addCollection(c) { + const all = store.getCollections(); + all.push(c); + store.setCollections(all); + }, + updateCollection(id, patch) { + const all = store.getCollections(); + const idx = all.findIndex(c => c.id === id); + if (idx < 0) return null; + all[idx] = { ...all[idx], ...patch }; + store.setCollections(all); + return all[idx]; + }, + deleteCollection(id) { + store.setCollections(store.getCollections().filter(c => c.id !== id)); + }, + collectionsForUser(userId) { + return store.getCollections().filter(c => c.ownerId === userId); + }, + findCollectionByToken(token) { + return store.getCollections().find(c => c.shareToken === token) || null; + } +}; + +export function uid(prefix = "id") { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/util.js b/docs/public/projects/ai-bibliotek/mocks/js/util.js new file mode 100644 index 0000000..fa20347 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/util.js @@ -0,0 +1,111 @@ +/* Small shared utilities. */ + +export function el(tag, attrs = {}, children = []) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (v === false || v === null || v === undefined) continue; + if (k === "class") node.className = v; + else if (k === "html") node.innerHTML = v; + else if (k === "text") node.textContent = v; + else if (k.startsWith("on") && typeof v === "function") { + node.addEventListener(k.slice(2).toLowerCase(), v); + } else if (k === "dataset") { + Object.assign(node.dataset, v); + } else if (v === true) { + node.setAttribute(k, ""); + } else { + node.setAttribute(k, v); + } + } + for (const child of [].concat(children)) { + if (child === null || child === undefined || child === false) continue; + node.appendChild(typeof child === "string" ? document.createTextNode(child) : child); + } + return node; +} + +export function escapeHtml(s) { + if (s === null || s === undefined) return ""; + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function formatDate(iso) { + if (!iso) return ""; + const d = new Date(iso); + if (isNaN(d)) return ""; + return d.toLocaleDateString("da-DK", { year: "numeric", month: "long", day: "numeric" }); +} + +export function formatYear(iso) { + if (!iso) return ""; + const d = new Date(iso); + return isNaN(d) ? "" : String(d.getFullYear()); +} + +export function formatSize(bytes) { + if (!bytes) return "—"; + const units = ["B", "kB", "MB", "GB"]; + let i = 0; + let n = bytes; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; +} + +export function toast(message) { + const region = document.getElementById("toast-region"); + if (!region) return; + const t = el("div", { class: "toast", role: "status" }, message); + region.appendChild(t); + setTimeout(() => { t.style.opacity = "0"; t.style.transition = "opacity 0.3s"; }, 2200); + setTimeout(() => t.remove(), 2600); +} + +export function clear(node) { + while (node.firstChild) node.removeChild(node.firstChild); +} + +export function navigate(hash) { + window.location.hash = hash; +} + +export function parseHash() { + const raw = window.location.hash.replace(/^#/, "") || "/"; + const [path, queryString = ""] = raw.split("?"); + const query = {}; + for (const part of queryString.split("&")) { + if (!part) continue; + const [k, v = ""] = part.split("="); + query[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, " ")); + } + return { path, query }; +} + +export function setActiveNav(hash) { + const nav = document.querySelector(".site-nav"); + if (!nav) return; + nav.querySelectorAll("a").forEach(a => { + a.classList.toggle("active", a.getAttribute("href") === hash); + }); +} + +export function b64encodeObject(obj) { + const json = JSON.stringify(obj); + // unicode-safe base64 + return btoa(unescape(encodeURIComponent(json))) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function b64decodeObject(s) { + try { + const padded = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4); + const json = decodeURIComponent(escape(atob(padded))); + return JSON.parse(json); + } catch { + return null; + } +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/_assistant-card.js b/docs/public/projects/ai-bibliotek/mocks/js/views/_assistant-card.js new file mode 100644 index 0000000..a774499 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/_assistant-card.js @@ -0,0 +1,70 @@ +/* Shared assistant card used in search results, favorites, collections. */ + +import { el, escapeHtml, toast } from "../util.js"; +import { auth } from "../auth.js"; +import { store } from "../store.js"; +import { FRAMEWORK_LABEL, dataSensitivityInfo } from "../catalog.js"; +import { iconHtml } from "../icons.js"; + +const SENSITIVITY_LABEL = { + almindelige: "Almindelige", + fortrolige: "Fortrolige", + personfoelsomme: "Personfølsomme" +}; + +export function renderAssistantCard(a, opts = {}) { + const user = auth.currentUser(); + const isFav = user ? store.isFavorite(user.id, a.id) : false; + const sensitivity = dataSensitivityInfo(a.dataSensitivity); + + const favBtn = el("button", { + class: "btn-icon" + (isFav ? " is-on" : ""), + title: isFav ? "Fjern favorit" : "Tilføj som favorit", + "aria-label": isFav ? "Fjern favorit" : "Tilføj som favorit", + html: iconHtml(isFav ? "heart-filled" : "heart"), + onclick: (e) => { + e.preventDefault(); + const u = auth.currentUser(); + if (!u) { + toast("Log ind for at gemme favoritter"); + window.location.hash = "#/login"; + return; + } + const nowFav = store.toggleFavorite(u.id, a.id); + favBtn.classList.toggle("is-on", nowFav); + favBtn.setAttribute("aria-label", nowFav ? "Fjern favorit" : "Tilføj som favorit"); + favBtn.title = nowFav ? "Fjern favorit" : "Tilføj som favorit"; + favBtn.innerHTML = iconHtml(nowFav ? "heart-filled" : "heart"); + if (opts.onFavoriteChange) opts.onFavoriteChange(nowFav); + } + }); + + const card = el("article", { class: "pub-card" }, [ + el("div", {}, [ + el("h3", {}, [ + el("a", { href: `#/assistant/${a.id}`, html: escapeHtml(a.name) }) + ]), + el("div", { class: "meta" }, + `${a.originKommune} · ${a.languageModel}` + (a.source === "user" ? " · delt af dig" : "")), + el("p", { class: "summary", text: trim(a.description, 240) }), + el("div", { class: "badges" }, [ + el("span", { class: "badge badge-framework" }, FRAMEWORK_LABEL[a.framework] || a.framework || "Rammeværk"), + el("span", { + class: "badge badge-sensitivity", + dataset: { level: a.dataSensitivity || "almindelige" }, + title: sensitivity.description + }, SENSITIVITY_LABEL[a.dataSensitivity] || sensitivity.title) + ]) + ]), + el("div", { class: "actions" }, [ + favBtn, + opts.extraAction || null + ]) + ]); + return card; +} + +function trim(s, n) { + if (!s) return ""; + return s.length > n ? s.slice(0, n - 1) + "…" : s; +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/_collection-modal.js b/docs/public/projects/ai-bibliotek/mocks/js/views/_collection-modal.js new file mode 100644 index 0000000..041c8d4 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/_collection-modal.js @@ -0,0 +1,93 @@ +import { el, toast, navigate } from "../util.js"; +import { auth } from "../auth.js"; +import { store } from "../store.js"; +import { createCollection, addAssistantToCollection } from "../collections.js"; + +/* Modal for working with collections. + - assistant provided → "Føj til samling": pick an existing or create + add. + - assistant omitted → "Opret ny samling": only the create form. + opts.onCreated(collection) fires after a new collection is created. */ +export function showCollectionModal(assistant = null, opts = {}) { + const user = auth.currentUser(); + if (!user) { toast("Log ind for at bruge samlinger"); navigate("#/login"); return; } + + const backdrop = el("div", { + class: "modal-backdrop", + onclick: (e) => { if (e.target === backdrop) close(); } + }); + const modal = el("div", { class: "modal", role: "dialog", "aria-modal": "true" }); + + function close() { backdrop.remove(); } + + if (assistant) { + modal.appendChild(el("h3", {}, "Føj til samling")); + modal.appendChild(el("p", { class: "muted text-sm" }, `“${assistant.name}” gemmes i den valgte samling.`)); + + const own = store.collectionsForUser(user.id); + if (own.length) { + const list = el("div", { class: "form-grid" }); + for (const c of own) { + const already = c.assistantIds.includes(assistant.id); + list.appendChild(el("button", { + class: "btn btn-secondary", + style: "justify-content:flex-start;", + onclick: () => { + if (already) { toast("Assistenten er allerede i samlingen."); close(); return; } + addAssistantToCollection(c.id, assistant.id); + toast(`Tilføjet til “${c.name}”`); + close(); + } + }, c.name + (already ? " · allerede tilføjet" : ` · ${c.assistantIds.length} assistenter`))); + } + modal.appendChild(list); + modal.appendChild(el("hr", { class: "divider" })); + } + } else { + modal.appendChild(el("h3", {}, "Opret ny samling")); + modal.appendChild(el("p", { class: "muted text-sm" }, "Saml relaterede assistenter og del dem som et link.")); + } + + const form = el("form", { + class: "form-grid", + onsubmit: (e) => { + e.preventDefault(); + const data = Object.fromEntries(new FormData(e.target)); + const name = (data.name || "").trim(); + if (!name) { toast("Giv samlingen et navn"); return; } + const c = createCollection(user.id, name, data.description || ""); + if (assistant) { + addAssistantToCollection(c.id, assistant.id); + toast(`Oprettet “${c.name}” og tilføjet assistent`); + } else { + toast(`Samling “${c.name}” oprettet`); + } + close(); + opts.onCreated?.(c); + } + }, [ + assistant ? el("strong", {}, "…eller opret en ny samling") : null, + el("label", { class: "field" }, [ + "Navn", + el("input", { type: "text", name: "name", required: true, placeholder: "f.eks. Borgerservice-assistenter", autofocus: true }) + ]), + el("label", { class: "field" }, [ + "Beskrivelse (valgfri)", + el("input", { type: "text", name: "description" }) + ]), + el("div", { class: "modal-actions" }, [ + el("button", { type: "button", class: "btn btn-secondary", onclick: close }, "Annullér"), + el("button", { type: "submit", class: "btn" }, assistant ? "Opret og tilføj" : "Opret samling") + ]) + ]); + modal.appendChild(form); + + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + // Focus the name input for fast keyboard flow + const nameInput = modal.querySelector('input[name="name"]'); + nameInput?.focus(); +} + +/* Backwards-compatible alias (detail.js still imports this). */ +export const showAddToCollectionModal = (assistant) => showCollectionModal(assistant); diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/collections.js b/docs/public/projects/ai-bibliotek/mocks/js/views/collections.js new file mode 100644 index 0000000..c4d3129 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/collections.js @@ -0,0 +1,170 @@ +import { el, clear, navigate, toast } from "../util.js"; +import { auth } from "../auth.js"; +import { store } from "../store.js"; +import { getAssistant } from "../catalog.js"; +import { removeAssistantFromCollection, buildShareUrl, decodeShareablePayload } from "../collections.js"; +import { renderAssistantCard } from "./_assistant-card.js"; +import { showCollectionModal } from "./_collection-modal.js"; +import { iconHtml } from "../icons.js"; + +/* List of own collections */ +export function render(root) { + const user = auth.currentUser(); + if (!user) { navigate("#/login"); return; } + + function paint() { + clear(root); + + const header = el("div", { + class: "row wrap", + style: "justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom: 8px;" + }, [ + el("div", {}, [ + el("h1", { style: "margin:0;" }, "Mine samlinger"), + el("p", { class: "muted", style: "margin:4px 0 0;" }, + "Saml relaterede assistenter og del dem som et link – f.eks. til kolleger eller på tværs af kommuner.") + ]), + el("button", { + class: "btn", + html: `${iconHtml("plus")}Opret ny samling`, + onclick: () => showCollectionModal(null, { onCreated: paint }) + }) + ]); + root.appendChild(header); + + const own = store.collectionsForUser(user.id); + if (!own.length) { + root.appendChild(el("div", { class: "empty card mt-4" }, [ + el("h2", {}, "Du har ikke oprettet nogen samlinger endnu"), + el("p", { class: "muted" }, "Klik på “Opret ny samling” for at komme i gang.") + ])); + return; + } + + own.forEach(c => root.appendChild(renderCollectionCard(c, paint))); + } + + paint(); +} + +function renderCollectionCard(c, refresh) { + const card = el("section", { class: "card mt-4" }); + card.appendChild(el("div", { class: "row wrap", style: "justify-content:space-between; align-items:flex-start;" }, [ + el("div", {}, [ + el("h2", { style: "margin:0;" }, c.name), + c.description ? el("p", { class: "muted", style: "margin:4px 0 0;" }, c.description) : null, + el("p", { class: "muted text-sm" }, `${c.assistantIds.length} assistenter · opdateret ${new Date(c.createdAt).toLocaleDateString("da-DK")}`) + ]), + el("div", { class: "row" }, [ + el("button", { + class: "btn btn-secondary btn-sm", + html: `${iconHtml("link", { size: 16 })}Kopier delelink`, + onclick: async () => { + const url = buildShareUrl(c); + try { + await navigator.clipboard.writeText(url); + toast("Delelink kopieret"); + } catch { + prompt("Kopier delelinket:", url); + } + } + }), + el("a", { + class: "btn btn-secondary btn-sm", + href: `#/collection/${c.shareToken}`, + title: "Åbn samlingsvisning" + }, "Vis"), + el("button", { + class: "btn btn-danger btn-sm", + onclick: () => { + if (confirm(`Slet samlingen "${c.name}"?`)) { + store.deleteCollection(c.id); + refresh(); + } + } + }, "Slet") + ]) + ])); + + if (!c.assistantIds.length) { + card.appendChild(el("p", { class: "muted mt-3" }, "Endnu ingen assistenter. Tilføj fra en assistents side.")); + return card; + } + + const list = el("div", { class: "results-list mt-3" }); + c.assistantIds.forEach(aid => { + const a = getAssistant(aid); + if (!a) return; + list.appendChild(renderAssistantCard(a, { + extraAction: el("button", { + class: "btn btn-ghost btn-sm", + onclick: () => { + removeAssistantFromCollection(c.id, a.id); + refresh(); + } + }, "Fjern") + })); + }); + card.appendChild(list); + return card; +} + +/* Shared collection view (#/collection/?d=) */ +export function renderShared(root, query = {}, params = {}) { + clear(root); + const token = params.token; + const localMatch = store.findCollectionByToken(token); + let collection = localMatch; + let assistants; + let isShared = false; + + if (query.d) { + const decoded = decodeShareablePayload(query.d); + if (decoded && decoded.shareToken === token) { + isShared = true; + collection = collection || { + id: decoded.id, + name: decoded.name, + description: decoded.description, + shareToken: decoded.shareToken, + createdAt: decoded.createdAt, + assistantIds: (decoded.assistants || []).map(a => a.id), + ownerId: null + }; + assistants = decoded.assistants; + } + } + + if (!collection) { + root.appendChild(el("div", { class: "empty card" }, [ + el("h2", {}, "Samling ikke fundet"), + el("p", { class: "muted" }, "Linket er ugyldigt eller udløbet. Bed afsenderen om at sende det fulde delelink igen."), + el("a", { class: "btn", href: "#/" }, "Til forsiden") + ])); + return; + } + + assistants = assistants || collection.assistantIds.map(id => getAssistant(id)).filter(Boolean); + + root.appendChild(el("nav", { class: "muted text-sm mb-3" }, [ + el("a", { href: "#/collections" }, "Samlinger"), " / ", el("span", {}, collection.name) + ])); + root.appendChild(el("h1", {}, collection.name)); + if (collection.description) root.appendChild(el("p", { class: "muted" }, collection.description)); + + if (isShared) { + root.appendChild(el("div", { class: "share-notice" }, + "Du ser en delt samling. Assistenterne er indlejret i delelinket og kan tilgås uden login.")); + } + + if (!assistants.length) { + root.appendChild(el("div", { class: "empty card" }, [ + el("p", { class: "muted" }, "Samlingen er tom.") + ])); + return; + } + + const list = el("div", { class: "results-list mt-3" }); + assistants.forEach(a => list.appendChild(renderAssistantCard(a))); + root.appendChild(list); +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/detail.js b/docs/public/projects/ai-bibliotek/mocks/js/views/detail.js new file mode 100644 index 0000000..16f7afe --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/detail.js @@ -0,0 +1,272 @@ +import { el, clear, escapeHtml, formatDate, navigate, toast } from "../util.js"; +import { getAssistant, dataSensitivityInfo, FRAMEWORK_LABEL, latestVersion } from "../catalog.js"; +import { auth } from "../auth.js"; +import { store } from "../store.js"; +import { iconHtml } from "../icons.js"; + +const SENSITIVITY_LABEL = { + almindelige: "Almindelige personoplysninger", + fortrolige: "Fortrolige data", + personfoelsomme: "Personfølsomme data" +}; + +export function render(root, _query, params) { + clear(root); + const assistant = getAssistant(params.id); + if (!assistant) { + root.appendChild(el("div", { class: "empty card" }, [ + el("h2", {}, "Assistent ikke fundet"), + el("p", {}, "Linket peger ikke på en eksisterende assistent."), + el("a", { class: "btn", href: "#/search" }, "Tilbage til kataloget") + ])); + return; + } + + const user = auth.currentUser(); + const isFav = user ? store.isFavorite(user.id, assistant.id) : false; + const sensitivity = dataSensitivityInfo(assistant.dataSensitivity); + const versions = assistant.versions || []; + + const grid = el("div", { class: "detail-grid" }); + const main = el("article", { class: "detail-main" }); + const meta = el("aside", { class: "detail-meta-aside" }); + const actionsAside = el("aside", { class: "detail-actions-aside" }); + + root.appendChild(el("nav", { class: "breadcrumb" }, [ + el("a", { href: "#/search" }, "Katalog"), + el("span", { class: "breadcrumb-sep", "aria-hidden": "true" }, "/"), + el("span", {}, assistant.originKommune) + ])); + + main.appendChild(el("p", { class: "eyebrow" }, `${assistant.originKommune} · ${assistant.languageModel}`)); + main.appendChild(el("h1", { html: escapeHtml(assistant.name) })); + if (assistant.tagline) main.appendChild(el("p", { class: "detail-subtitle", html: escapeHtml(assistant.tagline) })); + + main.appendChild(el("div", { class: "badges mb-4" }, [ + el("span", { class: "badge badge-framework" }, FRAMEWORK_LABEL[assistant.framework] || assistant.framework), + el("span", { + class: "badge badge-sensitivity", + dataset: { level: assistant.dataSensitivity }, + title: sensitivity.description + }, SENSITIVITY_LABEL[assistant.dataSensitivity] || sensitivity.title) + ])); + + // --- Tabbed sections --- + const tabs = [ + { id: "beskrivelse", label: "Beskrivelse" }, + { id: "modelkort", label: "Modelkort" }, + { id: "readme", label: "Readme" }, + { id: "viden", label: "Viden" }, + { id: "json", label: "JSON" } + ]; + + const tabBar = el("div", { class: "auth-tabs", role: "tablist", "aria-label": "Assistentdetaljer" }); + const panel = el("div", { class: "tab-panel mt-4" }); + + function showTab(id) { + tabBar.querySelectorAll("button").forEach(b => { + const active = b.dataset.tab === id; + b.classList.toggle("is-active", active); + b.setAttribute("aria-selected", active ? "true" : "false"); + }); + clear(panel); + panel.appendChild(renderPanel(id)); + } + + tabs.forEach((t, i) => { + tabBar.appendChild(el("button", { + type: "button", + role: "tab", + "data-tab": t.id, + class: i === 0 ? "is-active" : "", + "aria-selected": i === 0 ? "true" : "false", + onclick: () => showTab(t.id) + }, t.label)); + }); + + main.appendChild(tabBar); + main.appendChild(panel); + showTab("beskrivelse"); + + function renderPanel(id) { + if (id === "beskrivelse") { + const wrap = el("div", {}); + wrap.appendChild(multiline(assistant.description)); + if (assistant.tags?.length) { + wrap.appendChild(el("h3", { class: "mt-4" }, "Tags")); + const aiSet = new Set(assistant.aiTags || []); + wrap.appendChild(el("div", { class: "chips" }, assistant.tags.map(tag => + el("span", { + class: "chip", + title: aiSet.has(tag) ? "AI-foreslået tag" : null + }, aiSet.has(tag) ? `${tag} · AI-foreslået` : tag)))); + } + return wrap; + } + if (id === "modelkort") { + const wrap = el("div", {}); + wrap.appendChild(el("h3", { style: "margin-top:0;" }, "Modelkort")); + wrap.appendChild(el("p", { class: "muted" }, "Sprogmodel og hensyn ved brug af assistenten.")); + wrap.appendChild(multiline(assistant.modelCard)); + return wrap; + } + if (id === "readme") { + const wrap = el("div", {}); + wrap.appendChild(multiline(assistant.readme)); + return wrap; + } + if (id === "viden") { + const wrap = el("div", {}); + wrap.appendChild(el("h3", { style: "margin-top:0;" }, "Vidensopskrift")); + wrap.appendChild(el("p", { class: "muted" }, + "Selve videns- og datafilerne kan ikke deles mellem kommuner. Hver kommune leverer sit eget grundlag efter denne opskrift.")); + wrap.appendChild(multiline(assistant.knowledgeRecipe)); + return wrap; + } + if (id === "json") { + return renderJsonPanel(); + } + return el("div", {}); + } + + function renderJsonPanel() { + const wrap = el("div", {}); + wrap.appendChild(el("h3", { style: "margin-top:0;" }, "Eksportér konfiguration")); + wrap.appendChild(el("p", { class: "muted" }, + "Vælg en version og eksportér assistentens OpenWebUI-JSON. Importér filen i din egen OpenWebUI.")); + + if (!versions.length) { + wrap.appendChild(el("p", { class: "muted" }, "Ingen versioner tilgængelige.")); + return wrap; + } + + let selected = versions[0]; + + const select = el("select", { + "aria-label": "Vælg version", + onchange: (e) => { + selected = versions[e.target.selectedIndex] || versions[0]; + renderJson(); + } + }, versions.map(v => + el("option", { value: v.version }, `v${v.version} · ${formatDate(v.releasedAt)}`))); + + const exportBtn = el("button", { + class: "btn", + html: `${iconHtml("download")}Eksportér JSON`, + onclick: () => downloadVersion(assistant, selected) + }); + + wrap.appendChild(el("div", { class: "row wrap", style: "gap:12px; align-items:flex-end; margin-bottom:12px;" }, [ + el("label", { class: "field", style: "margin:0;" }, ["Version", select]), + exportBtn + ])); + + const notes = el("p", { class: "muted text-sm" }); + const pre = el("pre", { class: "code-block", style: "white-space:pre; overflow:auto; max-height:420px; background:var(--color-surface-2); padding:16px; border-radius:8px; border:1px solid var(--color-line);" }); + + function renderJson() { + notes.textContent = selected.notes ? `Ændringer: ${selected.notes}` : ""; + pre.innerHTML = escapeHtml(JSON.stringify(selected.json, null, 2)); + } + renderJson(); + + wrap.appendChild(notes); + wrap.appendChild(pre); + return wrap; + } + + // --- Actions column --- + const actions = el("div", { class: "detail-actions" }); + actions.appendChild(el("button", { + class: "btn", + html: `${iconHtml("download")}Hjemtag (eksportér JSON)`, + onclick: () => { + const v = latestVersion(assistant); + if (!v) { toast("Ingen version at eksportere"); return; } + downloadVersion(assistant, v); + } + })); + + function favLabel(active) { + return `${iconHtml(active ? "heart-filled" : "heart")}${active ? "Favorit" : "Føj til favoritter"}`; + } + const favBtn = el("button", { + class: "btn btn-secondary", + html: favLabel(isFav), + onclick: () => { + const u = auth.currentUser(); + if (!u) { toast("Log ind for at gemme favoritter"); navigate("#/login"); return; } + const now = store.toggleFavorite(u.id, assistant.id); + favBtn.innerHTML = favLabel(now); + } + }); + actions.appendChild(favBtn); + + actions.appendChild(el("button", { + class: "btn btn-secondary", + html: `${iconHtml("plus")}Føj til samling`, + onclick: () => openAddToCollection(assistant) + })); + actionsAside.appendChild(actions); + + // --- Metadata column --- + meta.appendChild(el("p", { class: "eyebrow" }, "Detaljer")); + const dl = el("dl", { class: "detail-meta" }); + appendMeta(dl, "Oprindelseskommune", assistant.originKommune); + appendMeta(dl, "Sprogmodel", assistant.languageModel); + appendMeta(dl, "Rammeværk", FRAMEWORK_LABEL[assistant.framework] || assistant.framework); + appendMeta(dl, "Datafølsomhed", SENSITIVITY_LABEL[assistant.dataSensitivity] || sensitivity.title); + if (assistant.approvedFor?.length) { + appendMeta(dl, "Godkendt til", assistant.approvedFor.map(l => SENSITIVITY_LABEL[l] || l).join(", ")); + } + appendMeta(dl, "Dato oprettet", formatDate(assistant.createdAt)); + appendMeta(dl, "Dato opdateret", formatDate(assistant.updatedAt)); + appendMeta(dl, "Antal versioner", String(versions.length)); + meta.appendChild(dl); + + grid.appendChild(main); + grid.appendChild(meta); + grid.appendChild(actionsAside); + root.appendChild(grid); +} + +/* Split a multi-paragraph string into

elements, preserving single line + breaks inside a paragraph via white-space:pre-wrap. */ +function multiline(text) { + const wrap = el("div", { class: "resume-block" }); + const blocks = String(text || "").split(/\n{2,}/); + blocks.forEach(b => { + if (!b.trim()) return; + wrap.appendChild(el("p", { style: "white-space:pre-wrap;", text: b })); + }); + if (!wrap.childNodes.length) wrap.appendChild(el("p", { class: "muted" }, "Ingen tekst.")); + return wrap; +} + +/* Trigger a real download of the version's JSON via a Blob + object URL. */ +function downloadVersion(assistant, version) { + const data = JSON.stringify(version.json, null, 2); + const blob = new Blob([data], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const slug = (version.json?.id || assistant.id || "assistant").replace(/[^a-z0-9-]+/gi, "-"); + const a = document.createElement("a"); + a.href = url; + a.download = `${slug}-v${version.version}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast(`Eksporterede ${slug}-v${version.version}.json`); +} + +function appendMeta(dl, label, value) { + if (!value) return; + dl.appendChild(el("dt", {}, label)); + dl.appendChild(el("dd", { text: value })); +} + +async function openAddToCollection(assistant) { + const { showAddToCollectionModal } = await import("./_collection-modal.js"); + showAddToCollectionModal(assistant); +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/favorites.js b/docs/public/projects/ai-bibliotek/mocks/js/views/favorites.js new file mode 100644 index 0000000..9062f3c --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/favorites.js @@ -0,0 +1,35 @@ +import { el, clear, navigate } from "../util.js"; +import { auth } from "../auth.js"; +import { store } from "../store.js"; +import { getAssistant } from "../catalog.js"; +import { renderAssistantCard } from "./_assistant-card.js"; + +export function render(root) { + const user = auth.currentUser(); + if (!user) { navigate("#/login"); return; } + clear(root); + + function paint() { + clear(root); + root.appendChild(el("h1", {}, "Mine favoritter")); + const favs = store.favoritesForUser(user.id); + const assistants = favs.map(f => getAssistant(f.assistantId)).filter(Boolean); + + if (!assistants.length) { + root.appendChild(el("div", { class: "empty card" }, [ + el("h2", {}, "Du har ingen favoritter endnu"), + el("p", { class: "muted" }, "Marker en assistent med hjertet for at gemme den her."), + el("a", { class: "btn", href: "#/search" }, "Find assistenter") + ])); + return; + } + + const list = el("div", { class: "results-list" }); + assistants.forEach(a => list.appendChild(renderAssistantCard(a, { + onFavoriteChange: (isFav) => { if (!isFav) paint(); } + }))); + root.appendChild(list); + } + + paint(); +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/home.js b/docs/public/projects/ai-bibliotek/mocks/js/views/home.js new file mode 100644 index 0000000..bba2e48 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/home.js @@ -0,0 +1,153 @@ +import { el, clear, navigate, escapeHtml } from "../util.js"; +import { getAllAssistants } from "../catalog.js"; +import { iconHtml } from "../icons.js"; + +/* Future features teased on the home page but not wired to real routes. */ +const COMING_SOON = [ + "Deling af tools", + "Deling af skills", + "Ratings", + "API", + "Abonnér på ændringer", + "Testcases" +]; + +export function render(root) { + clear(root); + + const all = getAllAssistants(); + const kommuneCount = new Set(all.map(a => a.originKommune)).size; + const modelCount = new Set(all.map(a => a.languageModel)).size; + + const searchBlock = el("section", { class: "home-search", "aria-label": "Søg i kataloget" }, [ + el("p", { class: "eyebrow" }, "Find en assistent"), + el("form", { + class: "home-search-form", + role: "search", + onsubmit: (e) => { + e.preventDefault(); + const q = new FormData(e.target).get("q") || ""; + navigate(`#/search?q=${encodeURIComponent(q.trim())}`); + } + }, [ + el("input", { + type: "search", + name: "q", + placeholder: "Søg efter borgerservice, referat, journalisering…", + "aria-label": "Søg" + }), + el("button", { class: "btn", type: "submit" }, "Søg") + ]) + ]); + + const hero = el("section", { class: "hero" }, [ + el("p", { class: "eyebrow" }, "Del & hjemtag · dansk offentlig AI"), + el("h1", { class: "hero-title" }, [ + "Et fælles bibliotek over ", + el("em", {}, "kommunale"), + " AI-assistenter." + ]), + el("p", { class: "lede" }, + "Find, del og hjemtag AI-assistenter bygget af danske myndigheder. Når én kommune løser en opgave, kan resten hjemtage assistenten, eksportere konfigurationen og køre den lokalt — så gode løsninger skalerer nationalt."), + + el("dl", { class: "hero-stats" }, [ + el("div", {}, [ + el("dt", {}, "Assistenter"), + el("dd", {}, String(all.length)) + ]), + el("div", {}, [ + el("dt", {}, "Kommuner"), + el("dd", {}, String(kommuneCount)) + ]), + el("div", {}, [ + el("dt", {}, "Sprogmodeller"), + el("dd", {}, String(modelCount)) + ]) + ]) + ]); + + const recent = [...all] + .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)) + .slice(0, 8); + + const railEl = el("div", { class: "recent-rail" }, + recent.map(a => renderRailCard(a)) + ); + + function scrollRail(direction) { + const firstCard = railEl.querySelector(".rail-card"); + const step = firstCard ? firstCard.getBoundingClientRect().width + 16 : 320; + railEl.scrollBy({ left: direction * step, behavior: "smooth" }); + } + + const prevBtn = el("button", { + class: "rail-nav rail-nav-prev", + type: "button", + "aria-label": "Forrige", + onclick: () => scrollRail(-1), + html: iconHtml("arrowLeft", { size: 18 }) + }); + const nextBtn = el("button", { + class: "rail-nav rail-nav-next", + type: "button", + "aria-label": "Næste", + onclick: () => scrollRail(1), + html: iconHtml("arrowRight", { size: 18 }) + }); + + const rail = el("section", { class: "recent-section" }, [ + el("div", { class: "section-head" }, [ + el("p", { class: "eyebrow" }, "Senest opdateret"), + el("a", { class: "section-link", href: "#/search" }, "Se hele kataloget →") + ]), + el("div", { class: "recent-rail-wrap" }, [ + railEl, + el("div", { class: "rail-nav-bar" }, [prevBtn, nextBtn]) + ]) + ]); + + const aboutSection = el("section", { class: "card mt-5 how-it-works" }, [ + el("p", { class: "eyebrow" }, "Sådan virker det"), + el("ol", { class: "steps-list" }, [ + el("li", {}, el("span", { class: "step-body" }, [el("strong", {}, "Opret bruger"), " og log ind som repræsentant for din myndighed."])), + el("li", {}, el("span", { class: "step-body" }, [el("strong", {}, "Find en assistent"), " – søg og filtrér på kommune, sprogmodel og datafølsomhed."])), + el("li", {}, el("span", { class: "step-body" }, [el("strong", {}, "Hjemtag"), " – eksportér assistentens JSON og følg vidensopskriften, der beskriver hvilke data du selv skal levere."])), + el("li", {}, el("span", { class: "step-body" }, [el("strong", {}, "Tilpas lokalt"), " – importér i din egen OpenWebUI, tilføj kommunens viden og tag den i brug."])) + ]) + ]); + + const comingSoon = el("section", { class: "card mt-5" }, [ + el("p", { class: "eyebrow" }, "Kommer snart"), + el("p", { class: "muted", style: "margin:4px 0 0;" }, + "Funktioner vi arbejder på til kommende versioner. De er ikke aktive endnu."), + el("div", { class: "coming-soon-grid", "aria-label": "Kommende funktioner" }, + COMING_SOON.map(label => el("span", { + class: "coming-soon-chip", + "aria-disabled": "true", + title: "Kommer snart" + }, [label, el("span", { class: "cs-tag" }, "snart")])) + ) + ]); + + root.appendChild(hero); + root.appendChild(searchBlock); + root.appendChild(rail); + root.appendChild(aboutSection); + root.appendChild(comingSoon); +} + +function renderRailCard(a) { + return el("a", { + class: "rail-card", + href: `#/assistant/${a.id}` + }, [ + el("span", { class: "rail-meta" }, `${a.originKommune} · ${a.languageModel}`), + el("span", { class: "rail-title", html: escapeHtml(a.name) }), + el("span", { class: "rail-summary", text: trim(a.description, 110) }) + ]); +} + +function trim(s, n) { + if (!s) return ""; + return s.length > n ? s.slice(0, n - 1) + "…" : s; +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/login.js b/docs/public/projects/ai-bibliotek/mocks/js/views/login.js new file mode 100644 index 0000000..10bf726 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/login.js @@ -0,0 +1,97 @@ +import { el, clear, navigate, toast } from "../util.js"; +import { auth } from "../auth.js"; + +export function render(root, query = {}) { + clear(root); + + let mode = query.mode === "register" ? "register" : "login"; + + const card = el("section", { class: "card auth-card" }); + + function paint() { + clear(card); + const tabs = el("div", { class: "auth-tabs" }, [ + el("button", { + class: mode === "login" ? "is-active" : "", + onclick: () => { mode = "login"; paint(); } + }, "Log ind"), + el("button", { + class: mode === "register" ? "is-active" : "", + onclick: () => { mode = "register"; paint(); } + }, "Opret bruger") + ]); + card.appendChild(tabs); + + if (mode === "login") { + card.appendChild(loginForm()); + } else { + card.appendChild(registerForm()); + } + } + + paint(); + root.appendChild(card); +} + +function loginForm() { + return el("form", { + class: "form-grid", + onsubmit: (e) => { + e.preventDefault(); + const data = Object.fromEntries(new FormData(e.target)); + try { + const user = auth.login(data); + toast(`Velkommen tilbage, ${user.name}`); + navigate("#/"); + } catch (err) { + toast(err.message); + } + } + }, [ + el("label", { class: "field" }, [ + "E-mail", + el("input", { type: "email", name: "email", required: true, autocomplete: "email" }) + ]), + el("label", { class: "field" }, [ + "Adgangskode", + el("input", { type: "password", name: "password", required: true, autocomplete: "current-password" }) + ]), + el("button", { class: "btn", type: "submit" }, "Log ind") + ]); +} + +function registerForm() { + return el("form", { + class: "form-grid", + onsubmit: (e) => { + e.preventDefault(); + const data = Object.fromEntries(new FormData(e.target)); + try { + const user = auth.register(data); + toast(`Velkommen, ${user.name}`); + navigate("#/"); + } catch (err) { + toast(err.message); + } + } + }, [ + el("label", { class: "field" }, [ + "Navn", + el("input", { type: "text", name: "name", required: true, autocomplete: "name" }) + ]), + el("label", { class: "field" }, [ + "Myndighed eller organisation", + el("input", { type: "text", name: "organization", placeholder: "f.eks. Aarhus Kommune", autocomplete: "organization" }) + ]), + el("label", { class: "field" }, [ + "E-mail", + el("input", { type: "email", name: "email", required: true, autocomplete: "email" }) + ]), + el("label", { class: "field" }, [ + "Adgangskode", + el("input", { type: "password", name: "password", required: true, minlength: 4, autocomplete: "new-password" }), + el("span", { class: "hint" }, "Mindst 4 tegn. Prototype – brug ikke en rigtig adgangskode.") + ]), + el("button", { class: "btn", type: "submit" }, "Opret bruger") + ]); +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/search.js b/docs/public/projects/ai-bibliotek/mocks/js/views/search.js new file mode 100644 index 0000000..2a6c7ad --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/search.js @@ -0,0 +1,246 @@ +import { el, clear } from "../util.js"; +import { searchAssistants, buildFacets, getAllAssistants, FRAMEWORK_LABEL } from "../catalog.js"; +import { renderAssistantCard } from "./_assistant-card.js"; + +const SENSITIVITY_LABEL = { + almindelige: "Almindelige personoplysninger", + fortrolige: "Fortrolige data", + personfoelsomme: "Personfølsomme data" +}; + +const RECENT_KEY = "ab:recent-searches"; +const FACET_OPEN_KEY = "ab:facet-open"; +const FACET_DEFAULT_OPEN = new Set(["Kommune", "Sprogmodel"]); + +function loadFacetOpen() { + try { return new Set(JSON.parse(localStorage.getItem(FACET_OPEN_KEY) || "null") || FACET_DEFAULT_OPEN); } + catch { return new Set(FACET_DEFAULT_OPEN); } +} +function persistFacetOpen(set) { + localStorage.setItem(FACET_OPEN_KEY, JSON.stringify([...set])); +} + +function loadRecentSearches() { + try { return JSON.parse(localStorage.getItem(RECENT_KEY) || "[]"); } catch { return []; } +} +function pushRecentSearch(q) { + if (!q) return; + const list = loadRecentSearches().filter(x => x !== q); + list.unshift(q); + localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, 6))); +} + +export function render(root, query = {}) { + clear(root); + + const state = { + q: query.q || "", + kommuner: new Set(), + languageModels: new Set(), + frameworks: new Set(), + sensitivities: new Set() + }; + + if (state.q) pushRecentSearch(state.q); + + const openFacets = loadFacetOpen(); + const layout = el("div", { class: "search-layout" }); + const aside = el("aside", { class: "facet-aside", "aria-label": "Filtre" }); + const main = el("div", { style: "min-width: 0;" }); + const context = el("div", { class: "context-rail" }); + layout.appendChild(aside); + layout.appendChild(main); + layout.appendChild(context); + root.appendChild(layout); + + function update() { + const results = searchAssistants(state); + const facets = buildFacets(getAllAssistants()); + + renderAside(); + renderMain(results); + renderContext(results); + syncUrl(); + + function renderAside() { + clear(aside); + aside.appendChild(el("h3", { style: "margin-top:0;" }, "Filtre")); + + aside.appendChild(facetGroup("Kommune", facets.kommuner, state.kommuner)); + aside.appendChild(facetGroup("Sprogmodel", facets.languageModels, state.languageModels)); + aside.appendChild(facetGroup("Rammeværk", facets.frameworks, state.frameworks, (k) => FRAMEWORK_LABEL[k] || k)); + aside.appendChild(facetGroup("Datafølsomhed", facets.sensitivities, state.sensitivities, (k) => SENSITIVITY_LABEL[k] || k)); + + if (anyFilter()) { + aside.appendChild(el("button", { + class: "btn btn-secondary btn-sm mt-3", + onclick: () => { + ["kommuner","languageModels","frameworks","sensitivities"].forEach(k => state[k].clear()); + update(); + } + }, "Nulstil filtre")); + } + } + + function renderMain(results) { + clear(main); + const formChildren = [ + el("input", { type: "search", name: "q", value: state.q, placeholder: "Søg…", "aria-label": "Søg" }) + ]; + if (state.q) { + formChildren.push(el("button", { + class: "btn btn-secondary", + type: "button", + onclick: () => { state.q = ""; update(); } + }, "Nulstil")); + } + formChildren.push(el("button", { class: "btn", type: "submit" }, "Søg")); + + const header = el("div", { class: "results-header" }, [ + el("form", { + role: "search", + style: "flex:1; display:flex; gap:8px; max-width:560px;", + onsubmit: (e) => { + e.preventDefault(); + state.q = (new FormData(e.target).get("q") || "").trim(); + if (state.q) pushRecentSearch(state.q); + update(); + } + }, formChildren), + el("span", { class: "count" }, `${results.length} resultat${results.length === 1 ? "" : "er"}`) + ]); + main.appendChild(header); + + if (!results.length) { + main.appendChild(el("div", { class: "empty card" }, [ + el("h2", {}, "Ingen assistenter matcher"), + el("p", { class: "muted" }, "Prøv at slække på filtrene eller skriv en bredere søgning.") + ])); + return; + } + + const list = el("div", { class: "results-list" }); + results.forEach(a => list.appendChild(renderAssistantCard(a))); + main.appendChild(list); + } + + function renderContext(results) { + clear(context); + + // Active filters + const filtersCard = el("aside", { class: "context-aside" }); + filtersCard.appendChild(el("h3", {}, "Aktive filtre")); + const chips = collectActiveChips(); + if (chips.length) { + const wrap = el("div", { class: "active-filters" }, chips); + filtersCard.appendChild(wrap); + } else { + filtersCard.appendChild(el("p", { class: "context-empty" }, "Ingen filtre aktive.")); + } + context.appendChild(filtersCard); + + // Recent searches + const recent = loadRecentSearches(); + if (recent.length) { + const recentCard = el("aside", { class: "context-aside" }); + recentCard.appendChild(el("h3", {}, "Seneste søgninger")); + const ul = el("ul", { class: "recent-searches" }); + recent.forEach(q => { + ul.appendChild(el("li", {}, el("a", { + href: "#", + onclick: (e) => { e.preventDefault(); state.q = q; update(); } + }, q))); + }); + recentCard.appendChild(ul); + context.appendChild(recentCard); + } + + // "Klar til hjemtagning" callout — all catalog assistants can be exported. + if (results.length) { + const callout = el("aside", { class: "context-aside" }); + callout.appendChild(el("h3", {}, "Klar til hjemtagning")); + callout.appendChild(el("p", { class: "context-empty" }, `Alle ${results.length} resultat${results.length === 1 ? "" : "er"} kan eksporteres som OpenWebUI-JSON og køres lokalt.`)); + context.appendChild(callout); + } + } + + function collectActiveChips() { + const chips = []; + if (state.q) { + chips.push(makeChip(`"${state.q}"`, () => { state.q = ""; update(); })); + } + state.kommuner.forEach(v => chips.push(makeChip(v, () => { state.kommuner.delete(v); update(); }))); + state.languageModels.forEach(v => chips.push(makeChip(v, () => { state.languageModels.delete(v); update(); }))); + state.frameworks.forEach(v => chips.push(makeChip(FRAMEWORK_LABEL[v] || v, () => { state.frameworks.delete(v); update(); }))); + state.sensitivities.forEach(v => chips.push(makeChip(SENSITIVITY_LABEL[v] || v, () => { state.sensitivities.delete(v); update(); }))); + return chips; + } + } + + function makeChip(label, onRemove) { + return el("button", { + class: "filter-chip", + type: "button", + onclick: onRemove, + "aria-label": `Fjern filter: ${label}` + }, [label, el("span", { class: "filter-chip-x", "aria-hidden": "true" }, "×")]); + } + + function facetGroup(label, counts, selectedSet, labelFn) { + let entries = [...counts.entries()]; + entries.sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]), "da")); + + const ul = el("ul"); + for (const [key, count] of entries) { + const value = key; + const checked = selectedSet.has(value); + ul.appendChild(el("li", {}, el("label", {}, [ + el("input", { + type: "checkbox", + checked, + onchange: (e) => { + if (e.target.checked) selectedSet.add(value); + else selectedSet.delete(value); + update(); + } + }), + el("span", {}, labelFn ? labelFn(key) : key), + el("span", { class: "count" }, String(count)) + ]))); + } + + // Force-open if the group has an active filter, otherwise respect user preference. + const isOpen = selectedSet.size > 0 || openFacets.has(label); + const summary = el("summary", {}, [ + el("h4", {}, label), + el("span", { class: "facet-chevron", "aria-hidden": "true" }) + ]); + const details = el("details", { + class: "facet-group", + open: isOpen, + ontoggle: (e) => { + if (e.target.open) openFacets.add(label); + else openFacets.delete(label); + persistFacetOpen(openFacets); + } + }, [summary, ul]); + + return details; + } + + function anyFilter() { + return state.kommuner.size || state.languageModels.size + || state.frameworks.size || state.sensitivities.size; + } + + function syncUrl() { + const params = []; + if (state.q) params.push(`q=${encodeURIComponent(state.q)}`); + const hash = "#/search" + (params.length ? `?${params.join("&")}` : ""); + if (window.location.hash !== hash) { + history.replaceState(null, "", hash); + } + } + + update(); +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/upload.js b/docs/public/projects/ai-bibliotek/mocks/js/views/upload.js new file mode 100644 index 0000000..d73ea22 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/upload.js @@ -0,0 +1,324 @@ +import { el, clear, navigate, toast } from "../util.js"; +import { auth } from "../auth.js"; +import { store, uid } from "../store.js"; +import { DATA_SENSITIVITY } from "../catalog.js"; +import { iconHtml, icon as iconNode } from "../icons.js"; + +export function render(root) { + const user = auth.currentUser(); + if (!user) { + navigate("#/login"); + return; + } + clear(root); + + // state.parsed holds the parsed OWUI JSON; state.draft holds suggested metadata. + const state = { step: 1, rawJson: null, parsed: null, draft: null, published: null }; + + root.appendChild(el("header", { class: "page-head" }, [ + el("h1", { style: "margin:0 0 8px;" }, "Del assistent"), + el("p", { class: "lede", style: "margin:0;" }, + "Del en AI-assistent fra din myndighed med de andre kommuner. Indsæt assistentens OpenWebUI-JSON — AI foreslår metadata, og du bekræfter datagrundlaget.") + ])); + + root.appendChild(el("aside", { + class: "alert alert-rights", + role: "note", + "aria-label": "Ansvar" + }, [ + el("span", { class: "alert-mark", "aria-hidden": "true" }), + el("p", {}, + "Du har som deler ansvaret for, at assistenten kan deles, og at det beskrevne datagrundlag ikke indeholder data, der ikke må deles. Selve videns- og datafiler deles ikke — kun konfigurationen og en opskrift på datagrundlaget.") + ])); + + const layout = el("div", { class: "upload-layout" }); + const stepNav = el("nav", { class: "step-rail", "aria-label": "Trin i deling" }); + const container = el("div", { class: "upload-step" }); + layout.appendChild(stepNav); + layout.appendChild(container); + root.appendChild(layout); + + paint(); + + function paint() { + renderStepRail(); + clear(container); + if (state.step === 1) container.appendChild(renderPick()); + else if (state.step === 2) container.appendChild(renderReview()); + else container.appendChild(renderReceipt()); + } + + function renderStepRail() { + clear(stepNav); + const stepDefs = [ + { n: 1, title: "Indsæt JSON", hint: "Vælg fil eller indsæt OWUI-JSON" }, + { n: 2, title: "Gennemgang", hint: "Tjek AI-forslag og datafølsomhed" }, + { n: 3, title: "Kvittering", hint: "Permalink til din assistent" } + ]; + stepDefs.forEach(({ n, title, hint }) => { + const status = state.step === n ? "active" : state.step > n ? "done" : "todo"; + stepNav.appendChild(el("div", { class: `step-item step-${status}` }, [ + el("span", { class: "step-num" }, status === "done" ? iconNode("check") : String(n).padStart(2, "0")), + el("div", { class: "step-text" }, [ + el("span", { class: "step-title" }, title), + el("span", { class: "step-hint" }, hint) + ]) + ])); + }); + } + + function renderPick() { + const card = el("section", { class: "card" }); + + const input = el("input", { + type: "file", + accept: "application/json,.json", + style: "display:none;", + onchange: (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => acceptJson(String(reader.result)); + reader.readAsText(file); + } + }); + + const drop = el("div", { + class: "dropzone", + tabindex: "0", + role: "button", + "aria-label": "Vælg JSON-fil eller træk hertil", + onclick: () => input.click(), + onkeydown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); input.click(); } }, + ondragover: (e) => { e.preventDefault(); drop.classList.add("is-dragover"); }, + ondragleave: () => drop.classList.remove("is-dragover"), + ondrop: (e) => { + e.preventDefault(); + drop.classList.remove("is-dragover"); + const f = e.dataTransfer?.files?.[0]; + if (!f) return; + const reader = new FileReader(); + reader.onload = () => acceptJson(String(reader.result)); + reader.readAsText(f); + } + }, [ + el("p", { html: iconHtml("document", { size: 36, stroke: 1.5 }) }), + el("p", { style: "font-weight:600;" }, "Træk OpenWebUI-JSON hertil"), + el("p", { class: "muted" }, "eller klik for at vælge en .json-fil"), + input + ]); + card.appendChild(drop); + + card.appendChild(el("hr", { class: "divider" })); + + const form = el("form", { + onsubmit: (e) => { + e.preventDefault(); + const raw = new FormData(e.target).get("json") || ""; + acceptJson(String(raw)); + } + }, [ + el("label", { class: "field" }, [ + "…eller indsæt JSON direkte", + el("textarea", { + name: "json", + rows: "8", + placeholder: '{\n "id": "min-assistent",\n "name": "Min assistent",\n "base_model_id": "llama3.1:70b",\n ...\n}' + }) + ]), + el("div", { class: "right" }, [ + el("button", { type: "submit", class: "btn" }, "Analysér JSON") + ]) + ]); + card.appendChild(form); + + return card; + } + + function acceptJson(raw) { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + toast("Kunne ikke læse JSON. Tjek at formatet er gyldigt."); + return; + } + state.rawJson = raw; + state.parsed = parsed; + state.step = 2; + state.draft = null; + paint(); + // Simulate brief AI processing of the parsed config. + setTimeout(() => { + state.draft = suggestMetadata(parsed); + paint(); + }, 1400); + } + + function renderReview() { + const card = el("section", { class: "card" }); + if (!state.draft) { + card.appendChild(el("div", { style: "text-align:center; padding: 32px 0;" }, [ + el("div", { class: "spinner" }), + el("p", { style: "font-weight:600;" }, "AI analyserer assistenten…"), + el("p", { class: "muted" }, "Læser konfigurationen og foreslår metadata.") + ])); + return card; + } + + const d = state.draft; + card.appendChild(el("h2", {}, "Gennemgå AI-forslag")); + card.appendChild(el("p", { class: "muted" }, "AI har foreslået metadata ud fra den indsatte konfiguration. Ret efter behov, og vælg datafølsomhed før deling.")); + + const form = el("form", { + onsubmit: (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const assistant = buildAssistant(d, fd, state.parsed); + store.addUpload(assistant); + state.published = assistant; + state.step = 3; + paint(); + toast("Assistent delt"); + } + }); + + form.appendChild(el("div", { class: "form-grid" }, [ + el("label", { class: "field" }, ["Navn", el("input", { type: "text", name: "name", value: d.name, required: true })]), + el("label", { class: "field" }, ["Tagline (kort beskrivelse)", el("input", { type: "text", name: "tagline", value: d.tagline || "" })]), + el("label", { class: "field" }, ["Beskrivelse", + el("textarea", { name: "description", required: true }, d.description) + ]), + el("div", { class: "form-grid cols-2" }, [ + el("label", { class: "field" }, ["Oprindelseskommune", el("input", { type: "text", name: "originKommune", value: d.originKommune, required: true })]), + el("label", { class: "field" }, ["Sprogmodel", el("input", { type: "text", name: "languageModel", value: d.languageModel, required: true })]) + ]), + el("label", { class: "field" }, [ + "Tags (komma-separeret)", + el("input", { type: "text", name: "tags", value: (d.tags || []).join(", ") }) + ]), + el("label", { class: "field" }, ["Vidensopskrift (hvad kommunen selv skal levere)", + el("textarea", { name: "knowledgeRecipe", rows: "4" }, d.knowledgeRecipe || "") + ]) + ])); + + form.appendChild(el("hr", { class: "divider" })); + form.appendChild(el("h3", {}, "Datafølsomhed")); + form.appendChild(el("p", { class: "muted" }, "Vælg hvilke data assistenten er beregnet til. Det styrer, hvordan assistenten må anvendes.")); + + const rl = el("div", { class: "rights-list" }); + DATA_SENSITIVITY.forEach(s => { + rl.appendChild(el("label", {}, [ + el("input", { type: "radio", name: "dataSensitivity", value: s.level, required: true, checked: s.level === d.dataSensitivity }), + el("div", {}, [ + el("div", { class: "rl-title" }, s.title), + el("div", { class: "rl-desc" }, s.description) + ]) + ])); + }); + form.appendChild(rl); + + form.appendChild(el("div", { class: "right mt-5" }, [ + el("button", { + type: "button", + class: "btn btn-secondary", + onclick: () => { state.step = 1; state.parsed = null; state.draft = null; paint(); }, + html: `${iconHtml("arrowLeft")}Indsæt anden JSON` + }), + el("button", { type: "submit", class: "btn" }, "Del assistent") + ])); + card.appendChild(form); + return card; + } + + function renderReceipt() { + const a = state.published; + const card = el("section", { class: "card" }); + card.appendChild(el("h2", { + html: `${iconHtml("check", { size: 22 })} Assistent delt`, + style: "display:flex; align-items:center; gap:8px; color: var(--color-ok);" + })); + card.appendChild(el("p", {}, `"${a.name}" er nu tilgængelig i biblioteket og kan hjemtages af andre kommuner.`)); + card.appendChild(el("div", { class: "muted text-sm mb-4" }, `Permalink: ${window.location.origin}${window.location.pathname}#/assistant/${a.id}`)); + card.appendChild(el("div", { class: "row wrap" }, [ + el("a", { class: "btn", href: `#/assistant/${a.id}` }, "Gå til assistent"), + el("button", { + class: "btn btn-secondary", + onclick: () => { + state.step = 1; state.parsed = null; state.rawJson = null; state.draft = null; state.published = null; paint(); + } + }, "Del en til"), + el("a", { class: "btn btn-ghost", href: "#/search" }, "Tilbage til kataloget") + ])); + return card; + } +} + +function buildAssistant(draft, fd, parsed) { + const now = new Date().toISOString().slice(0, 10); + return { + id: uid("asst"), + name: (fd.get("name") || "").trim(), + tagline: (fd.get("tagline") || "").trim(), + description: (fd.get("description") || "").trim(), + originKommune: (fd.get("originKommune") || "").trim(), + languageModel: (fd.get("languageModel") || "").trim(), + framework: "openwebui", + dataSensitivity: fd.get("dataSensitivity") || "almindelige", + approvedFor: [fd.get("dataSensitivity") || "almindelige"], + tags: splitList(fd.get("tags")), + aiTags: draft.aiTags || [], + readme: draft.readme || "", + modelCard: draft.modelCard || "", + knowledgeRecipe: (fd.get("knowledgeRecipe") || "").trim(), + versions: [ + { + version: "1.0.0", + releasedAt: new Date().toISOString(), + notes: "Første version delt via AI Bibliotek.", + json: parsed + } + ], + createdAt: now, + updatedAt: now, + uploadedBy: auth.currentUser()?.id ?? null, + source: "user" + }; +} + +function splitList(s) { + if (!s) return []; + return String(s).split(",").map(x => x.trim()).filter(Boolean); +} + +/* Suggest metadata from a parsed OpenWebUI-shaped JSON object. + This stands in for an AI extraction step. */ +function suggestMetadata(parsed) { + const user = auth.currentUser(); + const originKommune = user?.organization || "Min Kommune"; + + const name = parsed?.name || "Ny assistent"; + const description = parsed?.meta?.description + || parsed?.params?.system + || "Beskrivelse mangler — tilføj en kort forklaring af hvad assistenten gør."; + const languageModel = parsed?.base_model_id || parsed?.model || "ukendt model"; + const tags = (parsed?.meta?.tags || []) + .map(t => (typeof t === "string" ? t : t?.name)) + .filter(Boolean); + + return { + name, + tagline: "", + description, + originKommune, + languageModel, + tags: tags.length ? tags : ["assistent"], + aiTags: tags.slice(0, 2), + dataSensitivity: "almindelige", + readme: `# ${name}\n\n${description}`, + modelCard: `Sprogmodel: ${languageModel}.\nKontekstvindue: ${parsed?.params?.num_ctx || "ukendt"}.\nTemperatur: ${parsed?.params?.temperature ?? "ukendt"}.\n\nHensyn: Gennemgå system-prompten og tilpas den til din kommune før brug.`, + knowledgeRecipe: (parsed?.knowledge?.length) + ? parsed.knowledge.map((k, i) => `${i + 1}. Levér lokalt: ${k.name}${k.note ? ` (${k.note})` : ""}.`).join("\n") + : "Beskriv her hvilke videns- og datafiler den enkelte kommune selv skal levere." + }; +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/uploads.js b/docs/public/projects/ai-bibliotek/mocks/js/views/uploads.js new file mode 100644 index 0000000..b197820 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/uploads.js @@ -0,0 +1,44 @@ +import { el, clear, navigate, toast } from "../util.js"; +import { auth } from "../auth.js"; +import { store } from "../store.js"; +import { renderAssistantCard } from "./_assistant-card.js"; + +export function render(root) { + const user = auth.currentUser(); + if (!user) { navigate("#/login"); return; } + + function paint() { + clear(root); + root.appendChild(el("h1", {}, "Mine assistenter")); + root.appendChild(el("p", { class: "muted" }, + "Assistenter du har delt til biblioteket. Du kan trække en assistent tilbage — den fjernes også fra andres favoritter og samlinger.")); + + const mine = store.uploadsByUser(user.id) + .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + + if (!mine.length) { + root.appendChild(el("div", { class: "empty card mt-4" }, [ + el("h2", {}, "Du har ikke delt nogen assistenter endnu"), + el("p", { class: "muted" }, "Del en assistent fra din myndighed for at gøre den tilgængelig for andre kommuner."), + el("a", { class: "btn", href: "#/upload" }, "Del assistent") + ])); + return; + } + + const list = el("div", { class: "results-list mt-4" }); + mine.forEach(a => list.appendChild(renderAssistantCard(a, { + extraAction: el("button", { + class: "btn btn-danger btn-sm", + onclick: () => { + if (!confirm(`Slet assistenten "${a.name}"?\n\nDen fjernes fra biblioteket og alle samlinger.`)) return; + store.deleteUpload(a.id); + toast("Assistent slettet"); + paint(); + } + }, "Slet") + }))); + root.appendChild(list); + } + + paint(); +} From 20a2323fbfc09ac4f6796c46a9508de0d674f540 Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Wed, 27 May 2026 14:01:57 +0200 Subject: [PATCH 2/3] style: white background and remove resume drop-cap in AI Bibliotek mock Co-authored-by: Claude --- .../projects/ai-bibliotek/mocks/css/styles.css | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/docs/public/projects/ai-bibliotek/mocks/css/styles.css b/docs/public/projects/ai-bibliotek/mocks/css/styles.css index d90adf0..81fb2a1 100644 --- a/docs/public/projects/ai-bibliotek/mocks/css/styles.css +++ b/docs/public/projects/ai-bibliotek/mocks/css/styles.css @@ -1,7 +1,7 @@ /* AI Bibliotek – prototype styles */ :root { - --color-bg: #f4efe7; + --color-bg: #ffffff; --color-surface: #ffffff; --color-surface-2: #fbf9f5; --color-border: #e3ddd1; @@ -1003,16 +1003,6 @@ input:focus, textarea:focus, select:focus { font-variation-settings: "opsz" 36, "SOFT" 100; } .detail-main .resume-block { font-size: 1.05rem; line-height: 1.65; } -.detail-main .resume-block > p:first-of-type::first-letter { - font-family: var(--font-display); - font-weight: 500; - font-size: 3.6rem; - line-height: 0.9; - float: left; - padding: 6px 10px 0 0; - color: var(--color-accent-deep); - font-variation-settings: "opsz" 144, "SOFT" 50; -} .detail-main h2 { margin-top: var(--space-6); } .detail-main h3 { margin-top: var(--space-5); } From 2653df5fedd49c1656bc30b7cf0b30723433d14b Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Wed, 27 May 2026 14:23:27 +0200 Subject: [PATCH 3/3] feat: seed demo users and show credentials on AI Bibliotek login Two ready-made logins (Aarhus, Odense) are seeded idempotently on app start and listed on the login screen; clicking one fills the form so reviewers can try the gated flows without registering. Co-authored-by: Claude --- .../ai-bibliotek/mocks/css/styles.css | 33 +++++++++++++++++++ .../projects/ai-bibliotek/mocks/js/app.js | 1 + .../projects/ai-bibliotek/mocks/js/auth.js | 26 +++++++++++++++ .../ai-bibliotek/mocks/js/views/login.js | 29 ++++++++++++++-- 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/docs/public/projects/ai-bibliotek/mocks/css/styles.css b/docs/public/projects/ai-bibliotek/mocks/css/styles.css index 81fb2a1..c6bf5b4 100644 --- a/docs/public/projects/ai-bibliotek/mocks/css/styles.css +++ b/docs/public/projects/ai-bibliotek/mocks/css/styles.css @@ -1281,6 +1281,39 @@ input:focus, textarea:focus, select:focus { } .auth-tabs button.is-active { color: var(--color-primary); border-bottom-color: var(--color-primary); } +/* Demo users on the login screen */ +.demo-users { + margin-top: var(--space-5); + padding-top: var(--space-4); + border-top: 1px dashed var(--color-border-strong); +} +.demo-users-head { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.14em; + font-weight: 600; + color: var(--color-text-muted); + margin: 0 0 var(--space-3); +} +.demo-user { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + text-align: left; + padding: var(--space-3); + margin-bottom: var(--space-2); + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + font: inherit; +} +.demo-user:hover { border-color: var(--color-primary); } +.demo-user-name { font-weight: 600; color: var(--color-ink); font-size: 0.95rem; } +.demo-user-cred { font-family: var(--font-mono); font-size: 0.85rem; color: var(--color-text-muted); } +.demo-users-foot { margin: var(--space-2) 0 0; } + /* Tag chips */ .chips { display: flex; flex-wrap: wrap; gap: var(--space-2); } .chip { diff --git a/docs/public/projects/ai-bibliotek/mocks/js/app.js b/docs/public/projects/ai-bibliotek/mocks/js/app.js index 888bf71..a6a89e0 100644 --- a/docs/public/projects/ai-bibliotek/mocks/js/app.js +++ b/docs/public/projects/ai-bibliotek/mocks/js/app.js @@ -124,6 +124,7 @@ function wireNavToggle() { } window.addEventListener("DOMContentLoaded", () => { + auth.seedDemoUsers(); wireNavToggle(); renderUserArea(); renderAuthGuardedLinks(); diff --git a/docs/public/projects/ai-bibliotek/mocks/js/auth.js b/docs/public/projects/ai-bibliotek/mocks/js/auth.js index a2b3390..cac7a1e 100644 --- a/docs/public/projects/ai-bibliotek/mocks/js/auth.js +++ b/docs/public/projects/ai-bibliotek/mocks/js/auth.js @@ -11,6 +11,13 @@ function fakeHash(input) { return `h${h.toString(36)}`; } +/* Ready-made demo logins shown on the login screen so reviewers can try the + gated flows without registering. Plaintext passwords are intentional here. */ +export const DEMO_USERS = [ + { id: "demo-aarhus", name: "Mette Sørensen", organization: "Aarhus Kommune", email: "mette@aarhus.dk", password: "demo1234" }, + { id: "demo-odense", name: "Jonas Holm", organization: "Odense Kommune", email: "jonas@odense.dk", password: "demo1234" } +]; + export const auth = { currentUser() { const session = store.getSession(); @@ -56,5 +63,24 @@ export const auth = { logout() { store.setSession(null); + }, + + /* Idempotent: inserts any demo user whose email isn't already present. */ + seedDemoUsers() { + const users = store.getUsers(); + let changed = false; + for (const d of DEMO_USERS) { + if (users.some(u => u.email === d.email)) continue; + users.push({ + id: d.id, + email: d.email, + name: d.name, + organization: d.organization, + passwordHash: fakeHash(d.password), + createdAt: new Date().toISOString() + }); + changed = true; + } + if (changed) store.setUsers(users); } }; diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/login.js b/docs/public/projects/ai-bibliotek/mocks/js/views/login.js index 10bf726..93a3c69 100644 --- a/docs/public/projects/ai-bibliotek/mocks/js/views/login.js +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/login.js @@ -1,5 +1,5 @@ import { el, clear, navigate, toast } from "../util.js"; -import { auth } from "../auth.js"; +import { auth, DEMO_USERS } from "../auth.js"; export function render(root, query = {}) { clear(root); @@ -23,7 +23,9 @@ export function render(root, query = {}) { card.appendChild(tabs); if (mode === "login") { - card.appendChild(loginForm()); + const form = loginForm(); + card.appendChild(form); + card.appendChild(demoUsersBox(form)); } else { card.appendChild(registerForm()); } @@ -60,6 +62,29 @@ function loginForm() { ]); } +/* Prototype-only: ready-made logins. Clicking one fills the form fields. */ +function demoUsersBox(form) { + const fill = (email, password) => { + form.querySelector('input[name="email"]').value = email; + form.querySelector('input[name="password"]').value = password; + }; + + return el("div", { class: "demo-users" }, [ + el("p", { class: "demo-users-head" }, "Prototype – prøv med en testbruger"), + ...DEMO_USERS.map(d => + el("button", { + type: "button", + class: "demo-user", + onclick: () => fill(d.email, d.password) + }, [ + el("span", { class: "demo-user-name" }, `${d.name} · ${d.organization}`), + el("span", { class: "demo-user-cred" }, `${d.email} / ${d.password}`) + ]) + ), + el("p", { class: "hint demo-users-foot" }, "Klik en bruger for at udfylde felterne.") + ]); +} + function registerForm() { return el("form", { class: "form-grid",