// 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 = `
App-feil: ${e.message}\n${e.error && e.error.stack || ''}
`; } }); class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { err: null }; } static getDerivedStateFromError(err) { return { err }; } componentDidCatch(err, info) { console.error('Render error:', err, info); } render() { if (this.state.err) { return (
          App-feil under render: {String(this.state.err)}{'\n'}{this.state.err.stack || ''}
        
); } return this.props.children; } } const ASSET_V = (typeof window !== 'undefined' && window.__BOOTSTRAP__ && window.__BOOTSTRAP__.asset_version) || ''; const asset = (path) => ASSET_V ? `${path}?v=${ASSET_V}` : path; // ────────────────────────────────────────────────────────────────────────── // Icons (lifted from Presedens) // ────────────────────────────────────────────────────────────────────────── const Icon = ({ name, size = 16, color = 'currentColor' }) => { const icons = { search: , x: , chevronDown: , chevronRight: , chevronLeft: , sliders: , list: , table: , // Paragraph (§) — used as the middle segment label of the Lovdata 3-way // toggle (Ledd / Paragraf / Dokument). SVG with a serif fallback // chain so the § glyph renders crisp at small sizes. // // `dominantBaseline="central"` (with y=8) centers the glyph optically at // the viewBox midline. The naïve `y="13"` (baseline anchor) puts the § // visual center at ~y=8.45 — close to 8 but ~0.4 px low next to the // list icon, which the user noticed. paragraph: §, // Document — rectangle (the document frame) with three horizontal bars // inside (content lines, mirroring the `list` icon's bar style). // // Layout uses **equal center-to-center spacing of 3 px** across the four // horizontal lines (rect-top, bar-1, bar-2, bar-3, rect-bottom): 2 → 5 → // 8 → 11 → 14. Rect 10 × 12 at (3, 2) → portrait, evenly inset 3 px // from the left/right viewBox edges and 2 px from top/bottom. Bars span // x=5.5 to x=10.5 (length 5, 2.5 px inset from each rect side), so the // round stroke caps leave a 1.1 px breathing gap to the rect's inner // edge — the previous 0.6 px gap was what made it read as "off". document: , externalLink: , user: , moon: , sun: , feedback: , dot: , help: , book: , bookOpen: , headset: , copy: , check: , folder: , plus: , moreH: , bookmark: , trash: , }; return icons[name] || null; }; const VennMark = ({ size = 20, blue = '#5A6FF0', black = '#0A0A0A' }) => ( ); const ReliableLogo = ({ height = 20, color = 'var(--text)' }) => (
Reliable ai
); // ────────────────────────────────────────────────────────────────────────── // Badges // ────────────────────────────────────────────────────────────────────────── function decisionType(label) { if (!label) return 'neutral'; const s = label.toLowerCase(); if (s.includes('ikke brudd') || s.includes('intet brudd') || s.includes('intet')) return 'ikke-brudd'; if (s.includes('brudd') || s.includes('overtredelses') || s.includes('annullér')) return 'brudd'; if (s.includes('avvis') || s.includes('trukket') || s.includes('avvist')) return 'avvist'; return 'neutral'; } const AvgjørelseBadge = ({ label, wrap = false }) => { if (!label) return null; const t = decisionType(label); const styles = { 'brudd': { background: 'var(--red-bg)', color: 'var(--red)' }, 'ikke-brudd': { background: 'var(--green-bg)', color: 'var(--green)' }, 'avvist': { background: 'var(--surface-2)', color: 'var(--text-2)' }, 'neutral': { background: 'var(--surface-2)', color: 'var(--text-2)' }, }; const s = styles[t]; return ( {label} ); }; const TypeBadge = ({ label, wrap = false }) => { if (!label) return null; return ( {label} ); }; // EU court — Norwegian labels for the two instances and the 13 languages we ingest. const COURT_LABELS_NB = { cjeu: 'Domstolen', general_court: 'Underretten', }; // EU court — section_code labels. Codes mirror the KOFA section taxonomy: // i=intro/headnote, b=background/legal context, s=procedure, kv=findings of // the court (the analytical heart), k=operative part. The default unfiltered // view shows all five; the killer use case is the kv-only filter. const SECTION_CODE_LABELS_NB = { i: 'Innledning', b: 'Bakgrunn', s: 'Prosedyre', kv: 'Rettens vurdering', k: 'Slutning', }; const SECTION_CODE_LABELS_DA = { i: 'Indledning', b: 'Baggrund', s: 'Procedure', kv: 'Domstolens vurdering', k: 'Slutning', }; const LANGUAGE_LABELS_NB = { EN: 'engelsk', FR: 'fransk', DE: 'tysk', IT: 'italiensk', EL: 'gresk', DA: 'dansk', PL: 'polsk', PT: 'portugisisk', SL: 'slovensk', NL: 'nederlandsk', BG: 'bulgarsk', ES: 'spansk', HU: 'ungarsk', }; const langLabel = (code) => (code ? (LANGUAGE_LABELS_NB[code] || String(code).toLowerCase()) : ''); // ────────────────────────────────────────────────────────────────────────── // Top bar // ────────────────────────────────────────────────────────────────────────── // Search-mode labels resolve by app locale. NO + DK both show Norwegian (the // chooser is "Norwegian-only" by product decision); DE shows German. The else // branch reproduces the pre-German-edition NO/DK text verbatim. const SEARCH_MODE_OPTIONS_NB = [ { value: 'best', label: 'Hybrid', sub: 'Nøkkelord + meningssøk' }, { value: 'keyword', label: 'Nøkkelord', sub: 'Kun nøkkelord-søk' }, { value: 'semantic', label: 'Meningssøk', sub: 'Kun semantisk likhet' }, ]; const SEARCH_MODE_OPTIONS_DE = [ { value: 'best', label: 'Hybrid', sub: 'Schlagwort + Bedeutungssuche' }, { value: 'keyword', label: 'Schlagwort', sub: 'Nur Schlagwortsuche' }, { value: 'semantic', label: 'Bedeutungssuche', sub: 'Nur semantische Ähnlichkeit' }, ]; const searchModeOptions = (locale) => (locale === 'de' ? SEARCH_MODE_OPTIONS_DE : SEARCH_MODE_OPTIONS_NB); function SearchModePopover({ value, onChange, anchorRef, rerank, onSetRerank, rerankEnabled, locale }) { // Close on outside click / Escape. const ref = useRef(null); useEffect(() => { const onDocClick = (e) => { if (!ref.current) return; if (ref.current.contains(e.target)) return; if (anchorRef && anchorRef.current && anchorRef.current.contains(e.target)) return; onChange(null); }; const onKey = (e) => { if (e.key === 'Escape') onChange(null); }; document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); }; }, [onChange, anchorRef]); return (
{locale === 'de' ? 'Suchmodus' : 'Søkemodus'}
{searchModeOptions(locale).map(o => { const active = o.value === value; return ( ); })} {rerankEnabled && ( <>
{locale === 'de' ? 'Gründlichkeit' : 'Grundighet'}
)}
); } // Anchored popover under the header "Support" button. Two clear contact // affordances — phone (tel:) and email (mailto:) — and closes on outside // click / Escape. Phone displayed with Norwegian mobile spacing (469 06 444), // dialed as +47 prefixed. function SupportPopover({ anchorRef, onClose, strings }) { const ref = useRef(null); useEffect(() => { const onDoc = (e) => { if (!ref.current) return; if (ref.current.contains(e.target)) return; if (anchorRef && anchorRef.current && anchorRef.current.contains(e.target)) return; onClose(); }; const onKey = (e) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; }, [onClose, anchorRef]); return (
{strings.support_popover_title || 'Kontakt support'}
{strings.support_popover_body || 'Spørsmål eller problemer? Ring oss eller send e-post.'}
📞
{strings.support_phone_label || 'Telefon'}
{strings.support_phone_display || '469 06 444'}
{strings.support_email_label || 'E-post'}
support@reliableai.no
); } function TopBar({ brand, dark, onToggleDark, onLogout, onToggleSupport, supportOpen, onCloseSupport, strings }) { const supportRef = useRef(null); return (
{brand} window.location.reload()} style={{ height: 20, cursor: 'pointer' }}/>
{supportOpen && ( )}
Powered by Reliable AI
); } const iconBtn = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 6, borderRadius: 6, background: 'transparent', cursor: 'pointer', border: 'none' }; // ────────────────────────────────────────────────────────────────────────── // Left rail (collapsed by default) // ────────────────────────────────────────────────────────────────────────── function LeftRail({ expanded, onToggle, logoutUrl, strings, onHelp, onGuide, projectsAvailable, projectsSlot }) { const w = expanded ? 220 : 56; return (
{expanded ? ( <> Meny ) : ( )}
{expanded && projectsAvailable && projectsSlot}
{expanded && onGuide && (
)} {expanded && onHelp && (
)}
{expanded && {(strings && strings.logout) || 'Logg ut'}}
); } // ────────────────────────────────────────────────────────────────────────── // 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 (

{project.name}

· {items ? items.length : 0}
{loading && (
)} {!loading && items && items.length === 0 && (
{strings.projects_empty}
)} {!loading && grouped.map(group => (
{group.query ? strings.projects_query_prefix : strings.projects_no_query_label} {group.query && ( «{group.query}» )} {group.items.length}
{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 (
{cobj && cobj.mark && }
{it.case_title || it.case_id}
{(cobj && cobj.label) || it.corpus_id}
{pills.length > 0 && (
{pills.map((p, i) => ( {p.label}: {p.value} ))}
)} {(verdictBadges.length > 0 || flagBadges.length > 0) && (
{verdictBadges.map((b, i) => ( {b.label && {b.label}:} {b.display} ))} {flagBadges.map((label, i) => ( {label} ))}
)}
); } // 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 (
{cobj && cobj.mark && }
{it.case_title || it.case_id}
{(cobj && cobj.label) || it.corpus_id}{it.parent_case_id ? ` · ${it.parent_case_id}` : ''}
{text && (
{text}
)}
); } const projectBackPillStyle = () => ({ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 999, border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 12, color: 'var(--text-2)', cursor: 'pointer', }); // ────────────────────────────────────────────────────────────────────────── // Projects rail (LeftRail section: list of user's saved-case projects) // ────────────────────────────────────────────────────────────────────────── function ProjectsRail({ items, currentId, strings, onSelect, onCreate, onRename, onDelete }) { const [creating, setCreating] = useState(false); const [draftName, setDraftName] = useState(''); const [renamingId, setRenamingId] = useState(null); const [renameDraft, setRenameDraft] = useState(''); const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [menuOpenId, setMenuOpenId] = useState(null); const submitCreate = () => { const n = draftName.trim(); if (!n) { setCreating(false); setDraftName(''); return; } onCreate(n); setCreating(false); setDraftName(''); }; const submitRename = (pid) => { const n = renameDraft.trim(); if (n && n.length <= 120) onRename(pid, n); setRenamingId(null); setRenameDraft(''); }; return (
{strings.projects_section_title}
{items.length === 0 && !creating && (
{strings.projects_empty}
)}
{items.map(p => { const active = p.id === currentId; const isRenaming = renamingId === p.id; const isMenuOpen = menuOpenId === p.id; const isConfirmingDelete = confirmDeleteId === p.id; if (isRenaming) { return ( setRenameDraft(e.target.value.slice(0, 120))} onBlur={() => submitRename(p.id)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); submitRename(p.id); } else if (e.key === 'Escape') { e.preventDefault(); setRenamingId(null); setRenameDraft(''); } }} style={{ display: 'block', width: '100%', padding: '6px 8px', fontSize: 12.5, borderRadius: 'var(--r-sm)', border: '1px solid var(--accent)', background: 'var(--surface)', color: 'var(--text)', outline: 'none', marginBottom: 2, boxSizing: 'border-box' }}/> ); } return (
{ setMenuOpenId(prev => prev === p.id ? null : prev); }}> {isMenuOpen && !isConfirmingDelete && (
)} {isConfirmingDelete && (
{strings.projects_delete_confirm}
)}
); })}
{creating ? ( setDraftName(e.target.value.slice(0, 120))} onBlur={submitCreate} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); submitCreate(); } else if (e.key === 'Escape') { e.preventDefault(); setCreating(false); setDraftName(''); } }} style={{ display: 'block', width: '100%', padding: '6px 8px', fontSize: 12.5, borderRadius: 'var(--r-sm)', border: '1px solid var(--accent)', background: 'var(--surface)', color: 'var(--text)', outline: 'none', marginTop: 4, boxSizing: 'border-box' }}/> ) : ( )}
); } const menuItemStyle = () => ({ display: 'block', width: '100%', padding: '6px 10px', fontSize: 12, background: 'transparent', border: 'none', color: 'var(--text-2)', textAlign: 'left', cursor: 'pointer', borderRadius: 4, }); // ────────────────────────────────────────────────────────────────────────── // Jurisdiction marks // ────────────────────────────────────────────────────────────────────────── const JurisdictionMark = ({ kind }) => { const marks = { all:
, no: , dk: , de: , eu: {[...Array(12)].map((_, i) => { const a = (i * 30 - 90) * Math.PI / 180; return ; })} , klfu: Kongekronen, }; 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 ↹ )}
)}