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 + + + + + + + + + + + + +
+
+
+ + + +
+ + + + + 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, viden­deling 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(); +}