// KOFA multi-corpus SPA — React via CDN + Babel-standalone.
// Components lifted from Presedens v3, adapted to real /api/* JSON responses.
// Inline styles preserved from the prototype to keep the visual diff tight.
const { useState, useEffect, useRef, useReducer, useMemo, useCallback } = React;
const Api = window.KofaApi;
// Surface any uncaught error directly on the page so we never get the
// silent "white screen" failure mode.
window.addEventListener('error', (e) => {
const root = document.getElementById('root');
if (root) {
root.innerHTML = `
);
}
// ──────────────────────────────────────────────────────────────────────────
// Project view (replaces search + result list when a project is selected)
// ──────────────────────────────────────────────────────────────────────────
function ProjectView({ project, items, loading, corpora, selectedKey, strings, onBack, onSelect, onRemoveItem }) {
if (!project) {
return (
);
}
const corpusById = (cid) => corpora.find(c => c.id === cid);
// Group items by query_text. Null/empty becomes the "Uten spørsmål" bucket.
// Each group is sorted newest-first; groups themselves are ordered by their
// most-recent item's added_at desc, with the null bucket pushed to the end.
const grouped = (() => {
if (!items) return [];
const buckets = new Map();
for (const it of items) {
const key = (it.query_text && String(it.query_text).trim()) || '';
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(it);
}
// Each bucket: sort newest first
for (const arr of buckets.values()) {
arr.sort((a, b) => String(b.added_at || '').localeCompare(String(a.added_at || '')));
}
const arr = [...buckets.entries()].map(([key, gItems]) => ({
key,
query: key || null,
items: gItems,
latest: gItems.length ? gItems[0].added_at : '',
}));
arr.sort((a, b) => {
// Push null-query group to the end regardless of timestamp.
if (!a.query && b.query) return 1;
if (a.query && !b.query) return -1;
return String(b.latest).localeCompare(String(a.latest));
});
return arr;
})();
return (
{group.items.map(it => {
const cobj = corpusById(it.corpus_id);
// KOFA/KLFU paragraphs (avsnitt) have no standalone detail view —
// the panel shows the parent case. Their saved `case_id` is the
// paragraph pid, which the kofa-kind /api/detail can't resolve (it
// keys on case-id), so select by `parent_case_id` instead —
// mirroring how a paragraph hit in the search results opens its
// case (onSelect(hit.case_id)). Only kofa-kind corpora resolve
// detail by case-id; pid-resolved corpora (Lovdata, EU, Høyesterett,
// …) must keep using `case_id` (the pid) even when a parent exists.
const detailId = (it.item_kind === 'paragraph' && it.parent_case_id && cobj && cobj.kind === 'kofa')
? it.parent_case_id
: it.case_id;
const key = `${it.corpus_id}-${detailId}`;
const active = selectedKey === key;
const onClick = () => onSelect(detailId, { _corpus_source: it.corpus_id, case_id: detailId, title: it.case_title });
const onTrash = (e) => { e.stopPropagation(); onRemoveItem(it.id); };
return it.item_kind === 'paragraph'
?
: ;
})}
))}
);
}
// Sak: title + corpus mark + horizontal row of metadata pills (Klager, Innklaget,
// Saken gjelder, Avgjørelse, Registrert).
// ──────────────────────────────────────────────────────────────────────────
// _verdictTone — map a v2 KLFU signal value to a (bg, color) badge palette.
// Green = klager won / condition met; red = klager lost / condition not met;
// amber = mixed / delvist / unresolved. Used in CaseCard + the detail-modal
// Påstande section.
// ──────────────────────────────────────────────────────────────────────────
function _verdictTone(value) {
const v = String(value || '').toLowerCase();
if (['medhold', 'complainant_won', 'opfyldt', 'tildelt', 'klager'].includes(v)) {
return { bg: 'var(--green-bg)', color: 'var(--green)' };
}
if (['ikke_medhold', 'complainant_lost', 'ikke_opfyldt', 'ikke_tildelt', 'indklagede',
'afvises', 'hjemvises'].includes(v)) {
return { bg: 'var(--red-bg)', color: 'var(--red)' };
}
if (['delvist', 'mixed', 'udsat'].includes(v)) {
return { bg: 'var(--amber-bg)', color: 'var(--amber)' };
}
return { bg: 'var(--surface-2)', color: 'var(--text-2)' };
}
// Resolve a raw enum value (e.g. "medhold", "ikke_tildelt") to its display
// label by walking ``corpus.multi_select_filters`` for the filter declaring
// ``labelPrefix`` and reading its ``value_labels[rawValue]``. Returns the
// raw value as fallback. Single source of truth — adding a new filter value
// only requires editing the filter declaration in country_config.py.
function lookupFilterLabel(corpus, labelPrefix, rawValue) {
if (!corpus || !labelPrefix) return rawValue;
const ms = (corpus.multi_select_filters || []).find(m => m.label_prefix === labelPrefix);
return (ms && ms.value_labels && ms.value_labels[rawValue]) || rawValue;
}
// Boolean: does the corpus carry a filter with the given label_prefix?
// Used by CaseCard to decide whether a corpus-specific badge (Udfald, Ops.
// virkning, Fumus, Type) belongs on a saved item — replaces the old hack
// of probing ``strings[some_well_known_key]`` as a sentinel.
function corpusHasFilter(corpus, labelPrefix) {
if (!corpus || !labelPrefix) return false;
return (corpus.multi_select_filters || []).some(m => m.label_prefix === labelPrefix);
}
function CaseCard({ it, cobj, active, strings, onClick, onTrash }) {
const meta = it.metadata || {};
// Pick a curated set of pills to render. Different corpora use different keys.
const pills = [];
const pushIf = (label, value) => { if (value) pills.push({ label, value: String(value) }); };
pushIf(strings.projects_metadata_complainant, meta['Klager'] || meta['klager']);
pushIf(strings.projects_metadata_respondent, meta['Innklaget'] || meta['indklagede']);
pushIf(strings.projects_metadata_subject, meta['Saken gjelder'] || meta['title']);
pushIf(strings.projects_metadata_decision, meta['Avgjørelse'] || meta['Type']);
pushIf(strings.projects_metadata_registered, meta['Registrert inn'] || meta['date']);
// Colour-coded v2 verdict badges for KLFU saved items. The label-prefix
// decides which filter declaration to consult; we render a badge only when
// the corpus actually carries that filter (cross-country works because the
// filter list rides on the corpus descriptor). Display labels come from
// ``filter.value_labels`` via lookupFilterLabel — same source the dropdown
// uses.
const verdictBadges = [];
const pushBadge = (badgeLabel, value, prefix) => {
if (!value || !badgeLabel) return;
verdictBadges.push({
label: badgeLabel,
display: lookupFilterLabel(cobj, prefix, value),
tone: _verdictTone(value),
});
};
pushBadge(corpusHasFilter(cobj, 'outcome') ? 'Udfald' : null, meta['outcome_summary'], 'outcome');
pushBadge(corpusHasFilter(cobj, 'ops') ? 'Ops. virkning' : null, meta['opsættende_virkning'], 'ops');
pushBadge(corpusHasFilter(cobj, 'fumus') ? 'Fumus' : null, meta['fumus_boni_juris'], 'fumus');
pushBadge(corpusHasFilter(cobj, 'decision') ? 'Type' : null, meta['decision_type'], 'decision');
// Boolean flags — single-tag pills, only when true.
const flagBadges = [];
if (meta['is_annulled']) flagBadges.push('Annulleret');
if (meta['has_sanction']) flagBadges.push('Sanktion');
if (meta['damages_awarded']) flagBadges.push('Erstatning');
if (meta['is_stemmeflertallet']) flagBadges.push('Stemmeflertal');
return (
);
}
// Avsnitt: title + corpus mark + the actual paragraph text in a quoted blockquote.
function ParagraphCard({ it, cobj, active, strings, onClick, onTrash }) {
const text = (it.paragraph_text || '').trim();
return (
,
no: ,
dk: ,
de: ,
eu: ,
klfu: ,
};
return marks[kind] || marks.all;
};
// ──────────────────────────────────────────────────────────────────────────
// Source tabs
// ──────────────────────────────────────────────────────────────────────────
function SourceTabs({ corpora, active, onSelect, totals }) {
// Horizontally scrollable strip of source-cards. The scroller keeps each
// card uniform width (140px) so 7 home corpora fit on a 1100-wide layout
// and additional corpora overflow into a horizontal scroll instead of
// wrapping. Edge fades hint at off-screen content.
const scrollRef = useRef(null);
const [edge, setEdge] = useState({ left: false, right: false });
const recalcEdges = () => {
const el = scrollRef.current;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
setEdge({ left: el.scrollLeft > 4, right: el.scrollLeft < max - 4 });
};
useEffect(() => {
recalcEdges();
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', recalcEdges, { passive: true });
window.addEventListener('resize', recalcEdges);
return () => {
el.removeEventListener('scroll', recalcEdges);
window.removeEventListener('resize', recalcEdges);
};
}, [corpora.length]);
const scrollBy = (delta) => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({ left: el.scrollLeft + delta, behavior: 'smooth' });
};
// When the active tab changes, ensure it's visible in the scroller.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const node = el.querySelector(`[data-corpus-id="${active}"]`);
if (!node) return;
const elBox = el.getBoundingClientRect();
const nodeBox = node.getBoundingClientRect();
if (nodeBox.left < elBox.left + 24 || nodeBox.right > elBox.right - 24) {
node.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' });
}
}, [active]);
return (
{edge.left && (
)}
{edge.right && (
)}
{corpora.map(c => {
const isActive = active === c.id;
const total = totals[c.id];
return (
);
})}
);
}
// ──────────────────────────────────────────────────────────────────────────
// Result crossfade wrapper — fades content on view mode change
// ──────────────────────────────────────────────────────────────────────────
function ResultFade({ viewMode, render }) {
const [displayedMode, setDisplayedMode] = useState(viewMode);
const [phase, setPhase] = useState('visible');
useEffect(() => {
if (viewMode === displayedMode) { setPhase('visible'); return; }
setPhase('fading-out');
const t = setTimeout(() => {
setDisplayedMode(viewMode);
// Use rAF to ensure the DOM updates with new content at opacity 0
// before we start the fade-in transition
requestAnimationFrame(() => {
requestAnimationFrame(() => setPhase('visible'));
});
}, 180);
return () => clearTimeout(t);
}, [viewMode, displayedMode]);
return (
{render(displayedMode)}
);
}
// ──────────────────────────────────────────────────────────────────────────
// View mode toggle — slides between paragraph and table views
// ──────────────────────────────────────────────────────────────────────────
function ViewModeToggle({ mode, onChange, strings, corpus }) {
// PR C: N-segment toggle driven by corpus.view_modes when present. Lovdata
// and Retsinfo ship a 3-segment array (Ledd / Paragraf / Dokument); KOFA /
// KLFU and other corpora fall through to the existing 2-way Avsnitt / Sak.
//
// Icon mapping:
// 3-way (Lovdata-kind): Ledd → list · Paragraf → § symbol · Dokument → boxed-bars
// 2-way (KOFA-kind): Avsnitt → list · Sak → table grid
const cornerIcon = (i, n) => {
if (n === 3) {
if (i === 0) return 'list';
if (i === 1) return 'paragraph';
return 'document';
}
return i === 0 ? 'list' : 'table';
};
let tabs;
if (Array.isArray(corpus?.view_modes) && corpus.view_modes.length > 0) {
tabs = corpus.view_modes.map((vm, i, arr) => ({
id: vm.value,
label: vm.label,
icon: cornerIcon(i, arr.length),
}));
} else {
const paragraphLabel = strings.paragraph_tab || 'Avsnitt';
const caseLabel = strings.case_tab || 'Sak';
tabs = [
{ id: 'paragraph', label: paragraphLabel, icon: 'list' },
{ id: 'case', label: caseLabel, icon: 'table' },
];
}
let activeIdx = tabs.findIndex(t => t.id === mode);
if (activeIdx < 0) activeIdx = 0;
// Fixed per-segment width so all toggle buttons are equal regardless of
// label length. 120 px comfortably fits the widest label we expose today
// ("Dokument" / "Paragraf" + icon + gap). Pill positioning uses the same
// px value so it aligns exactly with the active button — the previous
// % math drifted because the container's 3 px padding wasn't subtracted
// from the percentage base.
const SEGMENT_PX = 120;
const PAD_PX = 3;
return (
{/* Sliding background pill. Animate `transform` instead of `left`:
`transform` composites on the GPU and avoids the layout/paint
cycle that `left` triggers on every frame — the earlier `left`
version stuttered visibly on the Ledd→§ transition because the
same frame swapped the (much heavier) result-list DOM. */}
{tabs.map(t => {
const a = mode === t.id;
return (
);
})}
);
}
// ──────────────────────────────────────────────────────────────────────────
// Search bar with rotating placeholder
// ──────────────────────────────────────────────────────────────────────────
function SearchBar({ query, onQuery, placeholderSamples, searchMode, onSetSearchMode, lockMode, rerank, onSetRerank, rerankEnabled, dark, locale }) {
const [focused, setFocused] = useState(false);
const [idx, setIdx] = useState(0);
const [popOpen, setPopOpen] = useState(false);
const anchorRef = useRef(null);
// Auto-grow the query box: a long (paragraph-length) query wraps onto new
// lines and the box grows in height up to a cap, then scrolls — rather than
// staying one line and scrolling sideways.
const taRef = useRef(null);
const TA_MAX = 168;
const autosize = (el) => {
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, TA_MAX) + 'px';
};
useEffect(() => { autosize(taRef.current); }, [query]);
useEffect(() => {
if (query || focused) return;
const id = setInterval(() => setIdx(i => (i + 1) % placeholderSamples.length), 5250);
return () => clearInterval(id);
}, [query, focused, placeholderSamples.length]);
const placeholder = placeholderSamples[idx];
const _modeOpts = searchModeOptions(locale);
const activeOpt = _modeOpts.find(o => o.value === searchMode) || _modeOpts[0];
return (
{popOpen && onSetSearchMode && (
{
if (m && m !== searchMode) onSetSearchMode(m);
setPopOpen(false);
}}
/>
)}
{!query && (
{placeholder}
{focused && (
Tab ↹
)}
)}
{query && (
)}
);
}
// ──────────────────────────────────────────────────────────────────────────
// Date-range quick chips — rendered inline below the date inputs in the
// Saksdata filter section. Only "date_range" kind presets from
// country_config.quick_chip_presets are surfaced here.
// ──────────────────────────────────────────────────────────────────────────
function DateRangePresets({ primary, value, onChange }) {
const presets = ((primary && primary.quick_chip_presets) || []).filter(p => p.kind === 'date_range');
if (presets.length === 0) return null;
const todayIso = () => new Date().toISOString().slice(0, 10);
const isoDaysAgo = (days) => {
const d = new Date();
d.setDate(d.getDate() - days);
return d.toISOString().slice(0, 10);
};
const isActive = (p) => value.start_date === isoDaysAgo(p.days_back) && value.end_date === todayIso();
const toggle = (p) => {
if (isActive(p)) onChange({ ...value, start_date: '', end_date: '' });
else onChange({ ...value, start_date: isoDaysAgo(p.days_back), end_date: todayIso() });
};
return (
);
}
function DocSlugFilter({ corpus, value, onChange }) {
// Slugs are pulled from the parquet on demand. Cached per-corpus on the
// backend, so this fetch is cheap. While loading, render a hint.
const [opts, setOpts] = useState(null);
const [filter, setFilter] = useState('');
useEffect(() => {
let alive = true;
setOpts(null);
Api.getDocSlugs(corpus).then(r => {
if (!alive) return;
setOpts(r.options || []);
}).catch(() => { if (alive) setOpts([]); });
return () => { alive = false; };
}, [corpus]);
if (opts === null) {
return
Laster dokumenter…
;
}
if (!opts.length) {
return
Ingen dokumenter
;
}
const f = filter.trim().toLowerCase();
const visible = f
? opts.filter(o => o.label.toLowerCase().includes(f) || o.value.toLowerCase().includes(f))
: opts;
// Always include selected items even if they don't match the filter, so users can
// see and remove an active selection.
const selectedNotVisible = opts.filter(o => value.includes(o.value) && !visible.includes(o));
const list = [...selectedNotVisible, ...visible];
return (
);
}
// Merged avsnitt — especially KLFU narrative sections (sagsfremstilling /
// anbringender) with no native paragraph numbering to split on — can run to
// several thousand words. Show a generous amount inline (we have room), then
// clamp the rest behind a "Vis mer" toggle so the result list stays scannable
// without losing access to the full text. Only clamps when the body actually
// overflows the threshold; the toggle stops propagation so it doesn't trigger
// the card's open-detail onClick. (This is option B of the chunking-size work;
// the data-side max-size cap — option A — lands separately.)
function CollapsibleText({ text, textStyle, clampPx = 460, moreLabel = 'Vis mer', lessLabel = 'Vis mindre' }) {
const ref = useRef(null);
const [overflowing, setOverflowing] = useState(false);
const [expanded, setExpanded] = useState(false);
React.useLayoutEffect(() => {
const el = ref.current;
// scrollHeight reports the full content height even while clamped, so this
// stays correct across re-measures. +24px buffer avoids a toggle that
// reveals only a single extra line.
if (el) setOverflowing(el.scrollHeight > clampPx + 24);
}, [text, clampPx]);
const clamped = overflowing && !expanded;
return (
{text}
{clamped && (
)}
{overflowing && (
)}
);
}
function KofaParagraphCard({ hit, onClick, selected, openPdf, sectionLabels, isCrossCountry, corpus, projects, query, onSaveHit, onCreateProject, strings }) {
const md = hit.metadata || {};
const sectionLabel = sectionLabels[hit.section] || hit.section || '';
// Cross-country corpora can render the home-style shaded PDF when their
// data is dual-mirrored to the home blob (KLFU on the NO container, KOFA
// on the DK container — both ship full case parquet + faiss + polygon
// meta + PDFs alongside the home primary). `corpus.supports_pdf` signals
// whether the bundle has the PDFs available. Cross-country corpora
// without mirrored PDFs (Supreme Court today) still fall back to the
// upstream link.
const useInAppPdf = !!corpus?.supports_pdf;
const handlePdf = (e) => {
e.stopPropagation();
if (isCrossCountry && !useInAppPdf && hit.pdf_url) {
window.open(hit.pdf_url, '_blank', 'noopener,noreferrer');
return;
}
// Pass the clicked avsnitt's section so the shaded PDF always includes it
// (unioned with the user's section filter — see the PdfModal mount).
if (openPdf) openPdf(hit.case_id, hit.page_nr, isCrossCountry ? corpus?.id : undefined, hit.section);
};
const pdfTitle = (isCrossCountry && !useInAppPdf) ? 'Åpne kilde-PDF (ny fane)' : 'Åpne PDF';
return (
{md.klager && {md.klager} mot {md.innklaget || ''}}
);
}
function KofaCaseTable({ rows, onSelect, onOpenPdf, selectedId, columns, isCrossCountry, corpus }) {
// See KofaParagraphCard.handlePdf for the cross-country in-app-vs-upstream
// rationale — same gate applies to the row-level "Åpne PDF" button.
const useInAppPdf = !!corpus?.supports_pdf;
return (
{columns.map(c => (
{c.label}
))}
PDF
{rows.map((r, i) => {
const id = r.case_id || r[columns[0].key];
const sel = id === selectedId;
const rowBg = sel ? 'var(--accent-bg)' : (i % 2 === 0 ? 'var(--surface)' : 'var(--surface-2)');
return (
onSelect(r)} style={{
cursor: 'pointer',
background: rowBg,
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = i % 2 === 0 ? 'var(--surface)' : 'var(--surface-2)'; }}>
{columns.map(c => {
const v = r[c.key];
if (c.key === columns[0].key) {
// First column carries the case id (Saksnummer / J.nr.).
// When empty and a column-level placeholder is declared
// (e.g. em-dash for KLFU rows where the API never exposed
// a real jnr), render it muted so users can see it's an
// intentional gap, not a render bug.
const isEmpty = v == null || v === '';
if (isEmpty && c.empty_placeholder) {
return
{/* KOFA Innkommet / Trukket / withdrawn / dismissed cases (and
most KLFU rulings) have no scraped document — pdf_id is empty.
The cross-country path relies on the foreign pdf_url instead.
Show a muted "–" when neither is available (no button → can't
open a 404), matching the table's empty-cell placeholder. */}
{((isCrossCountry && r.pdf_url) || (!isCrossCountry && r.pdf_id)) ? (
) : (
–
)}
);
})}
);
}
const tdCell = { padding: '10px 8px', borderBottom: '1px solid var(--border-light)' };
function GenericTable({ rows, columns, corpus, onSelect, selectedId, urlField, onOpenViewer, locale }) {
// Lovdata sak-mode gets a special Åpne column that opens the LovdataViewer
// modal at the top hit. Same right-aligned slot as KOFA's PDF column.
const showViewerCol = !!onOpenViewer; // gated upstream by corpus.supports_document_viewer
const isLovdata = corpus?.kind === 'lovdata';
// Accent (blue) on the first column is meaningful only when it's a real
// case identifier (Saksnummer / case number) — those are the rows the
// user wants to click. For Lovdata chapters, DFØ-veileder titles, EU
// directive titles, commentary book titles, etc. the first column is
// descriptive text, and the blue tint reads as a false-affordance link.
const CASE_ID_KEYS = new Set(['case_no', 'case_id', 'Saksnummer', 'jnr', 'sagsnr']);
const firstColIsCaseId = columns.length > 0 && CASE_ID_KEYS.has(columns[0].key);
return (
{columns.map(c => (
{c.label}
))}
{showViewerCol &&
Åpne
}
{!showViewerCol && urlField &&
Kilde
}
{rows.map((r, i) => {
const id = r.pid || r[columns[0].key];
const sel = id === selectedId;
return (
onSelect(r)} style={{
cursor: 'pointer',
background: sel ? 'var(--accent-bg)' : (i % 2 === 0 ? 'var(--surface)' : 'var(--surface-2)'),
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = i % 2 === 0 ? 'var(--surface)' : 'var(--surface-2)'; }}>
{columns.map(c => {
const v = r[c.key];
if (c.key === columns[0].key) {
// Lovdata sak-mode: column-0 is the synthesized _group_label
// (chapter title or "§ N"). Subtitle line shows hit count +
// _group_sublabel (part_title for chapters, paragraph_title
// for §s in anskaffelsesloven).
if (isLovdata && c.key === '_group_label') {
const hits = r._hit_count;
const sub = r._group_sublabel;
const subBits = [];
if (hits != null) subBits.push(`${hits} ${locale === 'de' ? 'Treffer' : 'treff'}`);
if (sub) subBits.push(String(sub));
return (
);
}
function formatCellValue(v, c) {
if (v == null || v === '') return c.empty_placeholder || '';
if (c.is_date) {
try { const d = new Date(v); if (!isNaN(d)) return d.toLocaleDateString('nb-NO'); } catch (_) {}
}
return String(v);
}
function LovCard({ hit, onClick, selected, corpus, onOpenViewer }) {
// Url field varies between Lovdata (lovdata_url) and Retsinformation
// (retsinfo_url). Title for the source link reflects the source name so
// the affordance reads correctly cross-corpus.
const urlField = (corpus && corpus.url_field) || 'lovdata_url';
const url = hit[urlField] || hit.lovdata_url || hit.retsinfo_url;
const sourceLabel = corpus?.id === 'retsinfo' ? 'Åbn på Retsinformation' : 'Åpne på Lovdata';
// Retsinformation parquet uses doc_title_da; Lovdata uses doc_title.
const docTitle = hit.doc_title || hit.doc_title_da || hit.doc_slug;
// Heading is retsinfo's "Overskrift"; lovdata uses paragraph_title. Both
// sit visually in the same slot beside the §.
const subtitle = hit.paragraph_title || hit.heading;
// Chapter breadcrumb: "kap. 8 › § 8-1". Lovdata supplies chapter_title; retsinfo
// exposes part_no/part_title for top-level Del. Only render when non-empty so
// anskaffelsesloven (which is unchaptered) doesn't show a stray "›".
const chapterRaw = (hit.chapter_title || '').toString().trim();
const partRaw = (hit.part_title || '').toString().trim();
const breadcrumb = chapterRaw || partRaw;
// Retsinfo: put the full locator ("§ 18, nr. 3") in the bold slot, not just
// "§ 18" — the stk./nr. is the useful part. Derive it by stripping the (often
// very long, e.g. bekendtgørelse) doc-title prefix off the citation.
const docTitleDa = (hit.doc_title_da || '').toString();
const locator = (corpus?.id === 'retsinfo' && hit.citation && docTitleDa && hit.citation.startsWith(docTitleDa))
? (hit.citation.slice(docTitleDa.length).trim() || hit.paragraph || '§')
: (hit.paragraph || '§');
return (
{breadcrumb && (
{breadcrumb}
)}
{locator}
{subtitle && {subtitle}}
{docTitle}
{onOpenViewer && (
)}
{/* External source link shows ONLY when there is no in-app viewer.
When a viewer exists (lovdata + retsinfo — Phase 1b shipped), the
viewer modal header already carries the prominent source pill and
the DetailPanel keeps the link, so a second card-level icon is a
confusing duplicate of the "Åpne dokument" button. */}
{url && !onOpenViewer && (
e.stopPropagation()} style={{ ...iconBtn, color: 'var(--text-3)', textDecoration: 'none' }} title={sourceLabel}>
)}
{hit.citation &&
{hit.citation}
}
);
}
function EuDirectiveCard({ hit, onClick, selected }) {
const isRecital = hit.unit_kind === 'recital';
const isRegulation = hit._doc_kind === 'regulation' || hit._corpus_source === 'eu_regulations';
// is_consolidated arrives as Python bool → JSON true/false; some parquets may
// serialize as 1/0 or string. Normalize to boolean.
const isConsolidated = hit.is_consolidated === true || hit.is_consolidated === 1
|| hit.is_consolidated === 'true' || hit.is_consolidated === 'True';
return (
);
}
function DfoVeilederCard({ hit, onClick, selected }) {
// section_path comes through the parquet as a Python list dumped to a string
// ("['Veileder i konkurransepreget dialog' 'Innholdsfortegnelse']"). Strip
// the noise so the card shows a clean breadcrumb.
const breadcrumb = (() => {
const raw = (hit.section_path || '').toString().trim();
if (!raw) return '';
const cleaned = raw.replace(/^\[|\]$/g, '').replace(/'/g, '"');
const parts = cleaned.match(/"([^"]+)"/g);
if (!parts) return raw;
return parts.map(s => s.replace(/"/g, '')).filter(Boolean).join(' › ');
})();
return (
);
}
// Resolve the two text variants for an EU court hit. Cards default to the
// English version when one is available — the source-language original is
// reachable via the per-card "Vis original" toggle.
function euCourtTextVariants(hit) {
const hitIsEnglish = hit.language_code === 'EN';
const pairedExists = hit._paired_text != null && hit._paired_language_code != null;
const pairedIsEnglish = pairedExists && hit._paired_language_code === 'EN';
let englishText = null;
let englishSource = null; // language we translated FROM (when the English text is a machine translation)
let originalText = null; // source-language text, if any
let originalLang = null;
if (hitIsEnglish) {
englishText = hit.text;
englishSource = hit.is_translation ? (hit.source_language || null) : null;
if (pairedExists && !pairedIsEnglish) {
originalText = hit._paired_text;
originalLang = hit._paired_language_code;
}
} else {
originalText = hit.text;
originalLang = hit.language_code || null;
if (pairedIsEnglish) {
englishText = hit._paired_text;
englishSource = originalLang;
}
}
return { englishText, englishSource, originalText, originalLang };
}
// Section-code → display-label mapping for Høyesterett / Højesteret cards.
// Section codes are shared NO/DK by design (set in the cleaners): i / b / a / kv / k.
// The two countries differ only in the rendered Danish/Norwegian phrasing.
const HOYESTERETT_SECTION_LABELS = {
no: {
i: 'Innledning',
b: 'Bakgrunn',
a: 'Anførsler',
kv: 'Høyesteretts vurdering',
k: 'Konklusjon',
},
dk: {
i: 'Indledning',
b: 'Sagsfremstilling',
a: 'Anbringender',
kv: 'Højesterets begrundelse',
k: 'Konklusion',
},
};
// Card for Norwegian Høyesterett + Danish Højesteret rulings. The two share a
// `kind: "hoyesterett"` corpus dict so this component renders both, switching
// labels via `corpus.mark` ("no" | "dk"). Designed to mirror the visual weight
// of EuCourtCard so the rail of court-decision corpora reads consistently.
function HoyesterettCard({ hit, onClick, selected, corpus }) {
const isDanish = corpus?.mark === 'dk';
const labels = isDanish ? HOYESTERETT_SECTION_LABELS.dk : HOYESTERETT_SECTION_LABELS.no;
const sectionLabel = labels[hit.section] || hit.section || '';
const caseIdLabel = isDanish ? 'Sagsnr' : 'Saksnr';
const chamberLabel = (() => {
const raw = (hit.chamber || '').toString().toLowerCase();
if (!raw) return '';
if (isDanish) {
if (raw.startsWith('civil')) return 'Civilret';
if (raw.startsWith('straf')) return 'Strafferet';
} else {
if (raw === 'sivil') return 'Sivil';
if (raw === 'straff') return 'Straff';
}
return raw.charAt(0).toUpperCase() + raw.slice(1);
})();
const sourceLabel = isDanish ? 'Åbn på domstol.dk' : 'Åpne på domstol.no';
return (
)}
{/* EU court catchwords — the structured per-case headnote written
by the Court's drafters (dash-separated taxonomy of the legal
issues at stake). Backfilled from the first paragraph chunk
per (doc, lang, is_translation) group; only ~27% of judgments
carry classic catchwords (older OPOCE format), modern ones
open with a narrative sentence instead and get no block. */}
{isEuCourt && d.catchwords && (
);
}
// Which sections the shaded PDF should highlight (home primary only). The
// backend reads `section_*` query params; we send the union of (a) the user's
// section-filter selection — or the config default if untouched — and (b) the
// section of the avsnitt they opened the PDF from. Cross-country corpora ignore
// section flags server-side (their shading is driven by similarity), so we send
// none. Returns a `{code: true}` map, or null when no flags should be sent.
function pdfShadeSections(state, primary) {
const open = state.pdfOpen;
if (!open || open.corpusId) return null;
const cfgSections = (primary && primary.sections) || [];
const touched = state.filters && state.filters.sections_touched === true;
// Emit an EXPLICIT boolean for every section. The backend defaults any
// *omitted* section to its config default (kv/ku are default:true), so
// sending only the on-sections would silently re-enable the board section —
// exactly the "always Klagenemndas vurdering" bug. Send false too.
const sel = {};
cfgSections.forEach(s => {
sel[s.code] = touched ? ((state.filters.sections || {})[s.code] === true) : (s.default === true);
});
// Always include the section of the avsnitt the PDF was opened from.
if (open.section) sel[open.section] = true;
return sel;
}
function PdfModal({ caseId, page, query, corpusId, sections, onClose, strings }) {
// Load the shaded PDF via fetch (not a blind form-POST-into-iframe) so we can
// detect a 404/500 — a missing PDF blob — and show a graceful message instead
// of rendering the backend's error page inside the viewer. `semantic_query`
// stays in the POST body; `corpus` + `section_*` ride the query string (the
// backend reads section flags from request.query_params). `#page=N` on the
// resulting blob: URL is honoured natively by the browser PDF viewer.
const [status, setStatus] = useState('loading'); // 'loading' | 'ok' | 'error'
const [src, setSrc] = useState(null);
const pageFragment = page ? `#page=${page}` : '';
const action = (() => {
const params = new URLSearchParams();
if (corpusId) params.set('corpus', corpusId);
if (!corpusId && sections) {
// Explicit true/false per section — see pdfShadeSections.
Object.keys(sections).forEach(code => params.set(`section_${code}`, sections[code] ? 'true' : 'false'));
}
const qs = params.toString();
return `/shaded_pdf/${encodeURIComponent(caseId)}${qs ? `?${qs}` : ''}`;
})();
useEffect(() => {
let cancelled = false;
let objUrl = null;
const fd = new FormData();
fd.append('semantic_query', query || '');
fetch(action, { method: 'POST', body: fd })
.then(res => { if (!res.ok) throw new Error(String(res.status)); return res.blob(); })
.then(blob => {
if (cancelled) return;
objUrl = URL.createObjectURL(blob);
setSrc(objUrl + pageFragment);
setStatus('ok');
})
.catch(() => { if (!cancelled) setStatus('error'); });
return () => { cancelled = true; if (objUrl) URL.revokeObjectURL(objUrl); };
}, []);
return (
{(strings && strings.pdf_unavailable_title) || 'PDF ikke tilgjengelig'}
{(strings && strings.pdf_unavailable_body) || 'Dokumentet er ikke publisert ennå. Prøv igjen senere.'}
)}
);
}
// ──────────────────────────────────────────────────────────────────────────
// Top-level App
// ──────────────────────────────────────────────────────────────────────────
function emptyFilterState() {
return {
case_id: '', respondent: '', complainant: '', saken_gjelder: '',
start_date: '', end_date: '',
sections: {}, sections_touched: false,
// Default to paragraph (Avsnitt) view — the ranked paragraphs that answer the
// query are the product's core value. Corpora with their own view_modes
// (Lovdata/EU) override via SET_CORPUS (validVMs[0]); the case table is one
// toggle away for everyone else.
view_mode: 'paragraph',
sort_by: '', sort_order: 'desc',
doc_slug: [], unit_kind: [], doc_kind: [], instance: [], section_code: [], language_code: [],
catchwords_terms: [],
};
}
const initialState = {
cfg: null,
corpus: null,
query: '',
// Per-corpus filter pockets (PR B). Each source tab remembers its own
// filter state — switching tabs swaps pockets, switching back restores
// what you had. The flat ``filters`` slot below is a *derived view* on
// ``filtersByCorpus[corpus]`` kept in sync by the reducer, so the 18+
// existing reads of ``state.filters`` continue to work unchanged.
filtersByCorpus: {},
filters: emptyFilterState(),
page: 1,
page_size: 20,
results: [],
// total: count of the unit shown in the summary line (cases in Sak view, or
// the pre-cap filtered pool size in Avsnitt view — the "X avsnitt" number).
// null on boot / corpus switch so the source-tab badge doesn't flash "0";
// becomes a number once the first SEARCH_OK arrives.
total: null,
total_pages: 1,
// doc_count: distinct highest-level units in the filtered pool (cases for
// KOFA-kind, documents for paragraph-native). Drives the source-tab badge,
// stable across view modes. null until first SEARCH_OK.
doc_count: null,
// cap_hit: did the FAISS rank pool exhaust K_CAP=500 paragraphs?
// Drives the "(kun de 500 mest relevante vises)" parenthetical in the
// Avsnitt-mode summary line.
cap_hit: false,
view_mode: 'paragraph',
meta: {},
selected: null, // { corpus, item }
detail: null, // detail payload from /api/detail
detailLoading: false,
loading: false,
error: null,
dark: false,
pdfOpen: null, // case_id when PDF modal open
lovdataViewerOpen: null, // { docSlug, externalUrl, chapterId } when LovdataViewer open
guideOpen: false,
supportOpen: false,
railExpanded: false,
searchMode: 'best', // 'best' (hybrid) | 'keyword' (BM25) | 'semantic' (vector) — EFFECTIVE mode
userSearchMode: 'best', // the user's last explicit pick; effective = corpus.force_search_mode || userSearchMode
rerank: false, // «Grundighet» toggle — cross-encoder rerank on/off (per request, default OFF / opt-in)
// Onboarding tutorial: drives a multi-step modal on first visit and a Help
// button in the LeftRail to re-open it. `available` flips to false when the
// userdata backend is unreachable so we don't keep retrying.
onboarding: { available: true, checked: false, seen: false, completedAt: null, dismissedAt: null, version: null, openOverride: false },
// Projects (LeftRail panel + DetailPanel "save case" affordance). When
// `currentId` is set the result list switches to that project's saved cases.
projects: { available: true, items: [], currentId: null, currentItems: null, currentLoading: false },
};
function reducer(state, action) {
switch (action.type) {
case 'BOOT': {
// Seed the active corpus's filter pocket with whatever filters were
// restored from Api.getSavedState (or default empty). View_mode comes
// from action.viewMode (cookie/URL hint) or defaults to 'paragraph'.
// First-visit default filters declared by the landing corpus (e.g.
// Vergabekammer defaults the Abschnitt facet to Begründung — the
// chamber's own reasoning — mirroring KOFA defaulting to kv). Restored
// state.filters keys win over the declared defaults, so a returning user
// keeps their choice.
const bootCorpusObj = (action.cfg?.corpora || []).find(c => c.id === action.corpus);
const initFilters = { ...(bootCorpusObj?.default_filters || {}), ...state.filters, view_mode: action.viewMode || 'paragraph' };
return {
...state,
cfg: action.cfg,
corpus: action.corpus,
dark: action.dark,
filters: initFilters,
// Respect a forced search mode if we boot straight onto such a corpus.
searchMode: bootCorpusObj?.force_search_mode || state.searchMode,
filtersByCorpus: action.corpus ? { ...state.filtersByCorpus, [action.corpus]: initFilters } : state.filtersByCorpus,
};
}
case 'SET_CORPUS': {
// Per-corpus filter memory (PR B). Save current corpus's filters to
// its pocket; switch to the new corpus's pocket (or an empty default
// if first visit). view_mode lives inside the pocket so each tab
// remembers whether you were last in Sak or Avsnitt. searchMode +
// semantic_query stay top-level — they're user-modal preferences,
// not data filters.
//
// view_mode default per corpus: corpora that ship a `view_modes` config
// (Lovdata, Retsinfo, …) MUST land on their first declared mode rather
// than the generic 'case' default in emptyFilterState — otherwise the
// first search after corpus switch sends view_mode='case' to the
// backend, hits the legacy PR #35 Kapittel rollup, and renders the old
// chapter table that PR #44 was supposed to retire. Same normalization
// for any pocket whose stored view_mode predates PR #44.
const oldCorpus = state.corpus;
const newCorpus = action.corpus;
const pockets = oldCorpus
? { ...state.filtersByCorpus, [oldCorpus]: state.filters }
: state.filtersByCorpus;
const newCorpusObj = (state.cfg?.corpora || []).find(c => c.id === newCorpus);
const validVMs = (newCorpusObj?.view_modes || []).map(vm => vm.value);
const defaultVM = validVMs[0] || 'case';
const isFirstVisit = !pockets[newCorpus];
let newFilters = pockets[newCorpus] || emptyFilterState();
// First visit only: honor the corpus's declared default_view_mode — laws
// land on their Dokument/Sak rollup, court + reference keep the global
// Avsnitt default. On return, the remembered pocket wins (we don't touch it).
if (isFirstVisit && newCorpusObj?.default_filters) {
newFilters = { ...newCorpusObj.default_filters, ...newFilters };
}
if (isFirstVisit && newCorpusObj?.default_view_mode) {
newFilters = { ...newFilters, view_mode: newCorpusObj.default_view_mode };
}
// Normalize a stale/invalid view_mode against the corpus's declared modes.
if (validVMs.length > 0 && !validVMs.includes(newFilters.view_mode)) {
newFilters = { ...newFilters, view_mode: defaultVM };
}
return {
...state,
corpus: newCorpus,
page: 1,
selected: null,
detail: null,
filters: newFilters,
filtersByCorpus: pockets,
results: [],
total: null,
doc_count: null,
cap_hit: false,
total_pages: 1,
view_mode: newFilters.view_mode,
// A corpus may force a search mode (EU corpora in the DE app force
// 'semantic': their text is embedded in English, so BM25/hybrid on a
// German query matches poorly — the multilingual vector doesn't). Fall
// back to the user's own last pick for corpora that don't force one.
searchMode: newCorpusObj?.force_search_mode || state.userSearchMode,
meta: {},
};
}
case 'SET_QUERY':
return { ...state, query: action.query, page: 1 };
case 'SET_FILTERS':
// Write into both the active pocket and the derived ``filters`` view.
return {
...state,
filters: action.filters,
filtersByCorpus: state.corpus
? { ...state.filtersByCorpus, [state.corpus]: action.filters }
: state.filtersByCorpus,
page: 1,
};
case 'SET_PAGE':
return { ...state, page: action.page };
case 'SEARCH_BEGIN':
return { ...state, loading: true, error: null };
case 'SEARCH_OK':
return { ...state, loading: false, results: action.results, total: action.total, total_pages: action.total_pages, doc_count: action.doc_count, cap_hit: !!action.cap_hit, view_mode: action.view_mode || state.view_mode, meta: action.meta || {} };
case 'SEARCH_ERR':
return { ...state, loading: false, error: action.error };
case 'SELECT':
if (state.selected && action.selected && state.selected.corpus === action.selected.corpus && state.selected.id === action.selected.id)
return { ...state, selected: null, detail: null };
return { ...state, selected: action.selected, detail: null, detailLoading: !!action.selected };
case 'DETAIL_OK':
return { ...state, detail: action.detail, detailLoading: false };
case 'DETAIL_ERR':
return { ...state, detail: null, detailLoading: false, error: action.error };
case 'CLOSE_DETAIL':
return { ...state, selected: null, detail: null };
case 'TOGGLE_DARK':
return { ...state, dark: !state.dark };
case 'OPEN_PDF':
return { ...state, pdfOpen: { caseId: action.caseId, page: action.page || null, corpusId: action.corpusId || null, section: action.section || null } };
case 'CLOSE_PDF':
return { ...state, pdfOpen: null };
case 'OPEN_LOVDATA_VIEWER':
return { ...state, lovdataViewerOpen: { docSlug: action.docSlug, externalUrl: action.externalUrl || null, chapterId: action.chapterId || null, targetPid: action.targetPid ?? null, corpus: action.corpus || 'lovdata' } };
case 'CLOSE_LOVDATA_VIEWER':
return { ...state, lovdataViewerOpen: null };
case 'GUIDE_OPEN':
return { ...state, guideOpen: true };
case 'GUIDE_CLOSE':
return { ...state, guideOpen: false };
case 'SUPPORT_TOGGLE':
return { ...state, supportOpen: !state.supportOpen };
case 'SUPPORT_CLOSE':
return { ...state, supportOpen: false };
case 'TOGGLE_RAIL':
return { ...state, railExpanded: !state.railExpanded };
case 'EXPAND_RAIL':
return state.railExpanded ? state : { ...state, railExpanded: true };
case 'SET_SEARCH_MODE':
// Record it as the user's explicit preference AND as the effective mode.
// (The chooser is hidden on corpora that force a mode, so this only fires
// where the user is actually free to choose.)
return { ...state, searchMode: action.mode, userSearchMode: action.mode, page: 1 };
case 'SET_RERANK':
return { ...state, rerank: !!action.on, page: 1 };
// ─── Onboarding ────────────────────────────────────────────────────
case 'ONBOARDING_LOADED':
return { ...state, onboarding: { ...state.onboarding, available: action.available !== false, checked: true, seen: !!action.seen, completedAt: action.completedAt || null, dismissedAt: action.dismissedAt || null, version: action.version ?? null } };
case 'ONBOARDING_COMPLETE':
return { ...state, onboarding: { ...state.onboarding, seen: true, completedAt: new Date().toISOString(), version: action.version ?? state.onboarding.version, openOverride: false } };
case 'ONBOARDING_DISMISS':
return { ...state, onboarding: { ...state.onboarding, seen: true, dismissedAt: new Date().toISOString(), openOverride: false } };
case 'HELP_OPEN':
return { ...state, onboarding: { ...state.onboarding, openOverride: true } };
case 'HELP_CLOSE':
return { ...state, onboarding: { ...state.onboarding, openOverride: false } };
// ─── Projects ──────────────────────────────────────────────────────
case 'PROJECTS_LOADED':
return { ...state, projects: { ...state.projects, available: action.available !== false, items: action.items || [] } };
case 'PROJECT_UPSERT': {
const next = state.projects.items.slice();
const idx = next.findIndex(p => p.id === action.project.id);
if (idx >= 0) next[idx] = { ...next[idx], ...action.project };
else next.unshift(action.project);
return { ...state, projects: { ...state.projects, items: next } };
}
case 'PROJECT_REMOVE': {
const next = state.projects.items.filter(p => p.id !== action.projectId);
const clearCurrent = state.projects.currentId === action.projectId;
return { ...state, projects: { ...state.projects, items: next, currentId: clearCurrent ? null : state.projects.currentId, currentItems: clearCurrent ? null : state.projects.currentItems } };
}
case 'PROJECT_SET_CURRENT':
return { ...state, projects: { ...state.projects, currentId: action.projectId, currentItems: action.projectId ? state.projects.currentItems : null, currentLoading: !!action.projectId } };
case 'PROJECT_ITEMS_LOADED':
if (state.projects.currentId !== action.projectId) return state; // stale
return { ...state, projects: { ...state.projects, currentItems: action.items || [], currentLoading: false } };
case 'PROJECT_ITEM_BUMP_COUNT': {
const next = state.projects.items.map(p => p.id === action.projectId ? { ...p, item_count: (p.item_count || 0) + (action.delta || 1) } : p);
return { ...state, projects: { ...state.projects, items: next } };
}
default:
return state;
}
}
function App() {
const [state, dispatch] = useReducer(reducer, initialState);
const [bootError, setBootError] = useState(null);
// Telemetry: log ONE 'search' event per *settled* query (not per keystroke).
// { timer } debounces past the search's own 250ms; { lastSig } dedupes so
// pagination / view-toggle / identical re-fires don't double-count.
const searchTelemetryRef = useRef({ timer: null, lastSig: null });
// Boot
useEffect(() => {
const dm = Api.loadDarkMode();
Api.getConfig().then(cfg => {
const persisted = Api.loadState();
const corpus = (persisted && persisted.corpus) || cfg.landing_corpus || cfg.default_corpus;
dispatch({ type: 'BOOT', cfg, corpus, dark: dm === true });
if (persisted && persisted.searchMode && ['best','keyword','semantic'].includes(persisted.searchMode)) {
dispatch({ type: 'SET_SEARCH_MODE', mode: persisted.searchMode });
}
if (persisted && typeof persisted.rerank === 'boolean') {
dispatch({ type: 'SET_RERANK', on: persisted.rerank });
}
// Per-user state. Fire in parallel; both .catch to null so a 503 from the
// userdata backend (or a 404 from PROJECTS_ENABLED=false) silently degrades:
// the LeftRail Prosjekt section and onboarding modal just don't render.
if (cfg.projects_enabled) {
Api.getOnboarding().then(r => {
dispatch({ type: 'ONBOARDING_LOADED', seen: r.seen, completedAt: r.completed_at, dismissedAt: r.dismissed_at, version: r.version });
}).catch(() => {
dispatch({ type: 'ONBOARDING_LOADED', available: false, seen: true });
});
Api.listProjects().then(r => {
dispatch({ type: 'PROJECTS_LOADED', items: r.items });
}).catch(() => {
dispatch({ type: 'PROJECTS_LOADED', available: false, items: [] });
});
} else {
dispatch({ type: 'ONBOARDING_LOADED', available: false, seen: true });
dispatch({ type: 'PROJECTS_LOADED', available: false, items: [] });
}
}).catch(e => {
console.error('Boot error:', e);
setBootError(String(e));
});
}, []);
// Apply dark-mode to root element
useEffect(() => {
document.documentElement.dataset.theme = state.dark ? 'dark' : 'light';
Api.saveDarkMode(state.dark);
}, [state.dark]);
// Persist lightweight state
useEffect(() => {
if (!state.cfg) return;
Api.saveState({ corpus: state.corpus, query: state.query, filters: state.filters, searchMode: state.searchMode, rerank: state.rerank });
}, [state.corpus, state.query, state.filters, state.cfg, state.searchMode, state.rerank]);
// Fire searches whenever query / filters / corpus / page change.
// Suppressed when the user is viewing a saved project (LeftRail click);
// the project's items are rendered as synthetic results instead.
useEffect(() => {
if (!state.cfg || !state.corpus) return;
if (state.projects && state.projects.currentId) return;
let cancelled = false;
const ctrl = new AbortController();
const handle = setTimeout(async () => {
dispatch({ type: 'SEARCH_BEGIN' });
try {
const payload = buildSearchPayload(state);
const _searchT0 = performance.now();
const res = await Api.search(payload, ctrl.signal);
if (cancelled) return;
if (res.error) {
const friendly = res.error === 'corpus_unavailable'
? 'Denne kilden er ikke synket lokalt. Kjør med USE_BLOB=true for å hente fra Azure (prod-mirror).'
: res.error === 'unknown_corpus'
? 'Ukjent kilde for dette miljøet.'
: (res.error_detail || res.error);
dispatch({ type: 'SEARCH_ERR', error: friendly });
return;
}
dispatch({
type: 'SEARCH_OK',
results: res.results || [],
total: typeof res.total === 'number' ? res.total : 0,
total_pages: res.total_pages || 1,
doc_count: typeof res.doc_count === 'number' ? res.doc_count : null,
cap_hit: !!res.cap_hit,
view_mode: res.view_mode,
meta: {
multi_select_options: res.multi_select_options,
multi_select_counts: res.multi_select_counts,
default_start_date: res.default_start_date,
default_end_date: res.default_end_date,
},
});
// Telemetry: log ONE 'search' event once the query settles (content-free).
// A dedicated 1.5s debounce collapses keystroke bursts to the final query;
// the signature dedupes pagination / view-toggle / identical re-fires.
if (state.cfg && state.cfg.events_enabled) {
const q = (state.query || '').trim();
if (q) {
const fsig = { ...state.filters }; delete fsig.view_mode;
const sig = JSON.stringify([state.corpus, q, fsig, state.searchMode]);
const total = typeof res.total === 'number' ? res.total : 0;
const evt = {
corpus_id: state.corpus,
search_mode: state.searchMode,
view_mode: state.filters.view_mode || 'case',
query_length: q.length,
result_count: total,
zero_result: total === 0,
latency_ms: Math.round(performance.now() - _searchT0),
};
const tref = searchTelemetryRef.current;
clearTimeout(tref.timer);
tref.timer = setTimeout(() => {
if (sig !== tref.lastSig) {
tref.lastSig = sig;
Api.emitEvent('search', evt);
}
}, 1500);
}
}
} catch (e) {
if (cancelled || e.name === 'AbortError') return;
dispatch({ type: 'SEARCH_ERR', error: String(e) });
}
}, 250);
return () => { cancelled = true; ctrl.abort(); clearTimeout(handle); };
}, [state.cfg, state.corpus, state.query, state.filters, state.page, state.searchMode, state.rerank, state.projects?.currentId]);
// Project items fetch — runs when a project is selected from the LeftRail.
useEffect(() => {
const pid = state.projects && state.projects.currentId;
if (!pid) return;
let cancelled = false;
Api.getProject(pid).then(r => {
if (cancelled) return;
dispatch({ type: 'PROJECT_ITEMS_LOADED', projectId: pid, items: r.items || [] });
}).catch(() => {
if (!cancelled) dispatch({ type: 'PROJECT_ITEMS_LOADED', projectId: pid, items: [] });
});
return () => { cancelled = true; };
}, [state.projects?.currentId]);
// Detail fetch
useEffect(() => {
if (!state.selected) return;
let cancelled = false;
Api.getDetail(state.selected.corpus, state.selected.id).then(r => {
if (cancelled) return;
if (r.error) dispatch({ type: 'DETAIL_ERR', error: r.error });
else dispatch({ type: 'DETAIL_OK', detail: r.detail });
}).catch(e => { if (!cancelled) dispatch({ type: 'DETAIL_ERR', error: String(e) }); });
return () => { cancelled = true; };
}, [state.selected]);
// Pre-cfg fallbacks read the locale hint baked into the SPA template's
// window.__BOOTSTRAP__ so the boot screen + load error are localized
// before /api/config arrives.
const bootIsDanish = (typeof window !== 'undefined' && window.__BOOTSTRAP__ && window.__BOOTSTRAP__.locale === 'da');
if (bootError) {
const msg = bootIsDanish ? 'Kunne ikke indlæse konfiguration' : 'Klarte ikke å laste konfigurasjon';
return
{msg}: {bootError}
;
}
if (!state.cfg) {
return
{bootIsDanish ? 'Indlæser…' : 'Laster…'}
;
}
const cfg = state.cfg;
const corpora = cfg.corpora;
const corpusObj = corpora.find(c => c.id === state.corpus) || corpora[0];
const primary = cfg.primary_corpus;
const strings = cfg.strings;
const isPrimary = corpusObj.id === primary.id;
// Section-code → label map. For a cross-country tab (e.g. KLFU in the NO app)
// use the foreign corpus's own sections (DK i/p/s/a/ku/h) so the paragraph-card
// tags resolve to Danish labels instead of leaking the raw code ("ku").
const sectionSource = (corpusObj?.is_cross_country && corpusObj?.sections?.length) ? corpusObj : primary;
const sectionLabels = (sectionSource.sections || []).reduce((m, s) => (m[s.code] = s.label, m), {});
// Active source tab's badge shows `state.doc_count` — distinct cases /
// documents in the filtered pool, stable across view modes (filter-aware,
// never the raw paragraph-cap-bounded number). null until first SEARCH_OK
// so badge doesn't flash "0" on load. See PR A in
// docs/architecture-roadmap.md.
const totals = corpora.reduce((m, c) => { m[c.id] = c.id === state.corpus ? state.doc_count : null; return m; }, {})
const isDanish = cfg.locale === 'da';
const isGermanUi = cfg.locale === 'de';
const placeholderSamples = corpusObj.id === 'vergabekammer'
// German full-question suggestions — fires whether Vergabekammer is the DE
// primary (APP_MODE=de) or a secondary corpus. §-anchors (GWB/VgV) double as
// BM25 anchors against the decision text.
? [
'Wann darf der öffentliche Auftraggeber ein Vergabeverfahren aufheben?',
'Welche Anforderungen gelten für die Aufklärung eines ungewöhnlich niedrigen Angebots?',
'Wie weit reicht der Beurteilungsspielraum des Auftraggebers bei der Wertung von Konzepten?',
'Unter welchen Voraussetzungen ist ein Angebot wegen fehlender Eignung auszuschließen?',
'Wann ist eine Rüge rechtzeitig im Sinne des § 160 Abs. 3 GWB?',
'Wann ist eine De-facto-Vergabe nach § 135 GWB unwirksam?',
]
: isPrimary
? (isDanish
? [
'Hvornår foreligger en væsentlig ændring efter udbudslovens § 178?',
'Hvilke krav stiller udbudsloven til ordregiverens undersøgelse af et unormalt lavt tilbud?',
'Hvornår er en ordregivers evalueringsmodel uegnet til at identificere det økonomisk mest fordelagtige tilbud?',
'Hvornår kan ordregiver annullere et udbud uden erstatningspligt?',
]
: [
'Når foreligger en vesentlig endring etter anskaffelsesforskriften § 28-2?',
'Hvilke krav stiller anskaffelsesforskriften til begrunnelsen for avvisning av tilbud?',
'Hvilke terskelverdier utløser kunngjøringsplikt etter anskaffelsesloven?',
'Når kan oppdragsgiver avlyse en anskaffelse uten erstatningsansvar?',
])
: corpusObj.id === 'retsinfo'
? [
'Hvilke obligatoriske udelukkelsesgrunde gælder efter udbudslovens § 135?',
'Hvad er Klagenævnet for Udbuds kompetence til at tilkende erstatning?',
'Hvornår udløser en kontraktændring krav om nyt udbud efter § 178?',
'Hvilke betingelser gælder for udbud med forhandling efter § 61?',
]
: corpusObj.id === 'lovdata'
// Norwegian corpus — keep statutory terms (avvisningsgrunner, KOFA,
// anskaffelsesforskriften, § refs) in Norwegian even in the Danish UI
// so BM25 fires against the actual statute text.
? (isDanish
? [
'Hvilke avvisningsgrunner gælder efter anskaffelsesforskriften § 24-2?',
'Hvornår foreligger en vesentlig endring af kontrakt efter § 28-2?',
'Hvad er KOFAs kompetence til at afgive rådgivende udtalelser?',
'Hvilke kvalifikasjonskrav kan oppdragsgiver stille efter § 16-1?',
]
: [
'Hvilke avvisningsgrunner følger av anskaffelsesforskriften § 24-2?',
'Når foreligger en vesentlig endring av kontrakt etter § 28-2?',
'Hva er KOFAs kompetanse til å gi rådgivende uttalelser?',
'Hvilke kvalifikasjonskrav kan oppdragsgiver stille etter § 16-1?',
])
: corpusObj.id === 'eu_directives'
// Full-question suggestions matching the veiledning's "skriv hele
// spørsmål, ikke bare nøkkelord" advice (issue #33). Same shape
// for both locales — the embedding model is multilingual so
// Norwegian/Danish prompts retrieve the EU-translated text fine.
// Article numbers (2014/24/EU) are language-neutral BM25 anchors.
? (isGermanUi
? [
'Unter welchen Voraussetzungen erlaubt Artikel 72 die Änderung eines öffentlichen Auftrags ohne neues Vergabeverfahren?',
'Welche Ausschlussgründe in Artikel 57 führen zum zwingenden Ausschluss eines Wirtschaftsteilnehmers?',
'Welche Anforderungen stellt Artikel 18 an Gleichbehandlung und Transparenz im Vergabeverfahren?',
'Unter welchen Voraussetzungen ist das Verhandlungsverfahren nach Artikel 29 zulässig?',
]
: isDanish
? [
'Hvilke betingelser i artikel 172 tillader ændring af en offentlig kontrakt uden ny udbudsprocedure?',
'Hvilke udelukkelsesgrunde i artikel 57 medfører obligatorisk udelukkelse af en tilbudsgiver?',
'Hvilke krav stiller artikel 18 til ligebehandling og gennemsigtighed i udbudsprocessen?',
'Hvilke betingelser gælder for udbud med forhandling efter artikel 29?',
]
: [
'Hvilke vilkår i artikkel 72 tillater endring av en offentlig kontrakt uten ny anskaffelsesprosedyre?',
'Hvilke utelukkelsesgrunner i artikkel 57 medfører obligatorisk utelukkelse av en leverandør?',
'Hvilke krav stiller artikkel 18 til likebehandling og åpenhet i anskaffelsesprosessen?',
'Hvilke vilkår gjelder for konkurranse med forhandling etter artikkel 29?',
])
: (isGermanUi
? [
'Wie legte der EuGH die «wesentliche Änderung» im Urteil pressetext (C-454/06) aus?',
'Wann verletzt der Ausschluss eines Angebots den Gleichbehandlungsgrundsatz, vgl. Succhi di Frutta (C-496/99 P)?',
'Welche Anforderungen stellt das Urteil Alcatel (C-81/98) an die Stillhaltefrist?',
'Wann liegt ein Interessenkonflikt nach dem Urteil Fabricom (C-21/03) vor?',
]
: isDanish
? [
'Hvordan fortolkede EU-Domstolen «væsentlig ændring» i pressetext-dommen (C-454/06)?',
'Hvornår krænker en afvisning af tilbud ligebehandlingsprincippet, jf. Succhi di Frutta (C-496/99 P)?',
'Hvilke krav stiller Alcatel-dommen (C-81/98) til standstill-perioden?',
'Hvornår foreligger en interessekonflikt efter Fabricom-dommen (C-21/03)?',
]
: [
'Hvordan tolket EU-domstolen «vesentlig endring» i pressetext-dommen (C-454/06)?',
'Når krenker avvisning av tilbud likebehandlingsprinsippet, jf. Succhi di Frutta (C-496/99 P)?',
'Hvilke krav stiller Alcatel-dommen (C-81/98) til karensperioden?',
'Når foreligger en interessekonflikt etter Fabricom-dommen (C-21/03)?',
]);
// Use the cross-country corpus's own filter config for chip enumeration so
// foreign multi-select chips (e.g. KLFU's verdict_kinds) render in NO app.
const chipsFilterCfg = (corpusObj?.is_cross_country && corpusObj?.kind === 'kofa') ? corpusObj : primary;
const chips = activeFilterChips(state.filters, chipsFilterCfg, sectionLabels, strings);
return (
dispatch({ type: 'SET_CORPUS', corpus: id })} totals={totals}/>
{/* Persistent left filter sidebar (experiment) */}
{/* Results column — search bar is a fixed header; the results scroll
in their own area below it, so the query stays visible without the
search bar ever overlapping the result cards. */}
{/* Hero figure — sets the tone, echoes the tour welcome */}
{strings.guide_intro}
{sections.map(s => (
{s.title}
{s.body && (
{s.body}
)}
{s.bullets && s.bullets.length > 0 && (
{s.bullets.map((b, i) =>
{b}
)}
)}
))}
{/* Contact-support callout. Sits at the bottom so users hit it after
* scanning the rest. Email is intentionally hardcoded as the single
* source of truth (not localized — it's an address, not copy). */}