diff --git a/CHANGELOG.md b/CHANGELOG.md
index e0c968a..8bb64d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+### Added — Dansk Viden til Dansk AI Project
+- National publication-corpus prototype for Danish public-sector knowledge collection feeding Danish AI training data, with a clear split between an open publication catalogue and a curated, rights-cleared training data bank
+- Single-page mock with seven views: forsiden, login/registrering, upload med simuleret AI-katalogisering, søgning med facetter, publikationsdetalje, favoritter og samlinger med base64-pakkede delelinks — bruger `localStorage` som backend
+
### Added — Carbontracker reference in Climate Awareness Nudging
- Reference [carbontracker.info](https://carbontracker.info/) in `co2-research.md` (new "Measurement tools" subsection + sources entry), `integration.md` (API/proxy layer), and `index.md` ("What makes it hard")
diff --git a/CLAUDE.md b/CLAUDE.md
index 8d0ae9c..93078cd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -50,6 +50,7 @@ Taskfile.yml # Task automation (dev, build, lint
| `book-aarhus` | Book Aarhus | No |
| `opkraevningsoverblik` | Opkrævningsoverblik | No |
| `roboway` | Roboway | No |
+| `dansk-viden-til-dansk-ai` | Dansk Viden til Dansk AI | No |
## Conventions
diff --git a/docs/.vitepress/sidebar.mts b/docs/.vitepress/sidebar.mts
index de7f621..ef8e5e7 100644
--- a/docs/.vitepress/sidebar.mts
+++ b/docs/.vitepress/sidebar.mts
@@ -98,6 +98,16 @@ const roboway: DefaultTheme.SidebarItem[] = [
},
]
+const danskVidenTilDanskAi: DefaultTheme.SidebarItem[] = [
+ {
+ text: 'Dansk Viden til Dansk AI',
+ items: [
+ { text: 'Overview', link: '/projects/dansk-viden-til-dansk-ai/' },
+ { text: 'Interactive Mocks', link: '/projects/dansk-viden-til-dansk-ai/mocks' },
+ ],
+ },
+]
+
const designSystem: DefaultTheme.SidebarItem[] = [
{
text: 'Design System',
@@ -123,6 +133,7 @@ export function sidebar(): DefaultTheme.Sidebar {
'/projects/book-aarhus/': bookAarhus,
'/projects/opkraevningsoverblik/': opkraevningsoverblik,
'/projects/roboway/': roboway,
+ '/projects/dansk-viden-til-dansk-ai/': danskVidenTilDanskAi,
'/projects/design-system/': designSystem,
}
}
diff --git a/docs/index.md b/docs/index.md
index 69c2989..b030444 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -42,4 +42,8 @@ features:
details: Kommunal platform til styring og overvågning af autonome robotflåder i Aarhus — med livekort, zoneadministration, hændelseshåndtering og operatørportal.
link: /projects/roboway/
linkText: View project
+ - title: Dansk Viden til Dansk AI
+ 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
---
diff --git a/docs/projects/dansk-viden-til-dansk-ai/index.md b/docs/projects/dansk-viden-til-dansk-ai/index.md
new file mode 100644
index 0000000..0ceb04e
--- /dev/null
+++ b/docs/projects/dansk-viden-til-dansk-ai/index.md
@@ -0,0 +1,122 @@
+**Project:** Dansk Viden til Dansk AI · **Status:** Prototype · **Date:** May 2026
+
+# Dansk Viden til Dansk AI
+
+**Fælles offentlig service til indsamling, katalogisering og deling af danske publikationer — som grundlag for både videnformidling og træning af danske sprogmodeller.**
+
+---
+
+## Baggrund
+
+Offentlige myndigheder i Danmark producerer hvert år store mængder viden i form af rapporter, analyser, vejledninger, strategier, evalueringer og faglige notater. Publikationerne ligger spredt på myndighedernes egne hjemmesider, bliver typisk kun markedsført kortvarigt og er sjældent samlet i et fælles overblik. De gøres kun i begrænset omfang tilgængelige som strukturerede data.
+
+Samtidig vokser behovet for danske træningsdata. Hvis offentlige AI-løsninger skal fungere godt på dansk, skal modellerne trænes på dansk sprog, danske begreber og dansk forvaltningspraksis — på data hvor kvalitet, ophav og rettigheder er dokumenteret. I dag foregår en stor del af modeltræningen i lukkede miljøer hos private virksomheder, hvor datagrundlaget ofte er uklart og rettighederne vanskelige at gennemskue.
+
+Offentlige publikationer er et oplagt udgangspunkt: de har høj kvalitet, tydelig afsender og stor relevans for dansk offentlig sektor.
+
+## Formål
+
+Prototypen skal undersøge spørgsmålet: **Hvordan kan en fælles offentlig tjeneste til indsamling og deling af publikationer se ud i praksis — med klare rettigheder og AI-assisteret metadata?**
+
+Tjenesten skal understøtte to formål:
+
+- **Et offentligt publikationskatalog** hvor borgere, medarbejdere, forskere og virksomheder kan finde, søge og læse offentlig viden på tværs af myndigheder
+- **Et rettighedsclearet og dokumenteret datagrundlag** til træning, evaluering og finjustering af danske sprogmodeller — med tydelig ophav, licens og kvalitet
+
+De to formål skal holdes adskilt teknisk og juridisk. Ikke alt, der kan vises i et publikationskatalog, bør automatisk bruges til AI-træning.
+
+## Hvad prototypen viser
+
+Prototypen er en single-page application med syv visninger. Den bruger `localStorage` som backend og simulerer AI-katalogisering med en kort spinner. Alle seed-publikationer indlæses fra `data/seed-publications.js`.
+
+### Forsiden
+
+Hero med søgefelt, kort introduktion til tjenesten og statistik over publikationer i kataloget (antal publikationer, myndigheder, dokumenttyper).
+
+### Registrering og login
+
+Simpel brugerflade hvor besøgende kan oprette en konto eller logge ind. Brugere gemmes i `localStorage`, og passwords obfuskeres med en triviel hash. Ingen reel auth — kun til demoformål.
+
+### Upload
+
+Tre-trins flow der demonstrerer hele rettigheds- og katalogiseringsforløbet:
+
+1. **Filvalg** — publicisten vælger en fil
+2. **AI-katalogisering** (simuleret med ~2 sek spinner) — systemet foreslår titel, resume, emneord, dokumenttype, fagområde, målgruppe og indikatorer på personoplysninger og tredjepartsindhold
+3. **Gennemgang** — publicisten godkender eller justerer metadata og tager **aktiv stilling til rettighedsniveau (1–7)** og **risikomarkering (grøn/gul/rød)**
+4. **Kvittering** med link til den katalogiserede publikation
+
+### Søgning
+
+Fritekstsøgning kombineret med facetter: myndighed, dokumenttype, fagområde, år, rettighedsniveau og risikomarkering. Resultater vises som kort med kort resume og badges.
+
+### Publikationsside
+
+Detaljevisning af en enkelt publikation: fuld metadata, AI-genereret resume, badges for rettighedsniveau og risiko, samt handlinger for favorit og tilføj-til-samling.
+
+### Favoritter
+
+Personlig favoritliste pr. bruger, gemt i `localStorage`.
+
+### Samlinger
+
+Navngivne samlinger af publikationer, hver med et **delelink**. Delelinket indeholder en base64-pakket kopi af samlingen — så den kan åbnes af andre uden backend. Lange samlinger giver lange links.
+
+---
+
+## Krav
+
+- Offentlige myndigheder skal kunne uploade publikationer direkte eller registrere dem med link til oprindelig placering
+- AI-baseret katalogisering skal foreslå metadata efter en fast profil (titel, resume, emneord, dokumenttype, målgruppe, fagområde, sprog, indikatorer på personoplysninger og tredjepartsindhold)
+- Publicisten skal tage **aktiv stilling** til rettighedsniveau og risikomarkering — ingen tavse defaults
+- Rettighedsmodellen skal være trinvis (fx 1–7) så myndigheder kan starte forsigtigt og udvide over tid
+- Publikationskatalog og træningsdatabank skal være **teknisk og juridisk adskilte** lag
+- Offentligt søgeinterface skal understøtte fritekst og facetterede filtre på tværs af myndigheder, emner, dokumenttyper, årstal og målgrupper
+- Hver publikation skal have en stabil præsentationsside med metadata, downloadlink, oprindelig kilde og rettighedsoplysninger
+- Træningsdatabanken skal være kurateret — kun publikationer der opfylder krav til rettigheder, databeskyttelse, kvalitet og teknisk anvendelighed indgår
+
+---
+
+## Uafklarede spørgsmål
+
+Prototypen er et visuelt og funktionelt diskussionsgrundlag — ikke en implementeringsklar løsning. Inden et reelt system kan bygges, skal en række forhold afklares.
+
+### Rettigheder og ophavsret
+
+- **Rettighedsmodellens niveauer.** Hvem definerer de syv niveauer juridisk? Er trappetrinnene de rigtige (registrering → visning → tekstudtræk → RAG → finjustering → fuld træning → fri licens), og hvilke standardlicenser knyttes til hvert niveau?
+- **Eksternt producerede rapporter.** Mange rapporter er udarbejdet af konsulenter, universiteter eller analyseinstitutter for myndigheden. Myndigheden har betalt — men har den ret til at give andre adgang til AI-træning på indholdet? Upload-flowet skal håndtere dette.
+- **Ansvar ved fejlklassificering.** Hvis en publikation fejlagtigt markeres som tilladt til træning og bagefter viser sig at indeholde tredjepartsmateriale — hvem hæfter? Myndigheden, platformen, eller AI-udvikleren der har brugt data?
+
+### AI-katalogisering
+
+- **Modelvalg og driftsmodel.** Hvilke modeller bruges til metadataudtræk og resume? Kører de hos en offentlig leverandør, on-prem, eller via API til kommerciel leverandør? Hvilke krav stilles til datalokalisering?
+- **Hallucinationer og kvalitet.** Hvordan håndteres tilfælde hvor AI'en foreslår forkerte metadata eller resume? Skal alle felter godkendes manuelt, eller er nogle felter "autoritative" uden review?
+- **Indikatorer på personoplysninger.** Automatisk screening kan hjælpe, men kan ikke stå alene ved publikationer med forhøjet risiko. Hvilken proces sikrer manuel vurdering af gule og røde publikationer?
+
+### Persondata og etik
+
+- **Risikoklassifikation (grøn/gul/rød).** Hvem træffer den endelige beslutning ved gul og rød? Er det publicisten, en central jurist hos opendata.dk, eller en kombination?
+- **Fotos, cases og citater.** Selv offentligt tilgængelige publikationer kan indeholde navngivne borgere. Hvordan skiller vi mellem "offentligt tilgængeligt" og "egnet til AI-træning"?
+
+### Governance og hosting
+
+- **Hvem ejer og driver tjenesten?** Digitaliseringsstyrelsen, Datatilsynet, et kommunalt konsortium, en kombination?
+- **Forholdet til opendata.dk.** Skal det være en udvidelse af eksisterende platform, eller en separat tjeneste der linker til opendata.dk?
+- **Forholdet til træningscenteret.** Hvordan kobler træningsdatabanken til det planlagte træningscenter for danske sprogmodeller?
+
+### Teknisk
+
+- **PDF-parsing og scannede dokumenter.** Hvilke værktøjer bruges til tekstudtræk? Hvordan håndteres scannede PDF'er uden OCR-lag?
+- **Versionering.** Hvad sker der når en myndighed opdaterer en publikation? Beholder vi tidligere versioner i træningsdatabanken?
+- **Skala.** Hvor mange publikationer forventes i pilot, og hvor mange efter fuld udrulning?
+
+### Pilot
+
+- **Omfang.** Realistisk antal myndigheder og publikationer i fase 1?
+- **Succeskriterier.** Hvad skal være på plads før pilot kan kaldes vellykket — antal publikationer, antal aktive myndigheder, faktisk anvendelse i AI-træning, eller noget andet?
+
+---
+
+## Interaktiv prototype
+
+Åbn prototypen ↗
diff --git a/docs/projects/dansk-viden-til-dansk-ai/mocks.md b/docs/projects/dansk-viden-til-dansk-ai/mocks.md
new file mode 100644
index 0000000..33f6b76
--- /dev/null
+++ b/docs/projects/dansk-viden-til-dansk-ai/mocks.md
@@ -0,0 +1,8 @@
+**Project:** Dansk Viden til Dansk AI
+
+# Interaktive Mocks
+
+---
+
+**Dansk Viden til Dansk AI — prototype ↗**
+Single-page prototype med syv visninger: forsiden, login/registrering, upload med AI-katalogisering, søgning med facetter, publikationsdetalje, favoritter og samlinger med delelink. Bruger `localStorage` som backend.
diff --git a/docs/public/projects/dansk-viden-til-dansk-ai/mocks/css/styles.css b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/css/styles.css
new file mode 100644
index 0000000..538a769
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/css/styles.css
@@ -0,0 +1,1301 @@
+/* Dansk Viden til Dansk AI – 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: #163057;
+ --color-primary-hover: #0f223e;
+ --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: #eef2f7;
+ 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;
+}
+
+/* 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/dansk-viden-til-dansk-ai/mocks/data/seed-publications.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/data/seed-publications.js
new file mode 100644
index 0000000..da80442
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/data/seed-publications.js
@@ -0,0 +1,457 @@
+/* Seeded fake publications.
+ Exposed as window.SEED_PUBLICATIONS so the app works when opened
+ via file:// where fetch() of local JSON is often blocked.
+*/
+window.SEED_PUBLICATIONS = [
+ {
+ id: "seed-001",
+ title: "Klimaplan 2030 for Aarhus Kommune",
+ subtitle: "Mål, indsatser og handlinger frem mod CO2-neutralitet",
+ summary:
+ "Aarhus Kommunes samlede plan for at blive CO2-neutral i 2030. Rapporten beskriver fokusområder inden for energi, transport, byggeri og affald, samt forslag til konkrete indsatser og opfølgning.",
+ authors: ["Aarhus Kommune, Teknik og Miljø"],
+ publisher: "Aarhus Kommune",
+ publishedAt: "2024-03-12",
+ language: "da",
+ documentType: "strategi",
+ subjectAreas: ["klima", "energi", "byudvikling"],
+ keywords: ["co2", "klimaneutralitet", "kommune", "energi"],
+ targetAudience: "Borgere, politikere, virksomheder",
+ geographicScope: "Aarhus Kommune",
+ rightsLevel: 4,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: "klimaplan-2030-aarhus.pdf",
+ fileSize: 4_812_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-03-15T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-002",
+ title: "Evaluering af kommunale sundhedsindsatser 2023",
+ summary:
+ "Region Midtjylland gennemgår effekten af kommunale forebyggelsesindsatser i samarbejde med almen praksis. Rapporten samler data fra 19 kommuner og giver anbefalinger til fremtidige indsatser.",
+ authors: ["Region Midtjylland, Folkesundhed"],
+ publisher: "Region Midtjylland",
+ publishedAt: "2023-11-04",
+ language: "da",
+ documentType: "evaluering",
+ subjectAreas: ["sundhed", "forebyggelse"],
+ keywords: ["folkesundhed", "evaluering", "almen praksis"],
+ targetAudience: "Fagprofessionelle, politikere",
+ geographicScope: "Region Midtjylland",
+ rightsLevel: 3,
+ riskLevel: "yellow",
+ personalDataFlags: ["citater fra borgere"],
+ thirdPartyContentFlags: [],
+ fileName: "evaluering-sundhed-2023.pdf",
+ fileSize: 2_134_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2023-11-10T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-003",
+ title: "Vejledning til digital tilgængelighed på kommunale hjemmesider",
+ summary:
+ "Digitaliseringsstyrelsens praktiske vejledning til kommuner om implementering af WCAG 2.2 og webtilgængelighedsloven, herunder testmetoder, eksempler og hyppige fejl.",
+ authors: ["Digitaliseringsstyrelsen"],
+ publisher: "Digitaliseringsstyrelsen",
+ publishedAt: "2024-06-20",
+ language: "da",
+ documentType: "vejledning",
+ subjectAreas: ["digitalisering", "tilgængelighed"],
+ keywords: ["wcag", "tilgængelighed", "kommune", "web"],
+ targetAudience: "Webredaktører, udviklere",
+ geographicScope: "Danmark",
+ rightsLevel: 7,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: "vejledning-digital-tilgaengelighed.pdf",
+ fileSize: 1_320_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-06-22T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-004",
+ title: "Analyse af mobilitetsmønstre i Hovedstadsområdet",
+ summary:
+ "Transportministeriets analyse af pendling, kollektiv trafik og cykelinfrastruktur i Region Hovedstaden, baseret på data fra 2019-2023.",
+ authors: ["Transportministeriet"],
+ publisher: "Transportministeriet",
+ publishedAt: "2024-01-30",
+ language: "da",
+ documentType: "analyse",
+ subjectAreas: ["transport", "mobilitet"],
+ keywords: ["pendling", "cykel", "kollektiv trafik"],
+ targetAudience: "Forvaltninger, forskere",
+ geographicScope: "Region Hovedstaden",
+ rightsLevel: 5,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: ["kortmateriale fra ekstern leverandør"],
+ fileName: "mobilitet-hovedstaden-2024.pdf",
+ fileSize: 6_482_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-02-01T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-005",
+ title: "Strategi for grønne indkøb i kommunen",
+ summary:
+ "Odense Kommunes strategi for bæredygtige offentlige indkøb 2024-2028. Indeholder mål for klimaaftryk, sociale klausuler og opfølgningsindikatorer.",
+ authors: ["Odense Kommune"],
+ publisher: "Odense Kommune",
+ publishedAt: "2024-04-18",
+ language: "da",
+ documentType: "strategi",
+ subjectAreas: ["indkøb", "bæredygtighed"],
+ keywords: ["indkøb", "udbud", "klima"],
+ targetAudience: "Forvaltning, leverandører",
+ geographicScope: "Odense Kommune",
+ rightsLevel: 4,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: "strategi-groenne-indkoeb.pdf",
+ fileSize: 980_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-04-20T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-006",
+ title: "Hvidbog om kunstig intelligens i offentlig sektor",
+ summary:
+ "KL og Digitaliseringsstyrelsen fremlægger overvejelser om ansvarlig brug af AI i offentlige opgaver, etiske principper og governance-modeller.",
+ authors: ["KL", "Digitaliseringsstyrelsen"],
+ publisher: "KL",
+ publishedAt: "2024-09-05",
+ language: "da",
+ documentType: "hvidbog",
+ subjectAreas: ["digitalisering", "ai", "etik"],
+ keywords: ["ai", "kunstig intelligens", "etik", "governance"],
+ targetAudience: "Politikere, ledere",
+ geographicScope: "Danmark",
+ rightsLevel: 6,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: "hvidbog-ai-offentlig-sektor.pdf",
+ fileSize: 2_780_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-09-08T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-007",
+ title: "Rapport om udsatte boligområder 2023",
+ summary:
+ "Indenrigs- og Boligministeriets årlige rapport om udviklingen i udsatte boligområder, herunder beskæftigelse, indkomst, uddannelse og kriminalitet.",
+ authors: ["Indenrigs- og Boligministeriet"],
+ publisher: "Indenrigs- og Boligministeriet",
+ publishedAt: "2023-12-01",
+ language: "da",
+ documentType: "rapport",
+ subjectAreas: ["bolig", "social"],
+ keywords: ["udsatte områder", "ghetto", "bolig"],
+ targetAudience: "Politikere, kommuner",
+ geographicScope: "Danmark",
+ rightsLevel: 2,
+ riskLevel: "red",
+ personalDataFlags: ["statistik på lille populationsniveau", "navngivne områder med beboeroplysninger"],
+ thirdPartyContentFlags: [],
+ fileName: "udsatte-boligomraader-2023.pdf",
+ fileSize: 3_410_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2023-12-03T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-008",
+ title: "Vejledning til kvalitetssikring af åbne data",
+ summary:
+ "Open Data DK's vejledning til datasæt-udgivere om kvalitetssikring, metadata, opdateringsfrekvens og dokumentation.",
+ authors: ["Open Data DK"],
+ publisher: "Open Data DK",
+ publishedAt: "2024-02-14",
+ language: "da",
+ documentType: "vejledning",
+ subjectAreas: ["data", "digitalisering"],
+ keywords: ["åbne data", "metadata", "kvalitet"],
+ targetAudience: "Dataudgivere",
+ geographicScope: "Danmark",
+ rightsLevel: 7,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: "kvalitet-aabne-data.pdf",
+ fileSize: 640_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-02-15T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-009",
+ title: "Analyse: Frafald på erhvervsuddannelserne",
+ summary:
+ "Børne- og Undervisningsministeriets analyse af årsager til frafald og effekt af fastholdelsesindsatser på tværs af 28 erhvervsskoler.",
+ authors: ["Børne- og Undervisningsministeriet"],
+ publisher: "Børne- og Undervisningsministeriet",
+ publishedAt: "2024-05-10",
+ language: "da",
+ documentType: "analyse",
+ subjectAreas: ["uddannelse", "ungdom"],
+ keywords: ["erhvervsuddannelse", "frafald", "ungdom"],
+ targetAudience: "Uddannelsesinstitutioner, politikere",
+ geographicScope: "Danmark",
+ rightsLevel: 3,
+ riskLevel: "yellow",
+ personalDataFlags: ["anonymiserede interviewcitater"],
+ thirdPartyContentFlags: [],
+ fileName: "frafald-erhvervsuddannelser.pdf",
+ fileSize: 1_870_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-05-12T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-010",
+ title: "Klimatilpasningsplan for Vejle Kommune",
+ summary:
+ "Vejle Kommunes plan for håndtering af klimaforandringer, herunder kystsikring, kloakker og grønne løsninger frem mod 2050.",
+ authors: ["Vejle Kommune"],
+ publisher: "Vejle Kommune",
+ publishedAt: "2023-08-22",
+ language: "da",
+ documentType: "strategi",
+ subjectAreas: ["klima", "byudvikling"],
+ keywords: ["klimatilpasning", "oversvømmelse", "kystsikring"],
+ targetAudience: "Borgere, virksomheder, forvaltning",
+ geographicScope: "Vejle Kommune",
+ rightsLevel: 4,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: ["luftfotos fra ekstern leverandør"],
+ fileName: "klimatilpasning-vejle.pdf",
+ fileSize: 5_120_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2023-08-25T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-011",
+ title: "Rapport om sagsbehandlingstider i Familieretshuset",
+ summary:
+ "Civilstyrelsens årlige opgørelse af sagsbehandlingstider, herunder forklaringer på variationer og handlingsplan for forbedring.",
+ authors: ["Civilstyrelsen"],
+ publisher: "Civilstyrelsen",
+ publishedAt: "2024-07-01",
+ language: "da",
+ documentType: "rapport",
+ subjectAreas: ["retsvæsen", "forvaltning"],
+ keywords: ["familieret", "sagsbehandling"],
+ targetAudience: "Borgere, advokater, politikere",
+ geographicScope: "Danmark",
+ rightsLevel: 2,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: "sagsbehandling-familieret-2024.pdf",
+ fileSize: 720_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-07-03T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-012",
+ title: "Strategi for biodiversitet i Region Sjælland",
+ summary:
+ "Region Sjællands strategi for at fremme biodiversitet på regionens arealer og i samarbejde med kommuner, lodsejere og frivillige.",
+ authors: ["Region Sjælland"],
+ publisher: "Region Sjælland",
+ publishedAt: "2024-04-02",
+ language: "da",
+ documentType: "strategi",
+ subjectAreas: ["natur", "miljø"],
+ keywords: ["biodiversitet", "natur", "naturpleje"],
+ targetAudience: "Forvaltning, lodsejere, foreninger",
+ geographicScope: "Region Sjælland",
+ rightsLevel: 5,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: "biodiversitet-sjaelland.pdf",
+ fileSize: 1_640_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-04-05T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-013",
+ title: "Evaluering af digitale læringsmidler i folkeskolen",
+ summary:
+ "Styrelsen for Undervisning og Kvalitet evaluerer brugen og effekten af digitale læringsmidler på tværs af 42 skoler i tre kommuner.",
+ authors: ["Styrelsen for Undervisning og Kvalitet"],
+ publisher: "Styrelsen for Undervisning og Kvalitet",
+ publishedAt: "2023-10-15",
+ language: "da",
+ documentType: "evaluering",
+ subjectAreas: ["uddannelse", "digitalisering"],
+ keywords: ["folkeskole", "digital læring"],
+ targetAudience: "Skoler, kommuner",
+ geographicScope: "Danmark",
+ rightsLevel: 3,
+ riskLevel: "yellow",
+ personalDataFlags: ["interviewcitater fra elever"],
+ thirdPartyContentFlags: [],
+ fileName: "digitale-laeringsmidler.pdf",
+ fileSize: 2_240_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2023-10-17T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-014",
+ title: "Analyse af energiforbrug i offentlige bygninger",
+ summary:
+ "Energistyrelsens kortlægning af energiforbrug i kommunale og statslige bygninger med anbefalinger til renovering og drift.",
+ authors: ["Energistyrelsen"],
+ publisher: "Energistyrelsen",
+ publishedAt: "2024-02-28",
+ language: "da",
+ documentType: "analyse",
+ subjectAreas: ["energi", "bygninger"],
+ keywords: ["energi", "bygninger", "renovering"],
+ targetAudience: "Bygningsejere, forvaltninger",
+ geographicScope: "Danmark",
+ rightsLevel: 6,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: "energi-offentlige-bygninger.pdf",
+ fileSize: 1_950_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-03-01T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-015",
+ title: "Rapport: Trivsel og mental sundhed blandt unge",
+ summary:
+ "Sundhedsstyrelsens samlede rapport om udviklingen i trivsel, ensomhed og psykisk mistrivsel blandt unge i Danmark 2018-2023.",
+ authors: ["Sundhedsstyrelsen"],
+ publisher: "Sundhedsstyrelsen",
+ publishedAt: "2024-08-12",
+ language: "da",
+ documentType: "rapport",
+ subjectAreas: ["sundhed", "ungdom"],
+ keywords: ["trivsel", "mental sundhed", "unge"],
+ targetAudience: "Kommuner, skoler, politikere",
+ geographicScope: "Danmark",
+ rightsLevel: 3,
+ riskLevel: "yellow",
+ personalDataFlags: ["anonymiserede citater", "spørgeskemadata"],
+ thirdPartyContentFlags: [],
+ fileName: "trivsel-unge-2024.pdf",
+ fileSize: 3_080_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-08-14T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-016",
+ title: "Vejledning til implementering af GDPR i mindre kommuner",
+ summary:
+ "Datatilsynet og KL har udarbejdet en praktisk vejledning til kommuner under 30.000 indbyggere om dokumentation, fortegnelser og databehandleraftaler.",
+ authors: ["Datatilsynet", "KL"],
+ publisher: "Datatilsynet",
+ publishedAt: "2024-03-04",
+ language: "da",
+ documentType: "vejledning",
+ subjectAreas: ["jura", "databeskyttelse"],
+ keywords: ["gdpr", "databeskyttelse", "kommune"],
+ targetAudience: "Kommunale forvaltninger",
+ geographicScope: "Danmark",
+ rightsLevel: 7,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: "gdpr-mindre-kommuner.pdf",
+ fileSize: 1_120_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-03-06T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-017",
+ title: "Analyse af affaldssortering i danske husstande",
+ summary:
+ "Miljøstyrelsens nationale analyse af affaldssorteringens udvikling og effekt på genanvendelsesgraden 2020-2024.",
+ authors: ["Miljøstyrelsen"],
+ publisher: "Miljøstyrelsen",
+ publishedAt: "2024-06-01",
+ language: "da",
+ documentType: "analyse",
+ subjectAreas: ["miljø", "affald"],
+ keywords: ["affald", "sortering", "genanvendelse"],
+ targetAudience: "Borgere, kommuner",
+ geographicScope: "Danmark",
+ rightsLevel: 5,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: "affaldssortering-2024.pdf",
+ fileSize: 1_460_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-06-04T09:00:00Z",
+ source: "seed"
+ },
+ {
+ id: "seed-018",
+ title: "Strategi for ældreområdet i Aalborg Kommune",
+ summary:
+ "Aalborg Kommunes ældrestrategi 2024-2030 med fokus på selvbestemmelse, værdighed, sammenhæng og rekruttering af medarbejdere.",
+ authors: ["Aalborg Kommune"],
+ publisher: "Aalborg Kommune",
+ publishedAt: "2024-01-12",
+ language: "da",
+ documentType: "strategi",
+ subjectAreas: ["ældre", "velfærd"],
+ keywords: ["ældre", "pleje", "velfærd"],
+ targetAudience: "Borgere, medarbejdere, politikere",
+ geographicScope: "Aalborg Kommune",
+ rightsLevel: 4,
+ riskLevel: "green",
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: "aeldrestrategi-aalborg.pdf",
+ fileSize: 1_280_000,
+ mimeType: "application/pdf",
+ uploadedBy: null,
+ uploadedAt: "2024-01-15T09:00:00Z",
+ source: "seed"
+ }
+];
diff --git a/docs/public/projects/dansk-viden-til-dansk-ai/mocks/index.html b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/index.html
new file mode 100644
index 0000000..078e155
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/index.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+ Dansk Viden til Dansk AI – prototype
+
+
+
+
+
+
+
+
+ Spring til indhold
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/app.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/app.js
new file mode 100644
index 0000000..87b9746
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/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("/publication/"),
+ view: Detail,
+ public: true,
+ width: "wide",
+ params: (p) => ({ id: p.replace("/publication/", "") })
+ },
+ {
+ 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/dansk-viden-til-dansk-ai/mocks/js/auth.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/auth.js
new file mode 100644
index 0000000..a2b3390
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/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/dansk-viden-til-dansk-ai/mocks/js/catalog.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/catalog.js
new file mode 100644
index 0000000..f111570
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/catalog.js
@@ -0,0 +1,93 @@
+/* Catalog: merges seeded publications with user uploads, plus search/filter. */
+
+import { store } from "./store.js";
+
+export const RIGHTS_LEVELS = [
+ { level: 1, title: "Kun registrering i kataloget", description: "Publikationen kan findes i kataloget, men ikke vises eller downloades direkte." },
+ { level: 2, title: "Offentlig visning og download", description: "Publikationen kan ses og hentes af alle besøgende." },
+ { level: 3, title: "Tekstudtræk til søgning og analyse", description: "Tekst kan udtrækkes til søgeindeks og analyser, men ikke videredistribueres." },
+ { level: 4, title: "Brug i offentlige RAG-løsninger", description: "Indhold kan bruges i offentlige retrieval-augmented systemer." },
+ { level: 5, title: "Brug til evaluering og finjustering", description: "Tekst kan bruges til evaluering og finjustering af sprogmodeller." },
+ { level: 6, title: "Brug til egentlig modeltræning", description: "Tekst indgår i datagrundlag til træning af danske sprogmodeller." },
+ { level: 7, title: "Fri videreanvendelse under åben licens", description: "Indhold kan genbruges frit under en åben licens." }
+];
+
+export function getAllPublications() {
+ const seeded = window.SEED_PUBLICATIONS || [];
+ const uploads = store.getUploads();
+ return [...uploads, ...seeded];
+}
+
+export function getPublication(id) {
+ return getAllPublications().find(p => p.id === id) || null;
+}
+
+export function rightsLevelInfo(level) {
+ return RIGHTS_LEVELS.find(r => r.level === level) || RIGHTS_LEVELS[0];
+}
+
+/* Search + filter
+ filters: {
+ q: string,
+ publishers: Set,
+ documentTypes: Set,
+ subjectAreas: Set,
+ years: Set,
+ rightsLevels: Set,
+ riskLevels: Set
+ }
+*/
+export function searchPublications(filters) {
+ const q = (filters.q || "").trim().toLowerCase();
+ const tokens = q ? q.split(/\s+/).filter(Boolean) : [];
+
+ return getAllPublications().filter(p => {
+ if (tokens.length) {
+ const hay = [
+ p.title, p.subtitle, p.summary, p.publisher,
+ (p.keywords || []).join(" "),
+ (p.subjectAreas || []).join(" "),
+ (p.authors || []).join(" ")
+ ].filter(Boolean).join(" ").toLowerCase();
+ if (!tokens.every(t => hay.includes(t))) return false;
+ }
+ if (filters.publishers?.size && !filters.publishers.has(p.publisher)) return false;
+ if (filters.documentTypes?.size && !filters.documentTypes.has(p.documentType)) return false;
+ if (filters.subjectAreas?.size) {
+ const has = (p.subjectAreas || []).some(s => filters.subjectAreas.has(s));
+ if (!has) return false;
+ }
+ if (filters.years?.size) {
+ const y = String(new Date(p.publishedAt).getFullYear());
+ if (!filters.years.has(y)) return false;
+ }
+ if (filters.rightsLevels?.size && !filters.rightsLevels.has(p.rightsLevel)) return false;
+ if (filters.riskLevels?.size && !filters.riskLevels.has(p.riskLevel)) return false;
+ return true;
+ });
+}
+
+/* Build facet counts based on a base set (pre-filtered or all). */
+export function buildFacets(base) {
+ const facets = {
+ publishers: new Map(),
+ documentTypes: new Map(),
+ subjectAreas: new Map(),
+ years: new Map(),
+ rightsLevels: new Map(),
+ riskLevels: new Map()
+ };
+ for (const p of base) {
+ inc(facets.publishers, p.publisher);
+ inc(facets.documentTypes, p.documentType);
+ (p.subjectAreas || []).forEach(s => inc(facets.subjectAreas, s));
+ inc(facets.years, String(new Date(p.publishedAt).getFullYear()));
+ inc(facets.rightsLevels, p.rightsLevel);
+ inc(facets.riskLevels, p.riskLevel);
+ }
+ 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/dansk-viden-til-dansk-ai/mocks/js/collections.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/collections.js
new file mode 100644
index 0000000..200bf1a
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/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 publication snapshots) into
+ the URL after the token. The token still uniquely identifies the
+ collection for the owner. */
+
+import { store, uid } from "./store.js";
+import { getPublication } 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,
+ publicationIds: [],
+ createdAt: new Date().toISOString(),
+ shareToken: Math.random().toString(36).slice(2, 10)
+ };
+ store.addCollection(collection);
+ return collection;
+}
+
+export function addPublicationToCollection(collectionId, pubId) {
+ const all = store.getCollections();
+ const c = all.find(x => x.id === collectionId);
+ if (!c) return null;
+ if (!c.publicationIds.includes(pubId)) {
+ c.publicationIds.push(pubId);
+ store.setCollections(all);
+ }
+ return c;
+}
+
+export function removePublicationFromCollection(collectionId, pubId) {
+ const all = store.getCollections();
+ const c = all.find(x => x.id === collectionId);
+ if (!c) return null;
+ c.publicationIds = c.publicationIds.filter(id => id !== pubId);
+ store.setCollections(all);
+ return c;
+}
+
+/* Encode a snapshot of the collection (with the actual publications)
+ into a base64 string suitable for embedding in the URL hash. */
+export function encodeShareablePayload(collection) {
+ const publications = collection.publicationIds
+ .map(id => getPublication(id))
+ .filter(Boolean);
+ return b64encodeObject({
+ id: collection.id,
+ name: collection.name,
+ description: collection.description,
+ shareToken: collection.shareToken,
+ createdAt: collection.createdAt,
+ publications
+ });
+}
+
+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/dansk-viden-til-dansk-ai/mocks/js/icons.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/icons.js
new file mode 100644
index 0000000..d536293
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/icons.js
@@ -0,0 +1,31 @@
+/* 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: ''
+};
+
+/** 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/dansk-viden-til-dansk-ai/mocks/js/store.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/store.js
new file mode 100644
index 0000000..a08e193
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/store.js
@@ -0,0 +1,108 @@
+/* localStorage-backed store. All state lives under the "dv:" prefix. */
+
+const KEYS = {
+ users: "dv:users",
+ session: "dv:session",
+ uploads: "dv:uploads",
+ favorites: "dv:favorites",
+ collections: "dv: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.uploads, []); },
+ setUploads(uploads) { write(KEYS.uploads, uploads); },
+ addUpload(pub) {
+ const all = store.getUploads();
+ all.push(pub);
+ store.setUploads(all);
+ },
+ deleteUpload(pubId) {
+ store.setUploads(store.getUploads().filter(p => p.id !== pubId));
+ // Also clean up any favorites and collection references to the dead pub.
+ store.setFavorites(store.getFavorites().filter(f => f.publicationId !== pubId));
+ const collections = store.getCollections();
+ let changed = false;
+ for (const c of collections) {
+ const before = c.publicationIds.length;
+ c.publicationIds = c.publicationIds.filter(id => id !== pubId);
+ if (c.publicationIds.length !== before) changed = true;
+ }
+ if (changed) store.setCollections(collections);
+ },
+ uploadsByUser(userId) {
+ return store.getUploads().filter(p => p.uploadedBy === userId);
+ },
+
+ getFavorites() { return read(KEYS.favorites, []); },
+ setFavorites(favs) { write(KEYS.favorites, favs); },
+ toggleFavorite(userId, pubId) {
+ const all = store.getFavorites();
+ const idx = all.findIndex(f => f.userId === userId && f.publicationId === pubId);
+ if (idx >= 0) {
+ all.splice(idx, 1);
+ } else {
+ all.push({ userId, publicationId: pubId, addedAt: new Date().toISOString() });
+ }
+ store.setFavorites(all);
+ return idx < 0;
+ },
+ isFavorite(userId, pubId) {
+ if (!userId) return false;
+ return store.getFavorites().some(f => f.userId === userId && f.publicationId === pubId);
+ },
+ 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/dansk-viden-til-dansk-ai/mocks/js/util.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/util.js
new file mode 100644
index 0000000..fa20347
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/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/dansk-viden-til-dansk-ai/mocks/js/views/_collection-modal.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/_collection-modal.js
new file mode 100644
index 0000000..81c19e2
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/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, addPublicationToCollection } from "../collections.js";
+
+/* Modal for working with collections.
+ - pub provided → "Føj til samling": pick an existing or create + add.
+ - pub omitted → "Opret ny samling": only the create form.
+ opts.onCreated(collection) fires after a new collection is created. */
+export function showCollectionModal(pub = 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 (pub) {
+ modal.appendChild(el("h3", {}, "Føj til samling"));
+ modal.appendChild(el("p", { class: "muted text-sm" }, `“${pub.title}” 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.publicationIds.includes(pub.id);
+ list.appendChild(el("button", {
+ class: "btn btn-secondary",
+ style: "justify-content:flex-start;",
+ onclick: () => {
+ if (already) { toast("Publikationen er allerede i samlingen."); close(); return; }
+ addPublicationToCollection(c.id, pub.id);
+ toast(`Tilføjet til “${c.name}”`);
+ close();
+ }
+ }, c.name + (already ? " · allerede tilføjet" : ` · ${c.publicationIds.length} publikationer`)));
+ }
+ 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 publikationer 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 (pub) {
+ addPublicationToCollection(c.id, pub.id);
+ toast(`Oprettet “${c.name}” og tilføjet publikation`);
+ } else {
+ toast(`Samling “${c.name}” oprettet`);
+ }
+ close();
+ opts.onCreated?.(c);
+ }
+ }, [
+ pub ? el("strong", {}, "…eller opret en ny samling") : null,
+ el("label", { class: "field" }, [
+ "Navn",
+ el("input", { type: "text", name: "name", required: true, placeholder: "f.eks. Klimaplaner 2024", 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" }, pub ? "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 = (pub) => showCollectionModal(pub);
diff --git a/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/_pub-card.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/_pub-card.js
new file mode 100644
index 0000000..9159509
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/_pub-card.js
@@ -0,0 +1,78 @@
+/* Shared publication card used in search results, favorites, collections. */
+
+import { el, escapeHtml, formatYear, toast } from "../util.js";
+import { auth } from "../auth.js";
+import { store } from "../store.js";
+import { rightsLevelInfo } from "../catalog.js";
+import { iconHtml } from "../icons.js";
+
+const RISK_LABEL = { green: "Lav risiko", yellow: "Bør vurderes", red: "Kræver afklaring" };
+const DOC_TYPE_LABEL = {
+ rapport: "Rapport",
+ analyse: "Analyse",
+ strategi: "Strategi",
+ vejledning: "Vejledning",
+ evaluering: "Evaluering",
+ hvidbog: "Hvidbog"
+};
+
+export function renderPubCard(p, opts = {}) {
+ const user = auth.currentUser();
+ const isFav = user ? store.isFavorite(user.id, p.id) : false;
+ const rights = rightsLevelInfo(p.rightsLevel);
+
+ 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, p.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: `#/publication/${p.id}`, html: escapeHtml(p.title) })
+ ]),
+ el("div", { class: "meta" },
+ `${p.publisher} · ${formatYear(p.publishedAt)}` + (p.source === "user" ? " · uploadet" : "")),
+ el("p", { class: "summary", text: trim(p.summary, 240) }),
+ el("div", { class: "badges" }, [
+ el("span", { class: "badge badge-doctype" }, DOC_TYPE_LABEL[p.documentType] || p.documentType || "Publikation"),
+ el("span", {
+ class: "badge badge-rights",
+ dataset: { level: String(p.rightsLevel) },
+ title: rights.description
+ }, `Niveau ${p.rightsLevel}: ${rights.title}`),
+ el("span", {
+ class: "badge badge-risk",
+ dataset: { risk: p.riskLevel || "green" }
+ }, RISK_LABEL[p.riskLevel] || RISK_LABEL.green)
+ ])
+ ]),
+ 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/dansk-viden-til-dansk-ai/mocks/js/views/collections.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/collections.js
new file mode 100644
index 0000000..95a2a86
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/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 { getPublication } from "../catalog.js";
+import { removePublicationFromCollection, buildShareUrl, decodeShareablePayload } from "../collections.js";
+import { renderPubCard } from "./_pub-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 publikationer og del dem som et link – f.eks. til kolleger eller borgermøder.")
+ ]),
+ 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.publicationIds.length} publikationer · 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.publicationIds.length) {
+ card.appendChild(el("p", { class: "muted mt-3" }, "Endnu ingen publikationer. Tilføj fra en publikations side."));
+ return card;
+ }
+
+ const list = el("div", { class: "results-list mt-3" });
+ c.publicationIds.forEach(pid => {
+ const p = getPublication(pid);
+ if (!p) return;
+ list.appendChild(renderPubCard(p, {
+ extraAction: el("button", {
+ class: "btn btn-ghost btn-sm",
+ onclick: () => {
+ removePublicationFromCollection(c.id, p.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 publications;
+ 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,
+ publicationIds: decoded.publications.map(p => p.id),
+ ownerId: null
+ };
+ publications = decoded.publications;
+ }
+ }
+
+ 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;
+ }
+
+ publications = publications || collection.publicationIds.map(id => getPublication(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. Publikationerne er indlejret i delelinket og kan tilgås uden login."));
+ }
+
+ if (!publications.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" });
+ publications.forEach(p => list.appendChild(renderPubCard(p)));
+ root.appendChild(list);
+}
diff --git a/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/detail.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/detail.js
new file mode 100644
index 0000000..d6cb07d
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/detail.js
@@ -0,0 +1,134 @@
+import { el, clear, escapeHtml, formatDate, formatSize, navigate, toast } from "../util.js";
+import { getPublication, rightsLevelInfo } from "../catalog.js";
+import { auth } from "../auth.js";
+import { store } from "../store.js";
+import { iconHtml } from "../icons.js";
+
+const RISK_LABEL = { green: "Lav risiko", yellow: "Bør vurderes", red: "Kræver afklaring" };
+const DOC_TYPE_LABEL = {
+ rapport: "Rapport", analyse: "Analyse", strategi: "Strategi",
+ vejledning: "Vejledning", evaluering: "Evaluering", hvidbog: "Hvidbog"
+};
+
+export function render(root, _query, params) {
+ clear(root);
+ const pub = getPublication(params.id);
+ if (!pub) {
+ root.appendChild(el("div", { class: "empty card" }, [
+ el("h2", {}, "Publikation ikke fundet"),
+ el("p", {}, "Linket peger ikke på en eksisterende publikation."),
+ el("a", { class: "btn", href: "#/search" }, "Tilbage til søgning")
+ ]));
+ return;
+ }
+
+ const user = auth.currentUser();
+ const isFav = user ? store.isFavorite(user.id, pub.id) : false;
+ const rights = rightsLevelInfo(pub.rightsLevel);
+
+ 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" });
+
+ // Breadcrumb above the grid keeps both columns aligned at the top.
+ root.appendChild(el("nav", { class: "breadcrumb" }, [
+ el("a", { href: "#/search" }, "Søg"),
+ el("span", { class: "breadcrumb-sep", "aria-hidden": "true" }, "/"),
+ el("span", {}, pub.publisher)
+ ]));
+
+ main.appendChild(el("p", { class: "eyebrow" }, DOC_TYPE_LABEL[pub.documentType] || pub.documentType));
+ main.appendChild(el("h1", { html: escapeHtml(pub.title) }));
+ if (pub.subtitle) main.appendChild(el("p", { class: "detail-subtitle", html: escapeHtml(pub.subtitle) }));
+
+ main.appendChild(el("div", { class: "badges mb-4" }, [
+ el("span", {
+ class: "badge badge-rights",
+ dataset: { level: String(pub.rightsLevel) },
+ title: rights.description
+ }, `Niveau ${pub.rightsLevel}: ${rights.title}`),
+ el("span", {
+ class: "badge badge-risk",
+ dataset: { risk: pub.riskLevel }
+ }, RISK_LABEL[pub.riskLevel] || RISK_LABEL.green)
+ ]));
+
+ main.appendChild(el("h2", {}, "Resume"));
+ main.appendChild(el("div", { class: "resume-block" }, [
+ el("p", { text: pub.summary })
+ ]));
+
+ if (pub.keywords?.length) {
+ main.appendChild(el("h3", {}, "Emneord"));
+ main.appendChild(el("div", { class: "chips" }, pub.keywords.map(k => el("span", { class: "chip" }, k))));
+ }
+
+ if (pub.personalDataFlags?.length || pub.thirdPartyContentFlags?.length) {
+ main.appendChild(el("h3", { class: "mt-4" }, "Risikohensyn"));
+ const ul = el("ul");
+ pub.personalDataFlags?.forEach(f => ul.appendChild(el("li", {}, `Persondata: ${f}`)));
+ pub.thirdPartyContentFlags?.forEach(f => ul.appendChild(el("li", {}, `Tredjepartsindhold: ${f}`)));
+ main.appendChild(ul);
+ }
+
+ // Actions column (far right on wide viewports)
+ const actions = el("div", { class: "detail-actions" });
+ actions.appendChild(el("button", {
+ class: "btn",
+ html: `${iconHtml("download")}Hent PDF (demo)`,
+ onclick: () => toast("Demo: download er ikke aktiv i prototypen.")
+ }));
+
+ 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, pub.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(pub)
+ }));
+ actionsAside.appendChild(actions);
+
+ // Metadata column (middle on wide viewports)
+ meta.appendChild(el("p", { class: "eyebrow" }, "Detaljer"));
+ const dl = el("dl", { class: "detail-meta" });
+ appendMeta(dl, "Udgiver", pub.publisher);
+ appendMeta(dl, "Forfatter(e)", (pub.authors || []).join(", "));
+ appendMeta(dl, "Udgivet", formatDate(pub.publishedAt));
+ appendMeta(dl, "Sprog", pub.language === "da" ? "Dansk" : pub.language);
+ appendMeta(dl, "Målgruppe", pub.targetAudience);
+ appendMeta(dl, "Geografisk omfang", pub.geographicScope);
+ if (pub.subjectAreas?.length) appendMeta(dl, "Fagområde", pub.subjectAreas.join(", "));
+ appendMeta(dl, "Filnavn", pub.fileName);
+ appendMeta(dl, "Filstørrelse", formatSize(pub.fileSize));
+ meta.appendChild(dl);
+
+ grid.appendChild(main);
+ grid.appendChild(meta);
+ grid.appendChild(actionsAside);
+ root.appendChild(grid);
+}
+
+function appendMeta(dl, label, value) {
+ if (!value) return;
+ dl.appendChild(el("dt", {}, label));
+ dl.appendChild(el("dd", { text: value }));
+}
+
+async function openAddToCollection(pub) {
+ const { showAddToCollectionModal } = await import("./_collection-modal.js");
+ showAddToCollectionModal(pub);
+}
diff --git a/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/favorites.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/favorites.js
new file mode 100644
index 0000000..c4b548a
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/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 { getPublication } from "../catalog.js";
+import { renderPubCard } from "./_pub-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 pubs = favs.map(f => getPublication(f.publicationId)).filter(Boolean);
+
+ if (!pubs.length) {
+ root.appendChild(el("div", { class: "empty card" }, [
+ el("h2", {}, "Du har ingen favoritter endnu"),
+ el("p", { class: "muted" }, "Marker en publikation med hjertet for at gemme den her."),
+ el("a", { class: "btn", href: "#/search" }, "Find publikationer")
+ ]));
+ return;
+ }
+
+ const list = el("div", { class: "results-list" });
+ pubs.forEach(p => list.appendChild(renderPubCard(p, {
+ onFavoriteChange: (isFav) => { if (!isFav) paint(); }
+ })));
+ root.appendChild(list);
+ }
+
+ paint();
+}
diff --git a/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/home.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/home.js
new file mode 100644
index 0000000..95d15c2
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/home.js
@@ -0,0 +1,129 @@
+import { el, clear, navigate, escapeHtml } from "../util.js";
+import { getAllPublications } from "../catalog.js";
+import { iconHtml } from "../icons.js";
+
+export function render(root) {
+ clear(root);
+
+ const all = getAllPublications();
+ const publisherCount = new Set(all.map(p => p.publisher)).size;
+ const trainableCount = all.filter(p => p.rightsLevel >= 5).length;
+
+ const searchBlock = el("section", { class: "home-search", "aria-label": "Søg i kataloget" }, [
+ el("p", { class: "eyebrow" }, "Find offentlig viden"),
+ 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 rapporter, analyser, strategier…",
+ "aria-label": "Søg"
+ }),
+ el("button", { class: "btn", type: "submit" }, "Søg")
+ ])
+ ]);
+
+ const hero = el("section", { class: "hero" }, [
+ el("p", { class: "eyebrow" }, "Offentlig viden · dansk AI"),
+ el("h1", { class: "hero-title" }, [
+ "Et fælles katalog over ",
+ el("em", {}, "dansk"),
+ " offentlig viden."
+ ]),
+ el("p", { class: "lede" },
+ "Find, gem og bidrag med rapporter, analyser og strategier fra danske myndigheder – og hjælp med at bygge et rettighedsclearet datagrundlag for danske sprogmodeller."),
+
+ el("dl", { class: "hero-stats" }, [
+ el("div", {}, [
+ el("dt", {}, "Publikationer"),
+ el("dd", {}, String(all.length))
+ ]),
+ el("div", {}, [
+ el("dt", {}, "Myndigheder"),
+ el("dd", {}, String(publisherCount))
+ ]),
+ el("div", {}, [
+ el("dt", {}, "Egnet til AI-træning"),
+ el("dd", {}, String(trainableCount))
+ ])
+ ])
+ ]);
+
+ const recent = [...all]
+ .sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt))
+ .slice(0, 8);
+
+ const railEl = el("div", { class: "recent-rail" },
+ recent.map(p => renderRailCard(p))
+ );
+
+ 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 tilføjet"),
+ 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", {}, "Upload publikation"), " – AI foreslår metadata, resume og rettighedsniveau."])),
+ el("li", {}, el("span", { class: "step-body" }, [el("strong", {}, "Godkend og udgiv"), " – du har ansvaret for rettighederne, ligesom på Open Data DK."])),
+ el("li", {}, el("span", { class: "step-body" }, [el("strong", {}, "Søg og deling"), " – brugere finder dit indhold, kan gemme favoritter og dele samlinger."]))
+ ])
+ ]);
+
+ root.appendChild(hero);
+ root.appendChild(searchBlock);
+ root.appendChild(rail);
+ root.appendChild(aboutSection);
+}
+
+function renderRailCard(p) {
+ return el("a", {
+ class: "rail-card",
+ href: `#/publication/${p.id}`
+ }, [
+ el("span", { class: "rail-meta" }, `${p.publisher} · ${new Date(p.publishedAt).getFullYear()}`),
+ el("span", { class: "rail-title", html: escapeHtml(p.title) }),
+ el("span", { class: "rail-summary", text: trim(p.summary, 110) })
+ ]);
+}
+
+function trim(s, n) {
+ if (!s) return "";
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
+}
diff --git a/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/login.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/login.js
new file mode 100644
index 0000000..10bf726
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/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/dansk-viden-til-dansk-ai/mocks/js/views/search.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/search.js
new file mode 100644
index 0000000..658a85b
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/search.js
@@ -0,0 +1,268 @@
+import { el, clear } from "../util.js";
+import { searchPublications, buildFacets, rightsLevelInfo, getAllPublications } from "../catalog.js";
+import { renderPubCard } from "./_pub-card.js";
+
+const DOC_TYPE_LABEL = {
+ rapport: "Rapport",
+ analyse: "Analyse",
+ strategi: "Strategi",
+ vejledning: "Vejledning",
+ evaluering: "Evaluering",
+ hvidbog: "Hvidbog"
+};
+
+const RISK_LABEL = { green: "Grøn (lav)", yellow: "Gul (vurder)", red: "Rød (kræver afklaring)" };
+const RECENT_KEY = "dv:recent-searches";
+const FACET_OPEN_KEY = "dv:facet-open";
+const FACET_DEFAULT_OPEN = new Set(["Myndighed", "Dokumenttype"]);
+
+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 || "",
+ publishers: new Set(),
+ documentTypes: new Set(),
+ subjectAreas: new Set(),
+ years: new Set(),
+ rightsLevels: new Set(),
+ riskLevels: 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 = searchPublications(state);
+ const facets = buildFacets(getAllPublications());
+
+ renderAside();
+ renderMain(results);
+ renderContext(results);
+ syncUrl();
+
+ function renderAside() {
+ clear(aside);
+ aside.appendChild(el("h3", { style: "margin-top:0;" }, "Filtre"));
+
+ aside.appendChild(facetGroup("Myndighed", facets.publishers, state.publishers));
+ aside.appendChild(facetGroup("Dokumenttype", facets.documentTypes, state.documentTypes, (k) => DOC_TYPE_LABEL[k] || k));
+ aside.appendChild(facetGroup("Fagområde", facets.subjectAreas, state.subjectAreas));
+ aside.appendChild(facetGroup("År", facets.years, state.years, null, true));
+ aside.appendChild(facetGroup(
+ "Rettighedsniveau",
+ facets.rightsLevels,
+ state.rightsLevels,
+ (k) => `${k}. ${rightsLevelInfo(Number(k)).title}`,
+ true,
+ (k) => Number(k)
+ ));
+ aside.appendChild(facetGroup("Risiko (persondata)", facets.riskLevels, state.riskLevels, (k) => RISK_LABEL[k] || k));
+
+ if (anyFilter()) {
+ aside.appendChild(el("button", {
+ class: "btn btn-secondary btn-sm mt-3",
+ onclick: () => {
+ ["publishers","documentTypes","subjectAreas","years","rightsLevels","riskLevels"].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 publikationer 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(p => list.appendChild(renderPubCard(p)));
+ 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);
+ }
+
+ // Training data callout for high-rights matches
+ const trainable = results.filter(r => r.rightsLevel >= 5).length;
+ if (trainable > 0) {
+ const callout = el("aside", { class: "context-aside" });
+ callout.appendChild(el("h3", {}, "Til træningsdatabank"));
+ callout.appendChild(el("p", { class: "context-empty" }, `${trainable} af resultaterne kan bruges til AI-træning (niveau 5+).`));
+ context.appendChild(callout);
+ }
+ }
+
+ function collectActiveChips() {
+ const chips = [];
+ if (state.q) {
+ chips.push(makeChip(`"${state.q}"`, () => { state.q = ""; update(); }));
+ }
+ state.publishers.forEach(v => chips.push(makeChip(v, () => { state.publishers.delete(v); update(); })));
+ state.documentTypes.forEach(v => chips.push(makeChip(DOC_TYPE_LABEL[v] || v, () => { state.documentTypes.delete(v); update(); })));
+ state.subjectAreas.forEach(v => chips.push(makeChip(v, () => { state.subjectAreas.delete(v); update(); })));
+ state.years.forEach(v => chips.push(makeChip(v, () => { state.years.delete(v); update(); })));
+ state.rightsLevels.forEach(v => chips.push(makeChip(`Niveau ${v}`, () => { state.rightsLevels.delete(v); update(); })));
+ state.riskLevels.forEach(v => chips.push(makeChip(RISK_LABEL[v] || v, () => { state.riskLevels.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, sortNumeric, parseFn) {
+ let entries = [...counts.entries()];
+ if (sortNumeric) {
+ entries.sort((a, b) => Number(a[0]) - Number(b[0]));
+ } else {
+ 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 = parseFn ? parseFn(key) : 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.publishers.size || state.documentTypes.size || state.subjectAreas.size
+ || state.years.size || state.rightsLevels.size || state.riskLevels.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/dansk-viden-til-dansk-ai/mocks/js/views/upload.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/upload.js
new file mode 100644
index 0000000..a37f12b
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/upload.js
@@ -0,0 +1,396 @@
+import { el, clear, navigate, toast, formatSize } from "../util.js";
+import { auth } from "../auth.js";
+import { store, uid } from "../store.js";
+import { RIGHTS_LEVELS } from "../catalog.js";
+import { iconHtml, icon as iconNode } from "../icons.js";
+
+const DOC_TYPES = ["rapport", "analyse", "strategi", "vejledning", "evaluering", "hvidbog"];
+const DOC_TYPE_LABEL = {
+ rapport: "Rapport", analyse: "Analyse", strategi: "Strategi",
+ vejledning: "Vejledning", evaluering: "Evaluering", hvidbog: "Hvidbog"
+};
+
+const PERSONAL_DATA_OPTIONS = [
+ { value: "navne", label: "Indeholder navngivne personer", risk: "yellow" },
+ { value: "citater", label: "Indeholder citater fra borgere/medarbejdere", risk: "yellow" },
+ { value: "billeder", label: "Indeholder fotos af personer", risk: "yellow" },
+ { value: "smaa-grupper", label: "Statistik på små populationer (<10 personer)", risk: "red" },
+ { value: "saerlige", label: "Indeholder særlige kategorier af persondata (helbred, etnicitet m.m.)", risk: "red" }
+];
+
+const THIRD_PARTY_OPTIONS = [
+ { value: "billeder", label: "Stockfotos eller indkøbte billeder", risk: "yellow" },
+ { value: "data", label: "Datavisualiseringer fra ekstern leverandør", risk: "yellow" },
+ { value: "kort", label: "Kortmateriale med tredjepartsrettigheder", risk: "yellow" },
+ { value: "konsulent", label: "Hele eller dele udarbejdet af ekstern konsulent uden videreoverdragelse", risk: "red" }
+];
+
+export function render(root) {
+ const user = auth.currentUser();
+ if (!user) {
+ navigate("#/login");
+ return;
+ }
+ clear(root);
+
+ const state = { step: 1, file: null, draft: null };
+
+ // Page-level shell (title + alert) renders once, sits outside the step card.
+ root.appendChild(el("header", { class: "page-head" }, [
+ el("h1", { style: "margin:0 0 8px;" }, "Upload publikation"),
+ el("p", { class: "lede", style: "margin:0;" },
+ "Bidrag med en publikation fra din myndighed til kataloget. AI hjælper med metadata og resume — du bekræfter rettighederne.")
+ ]));
+
+ root.appendChild(el("aside", {
+ class: "alert alert-rights",
+ role: "note",
+ "aria-label": "Rettighedsansvar"
+ }, [
+ el("span", { class: "alert-mark", "aria-hidden": "true" }),
+ el("p", {},
+ "Du har som uploader ansvaret for, at din myndighed har rettighederne til at stille publikationen til rådighed for de valgte formål.")
+ ]));
+
+ const layout = el("div", { class: "upload-layout" });
+ const stepNav = el("nav", { class: "step-rail", "aria-label": "Trin i upload" });
+ 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: "Vælg fil", hint: "Træk eller vælg din PDF" },
+ { n: 2, title: "Gennemgang", hint: "Tjek AI-forslag og rettigheder" },
+ { n: 3, title: "Kvittering", hint: "Delelink til din publikation" }
+ ];
+ 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/pdf,.pdf,.doc,.docx,.odt",
+ style: "display:none;",
+ onchange: (e) => acceptFile(e.target.files[0])
+ });
+
+ const drop = el("div", {
+ class: "dropzone",
+ tabindex: "0",
+ role: "button",
+ "aria-label": "Vælg 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) acceptFile(f);
+ }
+ }, [
+ el("p", { html: iconHtml("document", { size: 36, stroke: 1.5 }) }),
+ el("p", { style: "font-weight:600;" }, "Træk filen hertil"),
+ el("p", { class: "muted" }, "eller klik for at vælge"),
+ input
+ ]);
+
+ card.appendChild(drop);
+ return card;
+ }
+
+ function acceptFile(file) {
+ if (!file) return;
+ state.file = file;
+ state.step = 2;
+ state.draft = null;
+ paint();
+ // Simulate AI processing
+ setTimeout(() => {
+ state.draft = simulateAiExtraction(file);
+ paint();
+ }, 1800);
+ }
+
+ 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 publikationen…"),
+ el("p", { class: "muted" }, `Læser "${state.file?.name || "fil"}" (${formatSize(state.file?.size || 0)}) 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 baseret på publikationens indhold. Ret efter behov, og vælg rettighedsniveau før udgivelse."));
+
+ const form = el("form", {
+ onsubmit: (e) => {
+ e.preventDefault();
+ const fd = new FormData(e.target);
+ const pub = buildPublication(d, fd, state.file);
+ store.addUpload(pub);
+ state.draft = pub;
+ state.step = 3;
+ paint();
+ toast("Publikation udgivet");
+ }
+ });
+
+ form.appendChild(el("div", { class: "form-grid" }, [
+ el("label", { class: "field" }, ["Titel", el("input", { type: "text", name: "title", value: d.title, required: true })]),
+ el("label", { class: "field" }, ["Undertitel", el("input", { type: "text", name: "subtitle", value: d.subtitle || "" })]),
+ el("label", { class: "field" }, ["Resume",
+ el("textarea", { name: "summary", required: true }, d.summary)
+ ]),
+ el("div", { class: "form-grid cols-2" }, [
+ el("label", { class: "field" }, ["Udgiver", el("input", { type: "text", name: "publisher", value: d.publisher, required: true })]),
+ el("label", { class: "field" }, ["Udgivelsesdato", el("input", { type: "date", name: "publishedAt", value: d.publishedAt })])
+ ]),
+ el("div", { class: "form-grid cols-2" }, [
+ el("label", { class: "field" }, [
+ "Dokumenttype",
+ el("select", { name: "documentType" },
+ DOC_TYPES.map(t => el("option", { value: t, selected: t === d.documentType }, DOC_TYPE_LABEL[t])))
+ ]),
+ el("label", { class: "field" }, ["Sprog",
+ el("select", { name: "language" }, [
+ el("option", { value: "da", selected: d.language === "da" }, "Dansk"),
+ el("option", { value: "en", selected: d.language === "en" }, "Engelsk")
+ ])
+ ])
+ ]),
+ el("label", { class: "field" }, [
+ "Emneord (komma-separeret)",
+ el("input", { type: "text", name: "keywords", value: (d.keywords || []).join(", ") })
+ ]),
+ el("div", { class: "form-grid cols-2" }, [
+ el("label", { class: "field" }, ["Fagområde (komma-separeret)",
+ el("input", { type: "text", name: "subjectAreas", value: (d.subjectAreas || []).join(", ") })]),
+ el("label", { class: "field" }, ["Målgruppe",
+ el("input", { type: "text", name: "targetAudience", value: d.targetAudience || "" })])
+ ]),
+ el("label", { class: "field" }, ["Geografisk omfang",
+ el("input", { type: "text", name: "geographicScope", value: d.geographicScope || "" })])
+ ]));
+
+ form.appendChild(el("hr", { class: "divider" }));
+ form.appendChild(el("h3", {}, "Rettighedsniveau"));
+ form.appendChild(el("p", { class: "muted" }, "Vælg det højeste niveau, din myndighed kan stå inde for. Niveauet styrer, om publikationen kan bruges til AI-træning."));
+
+ const rl = el("div", { class: "rights-list" });
+ RIGHTS_LEVELS.forEach(r => {
+ rl.appendChild(el("label", {}, [
+ el("input", { type: "radio", name: "rightsLevel", value: String(r.level), required: true, checked: r.level === d.rightsLevel }),
+ el("div", {}, [
+ el("div", { class: "rl-title" }, `${r.level}. ${r.title}`),
+ el("div", { class: "rl-desc" }, r.description)
+ ])
+ ]));
+ });
+ form.appendChild(rl);
+
+ form.appendChild(el("hr", { class: "divider" }));
+ form.appendChild(el("h3", {}, "Risikohensyn"));
+ form.appendChild(el("p", { class: "muted" }, "Marker forhold der gælder publikationen. Markeringer styrer den automatiske risikoklassifikation."));
+
+ const pdGroup = el("div", { class: "check-list" });
+ pdGroup.appendChild(el("strong", {}, "Persondata"));
+ PERSONAL_DATA_OPTIONS.forEach(o => {
+ const checked = (d.personalDataFlags || []).some(f => f.includes(o.value) || f === o.label);
+ pdGroup.appendChild(el("label", {}, [
+ el("input", { type: "checkbox", name: "personalDataFlags", value: o.label, "data-risk": o.risk, checked }),
+ el("span", {}, o.label)
+ ]));
+ });
+ form.appendChild(pdGroup);
+
+ const tpGroup = el("div", { class: "check-list mt-3" });
+ tpGroup.appendChild(el("strong", {}, "Tredjepartsindhold"));
+ THIRD_PARTY_OPTIONS.forEach(o => {
+ const checked = (d.thirdPartyContentFlags || []).some(f => f.includes(o.value) || f === o.label);
+ tpGroup.appendChild(el("label", {}, [
+ el("input", { type: "checkbox", name: "thirdPartyContentFlags", value: o.label, "data-risk": o.risk, checked }),
+ el("span", {}, o.label)
+ ]));
+ });
+ form.appendChild(tpGroup);
+
+ const riskPreview = el("p", { class: "muted mt-3" });
+ function updateRiskPreview() {
+ const risk = computeRisk(form);
+ const label = { green: "Grøn", yellow: "Gul", red: "Rød" }[risk];
+ riskPreview.innerHTML = `Automatisk risikoniveau: ${label}`;
+ }
+ form.addEventListener("change", updateRiskPreview);
+ updateRiskPreview();
+ form.appendChild(riskPreview);
+
+ form.appendChild(el("div", { class: "right mt-5" }, [
+ el("button", {
+ type: "button",
+ class: "btn btn-secondary",
+ onclick: () => { state.step = 1; state.file = null; paint(); },
+ html: `${iconHtml("arrowLeft")}Vælg anden fil`
+ }),
+ el("button", { type: "submit", class: "btn" }, "Udgiv publikation")
+ ]));
+ card.appendChild(form);
+ return card;
+ }
+
+ function renderReceipt() {
+ const pub = state.draft;
+ const card = el("section", { class: "card" });
+ card.appendChild(el("h2", {
+ html: `${iconHtml("check", { size: 22 })} Publikation udgivet`,
+ style: "display:flex; align-items:center; gap:8px; color: var(--color-ok);"
+ }));
+ card.appendChild(el("p", {}, `"${pub.title}" er nu tilgængelig i kataloget.`));
+ card.appendChild(el("div", { class: "muted text-sm mb-4" }, `Permalink: ${window.location.origin}${window.location.pathname}#/publication/${pub.id}`));
+ card.appendChild(el("div", { class: "row wrap" }, [
+ el("a", { class: "btn", href: `#/publication/${pub.id}` }, "Gå til publikation"),
+ el("button", {
+ class: "btn btn-secondary",
+ onclick: () => {
+ state.step = 1; state.file = null; state.draft = null; paint();
+ }
+ }, "Upload en til"),
+ el("a", { class: "btn btn-ghost", href: "#/search" }, "Tilbage til kataloget")
+ ]));
+ return card;
+ }
+}
+
+function computeRisk(form) {
+ const checked = [...form.querySelectorAll('input[type="checkbox"]:checked')];
+ if (checked.some(c => c.dataset.risk === "red")) return "red";
+ if (checked.some(c => c.dataset.risk === "yellow")) return "yellow";
+ return "green";
+}
+
+function buildPublication(draft, fd, file) {
+ const personalDataFlags = fd.getAll("personalDataFlags");
+ const thirdPartyContentFlags = fd.getAll("thirdPartyContentFlags");
+
+ // Compute risk from selected options (same logic as the live preview)
+ const PD_RISK = Object.fromEntries(PERSONAL_DATA_OPTIONS.map(o => [o.label, o.risk]));
+ const TP_RISK = Object.fromEntries(THIRD_PARTY_OPTIONS.map(o => [o.label, o.risk]));
+ const allRisks = [
+ ...personalDataFlags.map(f => PD_RISK[f] || "green"),
+ ...thirdPartyContentFlags.map(f => TP_RISK[f] || "green")
+ ];
+ const riskLevel = allRisks.includes("red") ? "red"
+ : allRisks.includes("yellow") ? "yellow" : "green";
+
+ return {
+ id: uid("pub"),
+ title: (fd.get("title") || "").trim(),
+ subtitle: (fd.get("subtitle") || "").trim(),
+ summary: (fd.get("summary") || "").trim(),
+ authors: draft.authors || [],
+ publisher: (fd.get("publisher") || "").trim(),
+ publishedAt: fd.get("publishedAt") || new Date().toISOString().slice(0, 10),
+ language: fd.get("language") || "da",
+ documentType: fd.get("documentType") || "rapport",
+ subjectAreas: splitList(fd.get("subjectAreas")),
+ keywords: splitList(fd.get("keywords")),
+ targetAudience: (fd.get("targetAudience") || "").trim(),
+ geographicScope: (fd.get("geographicScope") || "").trim(),
+ rightsLevel: Number(fd.get("rightsLevel") || 2),
+ riskLevel,
+ personalDataFlags,
+ thirdPartyContentFlags,
+ fileName: file?.name || draft.fileName,
+ fileSize: file?.size || draft.fileSize || 0,
+ mimeType: file?.type || "application/pdf",
+ uploadedBy: auth.currentUser()?.id ?? null,
+ uploadedAt: new Date().toISOString(),
+ source: "user"
+ };
+}
+
+function splitList(s) {
+ if (!s) return [];
+ return String(s).split(",").map(x => x.trim()).filter(Boolean);
+}
+
+/* Fake AI extraction — generates plausible metadata from the filename. */
+function simulateAiExtraction(file) {
+ const name = (file?.name || "publikation.pdf").replace(/\.[^.]+$/, "");
+ const cleaned = name.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
+ const titled = cleaned.replace(/\b\w/g, c => c.toUpperCase());
+
+ const lower = cleaned.toLowerCase();
+ const docType = /strateg/.test(lower) ? "strategi"
+ : /analyse/.test(lower) ? "analyse"
+ : /vejledning/.test(lower) ? "vejledning"
+ : /evaluering/.test(lower) ? "evaluering"
+ : /hvidbog/.test(lower) ? "hvidbog"
+ : "rapport";
+
+ const guessedSubjects = [];
+ const subjectMap = {
+ klima: "klima", energi: "energi", sundhed: "sundhed", uddannelse: "uddannelse",
+ digital: "digitalisering", miljø: "miljø", miljo: "miljø", transport: "transport",
+ bolig: "bolig", ai: "ai", data: "data", ældre: "ældre", aeldre: "ældre"
+ };
+ for (const [k, v] of Object.entries(subjectMap)) {
+ if (lower.includes(k) && !guessedSubjects.includes(v)) guessedSubjects.push(v);
+ }
+ if (!guessedSubjects.length) guessedSubjects.push("offentlig forvaltning");
+
+ const user = auth.currentUser();
+ const publisher = user?.organization || "Min Myndighed";
+
+ const summary = `Denne publikation præsenterer ${cleaned ? cleaned.toLowerCase() : "et fagligt emne"} i en dansk offentlig kontekst. Den indeholder analyser, anbefalinger og baggrundsmateriale udarbejdet af ${publisher} og henvender sig til fagprofessionelle, beslutningstagere og borgere. Indholdet kan indgå i videre arbejde med digitalisering, videndeling og offentlig formidling.`;
+
+ return {
+ title: titled || "Ny publikation",
+ subtitle: "",
+ summary,
+ authors: [publisher],
+ publisher,
+ publishedAt: new Date().toISOString().slice(0, 10),
+ language: "da",
+ documentType: docType,
+ subjectAreas: guessedSubjects,
+ keywords: guessedSubjects.slice(0, 4),
+ targetAudience: "Fagprofessionelle, borgere",
+ geographicScope: "Danmark",
+ rightsLevel: 3,
+ personalDataFlags: [],
+ thirdPartyContentFlags: [],
+ fileName: file?.name || "publikation.pdf",
+ fileSize: file?.size || 0,
+ mimeType: file?.type || "application/pdf"
+ };
+}
diff --git a/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/uploads.js b/docs/public/projects/dansk-viden-til-dansk-ai/mocks/js/views/uploads.js
new file mode 100644
index 0000000..44ae524
--- /dev/null
+++ b/docs/public/projects/dansk-viden-til-dansk-ai/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 { renderPubCard } from "./_pub-card.js";
+
+export function render(root) {
+ const user = auth.currentUser();
+ if (!user) { navigate("#/login"); return; }
+
+ function paint() {
+ clear(root);
+ root.appendChild(el("h1", {}, "Mine uploads"));
+ root.appendChild(el("p", { class: "muted" },
+ "Publikationer du har uploadet til kataloget. Du kan trække en upload tilbage — den fjernes også fra andres favoritter og samlinger."));
+
+ const mine = store.uploadsByUser(user.id)
+ .sort((a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt));
+
+ if (!mine.length) {
+ root.appendChild(el("div", { class: "empty card mt-4" }, [
+ el("h2", {}, "Du har ikke uploadet noget endnu"),
+ el("p", { class: "muted" }, "Bidrag med en publikation fra din myndighed for at gøre den søgbar i kataloget."),
+ el("a", { class: "btn", href: "#/upload" }, "Upload publikation")
+ ]));
+ return;
+ }
+
+ const list = el("div", { class: "results-list mt-4" });
+ mine.forEach(p => list.appendChild(renderPubCard(p, {
+ extraAction: el("button", {
+ class: "btn btn-danger btn-sm",
+ onclick: () => {
+ if (!confirm(`Slet uploaden "${p.title}"?\n\nPublikationen fjernes fra kataloget og alle samlinger.`)) return;
+ store.deleteUpload(p.id);
+ toast("Upload slettet");
+ paint();
+ }
+ }, "Slet")
+ })));
+ root.appendChild(list);
+ }
+
+ paint();
+}