Initial commit: SMF2290 studie-app for eksamen vår 2026

Komplett pensumstudie-app med:
- 120 flashcards, 49 quiz-spørsmål, 16 eksamenstrener-oppgaver
- Sammendrag av alle 12 forelesninger
- tl;dr-side for siste-minutts pugging
- Søk gjennom hele pensumet
- Dark/light mode, mobile-vennlig

Cross-platform launchere (Start.sh + Start.bat) med auto-detect
og auto-install av HTTP-server. PowerShell-fallback for Windows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 18:44:00 +02:00
commit 8933e9501d
52 changed files with 8796 additions and 0 deletions

179
app/js/app.js Normal file
View File

@@ -0,0 +1,179 @@
// =====================================================
// App — router, sidebar, theme toggle
// =====================================================
const view = document.getElementById('view');
const crumbCurrent = document.getElementById('crumbCurrent');
function parseRoute() {
const hash = location.hash.replace(/^#\/?/, '');
if (!hash || hash === '/') return { name: 'home' };
const parts = hash.split('/').filter(Boolean);
if (parts[0] === 'uke' && parts[1]) return { name: 'uke', weekId: parseInt(parts[1], 10) };
if (parts[0] === 'tema' && parts[1]) return { name: 'tema', temaId: parts[1] };
if (parts[0] === 'tldr') return { name: 'tldr' };
if (parts[0] === 'flashcards') return { name: 'flashcards' };
if (parts[0] === 'quiz') return { name: 'quiz' };
if (parts[0] === 'eksamen') return { name: 'eksamen' };
return { name: 'home' };
}
async function route() {
const r = parseRoute();
view.innerHTML = '';
let content;
let crumb = 'Oversikt';
switch (r.name) {
case 'home':
content = SMF.renderHome();
crumb = 'Oversikt';
break;
case 'uke':
content = await SMF.renderLesson(r.weekId);
const w = SMF.WEEKS.find(x => x.id === r.weekId);
crumb = w ? `Uke ${w.id} · ${w.title}` : 'Ukjent uke';
break;
case 'tema':
content = await SMF.renderTema(r.temaId);
const t = SMF.getTheme(r.temaId);
crumb = t ? t.label : 'Tema';
break;
case 'tldr':
content = await SMF.renderTldr();
crumb = 'tl;dr · i farta';
break;
case 'flashcards':
content = document.getElementById('t-flashcards').content.cloneNode(true);
crumb = 'Flashcards';
break;
case 'quiz':
content = document.getElementById('t-quiz').content.cloneNode(true);
crumb = 'Selvtest';
break;
case 'eksamen':
content = document.getElementById('t-eksamen').content.cloneNode(true);
crumb = 'Eksamenstrener';
break;
}
view.appendChild(content);
crumbCurrent.textContent = crumb;
// After DOM is in place, init relevant mode
if (r.name === 'flashcards') await SMF.fcInit();
else if (r.name === 'quiz') await SMF.quizInit();
else if (r.name === 'eksamen') await SMF.examInit();
updateActiveLinks(r);
closeSidebar();
window.scrollTo(0, 0);
}
function updateActiveLinks(r) {
document.querySelectorAll('.sidebar__link').forEach(a => {
a.classList.remove('sidebar__link--active');
});
if (r.name === 'home') {
document.querySelector('.sidebar__link[data-route="home"]')?.classList.add('sidebar__link--active');
} else if (r.name === 'tldr') {
document.querySelector('.sidebar__link[data-route="tldr"]')?.classList.add('sidebar__link--active');
} else if (r.name === 'flashcards') {
document.querySelector('.sidebar__link[data-route="flashcards"]')?.classList.add('sidebar__link--active');
} else if (r.name === 'quiz') {
document.querySelector('.sidebar__link[data-route="quiz"]')?.classList.add('sidebar__link--active');
} else if (r.name === 'eksamen') {
document.querySelector('.sidebar__link[data-route="eksamen"]')?.classList.add('sidebar__link--active');
} else if (r.name === 'uke') {
document.querySelector(`.sidebar__link[data-week="${r.weekId}"]`)?.classList.add('sidebar__link--active');
} else if (r.name === 'tema') {
// No specific active link for tema (could be added)
}
}
function renderWeekNav() {
const nav = document.getElementById('weekNav');
if (!nav) return;
nav.innerHTML = SMF.WEEKS.map(w => {
return `
<a href="#/uke/${w.id}" data-week="${w.id}" class="sidebar__link">
<span class="sidebar__link-num">${String(w.id).padStart(2, '0')}</span>
<span>${w.title}</span>
</a>
`;
}).join('');
}
// ============= Sidebar (mobile) =============
function openSidebar() {
document.getElementById('sidebar').classList.add('sidebar--open');
document.getElementById('sidebarScrim').classList.add('sidebar-scrim--open');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('sidebar--open');
document.getElementById('sidebarScrim').classList.remove('sidebar-scrim--open');
}
document.getElementById('sidebarToggle')?.addEventListener('click', () => {
if (document.getElementById('sidebar').classList.contains('sidebar--open')) closeSidebar();
else openSidebar();
});
document.getElementById('sidebarScrim')?.addEventListener('click', closeSidebar);
// ============= Theme toggle =============
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
localStorage.setItem('smf-theme', theme);
// Update icon
const icon = document.getElementById('themeIcon');
if (theme === 'dark') {
icon.innerHTML = `<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>`;
} else {
icon.innerHTML = `<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>`;
}
}
function initTheme() {
// dark default; only switch to light if explicitly saved
const saved = localStorage.getItem('smf-theme');
applyTheme(saved === 'light' ? 'light' : 'dark');
}
document.getElementById('themeBtn').addEventListener('click', () => {
const current = document.documentElement.dataset.theme;
applyTheme(current === 'dark' ? 'light' : 'dark');
});
// ============= Countdown =============
function updateCountdown() {
const el = document.getElementById('examCountdown');
if (!el) return;
const c = SMF.daysToExam();
el.textContent = c.label;
}
// ============= Init =============
async function init() {
initTheme();
renderWeekNav();
updateCountdown();
SMF.attachSearch();
// Sidebar brand link
document.querySelectorAll('[data-route="home"]').forEach(a => {
a.addEventListener('click', (e) => {
e.preventDefault();
location.hash = '';
});
});
// Initial route
await route();
// Build search index in background
setTimeout(() => SMF.buildSearchIndex().catch(console.warn), 500);
}
window.addEventListener('hashchange', route);
window.addEventListener('DOMContentLoaded', init);
if (document.readyState !== 'loading') init();

161
app/js/data.js Normal file
View File

@@ -0,0 +1,161 @@
// =====================================================
// Data — uker, temaer, og metadata
// =====================================================
const COURSE = {
code: 'SMF2290',
name: 'Etikk, bærekraft og samfunnsansvar',
semester: 'Vår 2026',
lecturer: 'Martina Ortová',
exam: {
date: '2026-06-01',
time: '09:00',
duration_hours: 4,
platform: 'Inspera Assessment',
location: 'Campus Gjøvik',
aids: 'Kode E'
}
};
const WEEKS = [
{ id: 2, title: 'Introduksjon', theme: 'baerekraft', file: 'uke02-03-04.md', section: '#uke-2--introduksjon' },
{ id: 3, title: 'Etikk 1', theme: 'etikk', file: 'uke02-03-04.md', section: '#uke-3--etikk-1-diskusjons-uke' },
{ id: 4, title: 'Etikk 2', theme: 'etikk', file: 'uke02-03-04.md', section: '#uke-4--etikk-2' },
{ id: 5, title: 'Etikk 3', theme: 'etikk', file: 'uke05-06.md', section: '#uke-5--etikk-3' },
{ id: 6, title: 'Institusjon & Stakeholder', theme: 'samfunn', file: 'uke05-06.md', section: '#uke-6--institusjon-og-stakeholder' },
{ id: 7, title: 'Stakeholder & Historisk linje', theme: 'samfunn', file: 'uke07-08.md', section: '#uke-7--stakeholder-historisk-linje-og-fn' },
{ id: 8, title: 'Carroll, CSR & CSV', theme: 'samfunn', file: 'uke07-08.md', section: '#uke-8--carrolls-pyramide-csr-vs-csv-og-eksternaliteter-kap-8' },
{ id: 10, title: 'Sosial pilar', theme: 'samfunn', file: 'uke10-11.md', section: '#uke-10--den-sosiale-delen-av-csr' },
{ id: 11, title: 'Sirkulær økonomi & ESG', theme: 'baerekraft', file: 'uke10-11.md', section: '#uke-11--planetens-talegrenser-sirkulær-økonomi-og-esg-investeringer' },
{ id: 12, title: 'Verktøykasse', theme: 'verktoy', file: 'uke12-16.md', section: '#uke-12--verktøykasse-for-etikk-samfunnsansvar-og-bærekraft' },
{ id: 16, title: 'Implementering', theme: 'verktoy', file: 'uke12-16.md', section: '#uke-16--implementering' },
{ id: 17, title: 'Eksamen & oppsummering', theme: 'all', file: 'uke17-eksamen.md', section: '' }
];
const THEMES = {
etikk: {
id: 'etikk',
label: 'Etikk',
title: 'Etikk',
eyebrow: 'Oppgave I',
color: 'var(--theme-etikk)',
weeks: [3, 4, 5],
intro: 'Etikk er systematisk refleksjon over hva som er rett og galt i omgang mellom mennesker — et fag du lærer gjennom studier og trening i å anvende prinsipper og begreper.',
keyConcepts: [
{ term: 'Moral vs Etikk', def: 'Moral = personlige/felles oppfatninger av rett og galt, læres i samhandling. Etikk = systematisk refleksjon over moral, læres som fag.' },
{ term: 'Normativ etikk', def: 'Hva man bør gjøre — søker normer og prinsipper. Inkluderer konsekvensetikk, pliktetikk og dydsetikk.' },
{ term: 'Deskriptiv etikk', def: 'Hva folk faktisk mener er rett og galt — beskrivelse av moralpraksis.' },
{ term: 'Etisk dilemma', def: 'Situasjon hvor du må velge mellom alternativer som alle har gode etiske argumenter for seg — ingen er åpenbart riktige.' },
{ term: 'De 6 etiske prinsipper', def: 'Likhet, Autonomi, Velgjørenhet, Ikke-skade, Rettferdighet, Føre-var.' },
{ term: 'Konsekvensetikk (Mill)', def: 'Handlinger bedømmes ut fra konsekvensene. Utilitarisme: mest mulig lykke for flest mulig.' },
{ term: 'Pliktetikk (Kant)', def: 'Handlinger bedømmes ut fra plikt og prinsipp, ikke konsekvenser. Det kategoriske imperativ.' },
{ term: 'Dydsetikk (Aristoteles)', def: 'Fokus på karakter og dyder. Hva slags person ønsker du å være? Den gylne middelvei.' },
{ term: 'Diskursetikk', def: 'Etikk gjennom dialog og argumentasjon mellom likeverdige parter — Habermas.' },
{ term: 'Kvalnes Navigasjonshjul', def: '6-perspektiv-verktøy for etisk beslutning: Juss, Identitet, Etikk, Økonomi, Omdømme, Moral.' }
]
},
baerekraft: {
id: 'baerekraft',
label: 'Bærekraft',
title: 'Bærekraft',
eyebrow: 'Oppgave II',
color: 'var(--theme-baerekraft)',
weeks: [2, 11, 16],
intro: 'Bærekraftig utvikling er utvikling som imøtekommer dagens behov uten å ødelegge mulighetene for at kommende generasjoner skal få dekket sine behov (Brundtland 1987).',
keyConcepts: [
{ term: 'Brundtland-definisjonen (1987)', def: '«Utvikling som tilfredsstiller dagens behov uten å ødelegge fremtidige generasjoners muligheter for å tilfredsstille sine behov.»' },
{ term: 'Triple Bottom Line (Elkington 1994)', def: 'People, Planet, Profit — bedrifter skal måles på sosiale, miljømessige og økonomiske resultater samtidig.' },
{ term: 'De tre pilarene', def: 'Sosial (mennesker), Miljø (planet), Økonomi (profit). Snitt = bærekraft.' },
{ term: 'Svak vs Sterk bærekraft', def: 'Svak: natur kan erstattes av kapital. Sterk: naturkapital er kritisk og kan ikke erstattes.' },
{ term: 'Planetens tålegrenser', def: 'Stockholm Resilience Centre 2009 — 9 grenser som regulerer planetens stabilitet. Flere er allerede overskredet.' },
{ term: 'Sirkulær økonomi', def: 'Lukket loop: råmaterialer → bærekraftig design → produksjon → distribusjon → bruk/gjenbruk → innsamling → resirkulering.' },
{ term: '10R-rammeverket (Kirchher)', def: 'Refuse, Rethink, Reduce, Reuse, Repair, Refurbish, Remanufacture, Repurpose, Recycle, Recover.' },
{ term: 'Smultringmodellen (Raworth 2017)', def: 'Sosialt fundament (innside) og økologisk tak (utside). Trygt og rettferdig rom mellom dem.' },
{ term: 'FN 17 bærekraftsmål (SDG)', def: 'Vedtatt 2015. 169 delmål. Felles globalt rammeverk for utvikling fram til 2030.' },
{ term: 'Motvekst (Degrowth)', def: 'Bevegelse som utfordrer uendelig økonomisk vekst. 4 pilarer: ressursbevissthet, rettferdighet, lokalisering, livskvalitet.' }
]
},
samfunn: {
id: 'samfunn',
label: 'Samfunnsansvar',
title: 'Samfunnsansvar',
eyebrow: 'Oppgave III',
color: 'var(--theme-samfunn)',
weeks: [6, 7, 8, 10],
intro: 'CSR — Corporate Social Responsibility — er det ansvaret selskaper påtar seg for miljø, samfunn og mennesker som påvirkes av virksomheten, utover det som er lovpålagt.',
keyConcepts: [
{ term: 'CSR (Corporate Social Responsibility)', def: 'Ansvar selskaper påtar seg for miljø, samfunn og mennesker — utover det lovpålagte.' },
{ term: 'CSV (Creating Shared Value)', def: 'Porter & Kramer 2006. Integrert i strategi — kobler samfunnsforbedring til økonomisk verdiskaping.' },
{ term: 'CSR vs CSV', def: 'CSR drives ofte eksternt og er "ved siden av" kjernevirksomheten. CSV er strategi som skaper felles verdi.' },
{ term: 'Carrolls pyramide (1991)', def: '4 nivåer nedenfra: Økonomisk (vær lønnsom), Juridisk (adlyd loven), Etisk (vær etisk), Filantropisk (vær god bedriftsborger).' },
{ term: 'Friedman (1970)', def: '"The social responsibility of business is to increase its profits." Bedriftens eneste ansvar er å maksimere aksjonærverdi innenfor lovens rammer.' },
{ term: 'Stakeholder-teori (Freeman 1984)', def: 'God ledelse balanserer hensynet til alle som påvirkes av eller påvirker virksomheten — ikke bare aksjonærer.' },
{ term: 'Mitchells modell (1997)', def: '3 attributter: Makt, Legitimitet, Hastverk. Gir 7 typer interessenter (avgjørende = alle 3).' },
{ term: 'Institusjonell teori', def: 'Forklarer hvorfor organisasjoner blir like — press fra lover, normer og kulturelle koder.' },
{ term: 'Isomorfisme', def: 'Prosessen der organisasjoner blir mer like hverandre over tid pga press fra institusjonene.' },
{ term: 'Eksternaliteter', def: 'Kostnader eller fordeler som påvirker tredjeparter — uten å fanges av markedstransaksjonene. Kan være positive eller negative.' },
{ term: 'Sosial søyle (intern/ekstern)', def: 'Intern: obligatorisk sosialpolitikk + avtalt + frivillig (CSR). Ekstern: filantropi, Cause Related Marketing.' }
]
},
verktoy: {
id: 'verktoy',
label: 'Verktøy & implementering',
title: 'Verktøy',
eyebrow: 'Oppgave IV · Case',
color: 'var(--theme-verktoy)',
weeks: [12, 16],
intro: 'For å gå fra teori til praksis trenger bedrifter konkrete verktøy: kodekser, standarder, rapportering, og en strukturert implementeringsprosess fra planlegging til oppfølging.',
keyConcepts: [
{ term: 'Obligatoriske vs frivillige verktøy', def: 'Obligatorisk: lover, regulering, rapporteringskrav (CSRD, Åpenhetsloven). Frivillig: ISO, GRI, miljømerker.' },
{ term: 'Code of Conduct', def: 'Etisk kodeks — interne retningslinjer for atferd. Eksempel: Telenors Code of Conduct.' },
{ term: 'ISO 26000', def: 'Internasjonal standard for samfunnsansvar. Veiledende, ikke sertifiserbar.' },
{ term: 'ISO 14001 / 9001', def: '14001: miljøledelse. 9001: kvalitetsledelse. Begge er sertifiserbare.' },
{ term: 'GRI (Global Reporting Initiative)', def: 'Globalt rammeverk for bærekraftsrapportering. Standardiserer hva og hvordan man rapporterer.' },
{ term: 'CSRD', def: 'EUs Corporate Sustainability Reporting Directive. Krav om bærekraftsrapportering fra 2024/2025/2026 avhengig av størrelse.' },
{ term: 'Dobbel vesentlighet', def: 'CSRD-prinsipp: rapporter både hvordan virksomheten påvirker omgivelsene (impact) og hvordan bærekraft påvirker virksomheten (financial).' },
{ term: 'Green Deal', def: 'EUs strategi for å gjøre Europa klimanøytralt innen 2050. Bred politikkpakke som CSRD og taksonomien er del av.' },
{ term: 'EUs taksonomi', def: 'Klassifiseringssystem for hvilke økonomiske aktiviteter som er miljømessig bærekraftige.' },
{ term: 'Åpenhetsloven (2022)', def: 'Norsk lov: virksomheter må gjennomføre aktsomhetsvurderinger og rapportere om menneskerettigheter og arbeidsforhold.' },
{ term: '5 implementeringssteg', def: 'Planlegging → Interessentdialog → Rapportering → Verifikasjon → Oppfølging.' },
{ term: 'SRI (Social Responsible Investments)', def: 'Investeringsstrategi som integrerer etiske, miljømessige og sosiale betraktninger. Mål: avkastning + samfunnsansvar.' }
]
}
};
// Mapping from theme to color name
const THEME_COLORS = {
etikk: { color: '#D89AA2', soft: '#4A2027', bg: '#2A1A1D' },
baerekraft: { color: '#9CC089', soft: '#2D4029', bg: '#1A2419' },
samfunn: { color: '#E6B777', soft: '#5A3D17', bg: '#2A2014' },
verktoy: { color: '#A2BDD9', soft: '#1F3149', bg: '#161E2A' },
all: { color: '#E07B5F', soft: '#5A2317', bg: '#2A1A14' }
};
function themeOf(weekId) {
const w = WEEKS.find(x => x.id === weekId);
return w ? w.theme : 'all';
}
function getTheme(id) {
return THEMES[id];
}
// Countdown
function daysToExam() {
const exam = new Date('2026-06-01T09:00:00');
const now = new Date();
const diffMs = exam - now;
if (diffMs <= 0) return { days: 0, hours: 0, label: 'I dag!' };
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs / (1000 * 60 * 60)) % 24);
return { days, hours, label: days === 0 ? `${hours}t` : days === 1 ? '1 dag' : `${days} dager` };
}
window.SMF = window.SMF || {};
SMF.COURSE = COURSE;
SMF.WEEKS = WEEKS;
SMF.THEMES = THEMES;
SMF.THEME_COLORS = THEME_COLORS;
SMF.themeOf = themeOf;
SMF.getTheme = getTheme;
SMF.daysToExam = daysToExam;

125
app/js/eksamen.js Normal file
View File

@@ -0,0 +1,125 @@
// =====================================================
// Eksamenstrener — practice questions with guided answers
// =====================================================
let examData = null;
let examFilter = 'all';
async function examLoad() {
if (examData) return examData;
try {
const res = await fetch('data/exam.json');
if (!res.ok) throw new Error('Not ready');
examData = await res.json();
} catch (e) {
console.warn('Eksamen-data ikke klar ennå:', e.message);
examData = [];
}
return examData;
}
// Normalize category names — agents sometimes used "samfunnsansvar"
function normalizeCat(cat) {
if (cat === 'samfunnsansvar') return 'samfunn';
return cat;
}
function examFilteredQuestions() {
if (examFilter === 'all') return examData;
return examData.filter(q => normalizeCat(q.category) === examFilter);
}
function examCategoryLabel(cat) {
return {
etikk: 'Oppgave I · Etikk',
baerekraft: 'Oppgave II · Bærekraft',
samfunn: 'Oppgave III · Samfunnsansvar',
case: 'Oppgave IV · Case-drøfting'
}[normalizeCat(cat)] || cat;
}
function examCategoryColor(cat) {
return {
etikk: 'var(--theme-etikk)',
baerekraft: 'var(--theme-baerekraft)',
samfunn: 'var(--theme-samfunn)',
case: 'var(--theme-verktoy)'
}[normalizeCat(cat)] || 'var(--accent)';
}
function examRender() {
const container = document.getElementById('examQuestions');
if (!container) return;
// Filter chips at top
container.innerHTML = `
<div class="fc-filters reveal" id="examFilters" style="margin-bottom:var(--sp-7)">
<button class="fc-filter ${examFilter==='all'?'fc-filter--active':''}" data-filter="all">Alle</button>
<button class="fc-filter ${examFilter==='etikk'?'fc-filter--active':''}" data-filter="etikk">Etikk</button>
<button class="fc-filter ${examFilter==='baerekraft'?'fc-filter--active':''}" data-filter="baerekraft">Bærekraft</button>
<button class="fc-filter ${examFilter==='samfunn'?'fc-filter--active':''}" data-filter="samfunn">Samfunnsansvar</button>
<button class="fc-filter ${examFilter==='case'?'fc-filter--active':''}" data-filter="case">Case</button>
</div>
<div id="examList"></div>
`;
const list = document.getElementById('examList');
const questions = examFilteredQuestions();
let counter = 0;
questions.forEach(q => {
counter++;
const num = String(counter).padStart(2, '0');
const color = examCategoryColor(q.category);
const div = document.createElement('div');
div.className = 'exam-q reveal';
div.innerHTML = `
<div class="exam-q__head">
<div class="exam-q__num" style="color:${color}">${num}</div>
<div class="exam-q__head-info">
<span class="exam-q__category">${examCategoryLabel(q.category)}</span>
<span class="exam-q__weight">${q.title || ''}</span>
</div>
</div>
<p class="exam-q__sub">${q.question}</p>
${q.checklist ? `
<h4 style="margin-top:var(--sp-5); font-family:var(--f-mono); font-size:var(--s-0); letter-spacing:0.15em; text-transform:uppercase; color:var(--muted); font-weight:600">Hva må svaret inneholde</h4>
<ul style="margin-top:var(--sp-2); padding-left:var(--sp-5)">
${q.checklist.map(c => `<li>${c}</li>`).join('')}
</ul>
` : ''}
<button class="exam-q__toggle" data-toggle>Vis veiledet svar ↓</button>
<div class="exam-q__reveal" style="display:none" data-answer>
<div class="exam-q__reveal-label">Veiledet svar</div>
<div class="markdown-content">${SMF.renderMarkdown(q.guidedAnswer || '')}</div>
${q.tips ? `<div style="margin-top:var(--sp-4); padding-top:var(--sp-3); border-top:1px solid var(--line); font-family:var(--f-display); font-style:italic; font-size:var(--s-2); color:var(--ink-2)"><strong style="font-family:var(--f-mono); font-size:0.6875rem; letter-spacing:0.15em; text-transform:uppercase; color:var(--accent); font-weight:600; font-style:normal; display:block; margin-bottom:6px">Tips</strong>${q.tips}</div>` : ''}
</div>
`;
list.appendChild(div);
});
// Attach toggles
list.querySelectorAll('[data-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
const reveal = btn.nextElementSibling;
const isOpen = reveal.style.display !== 'none';
reveal.style.display = isOpen ? 'none' : 'block';
btn.textContent = isOpen ? 'Vis veiledet svar ↓' : 'Skjul veiledet svar ↑';
});
});
// Attach filters
document.getElementById('examFilters').addEventListener('click', (e) => {
const f = e.target.closest('[data-filter]');
if (f) {
examFilter = f.dataset.filter;
examRender();
}
});
}
async function examInit() {
await examLoad();
examRender();
}
SMF.examInit = examInit;

196
app/js/flashcards.js Normal file
View File

@@ -0,0 +1,196 @@
// =====================================================
// Flashcards — flippable cards with category filter
// =====================================================
let fcData = null;
let fcState = {
filter: 'all',
index: 0,
flipped: false,
cards: [],
known: new Set(), // localStorage-backed
hard: new Set()
};
async function fcLoad() {
if (fcData) return fcData;
try {
const res = await fetch('data/flashcards.json');
fcData = await res.json();
} catch (e) {
console.error('Klarte ikke laste flashcards:', e);
fcData = [];
}
// Load saved state
try {
const saved = JSON.parse(localStorage.getItem('smf-fc-state') || '{}');
if (saved.known) fcState.known = new Set(saved.known);
if (saved.hard) fcState.hard = new Set(saved.hard);
} catch {}
return fcData;
}
function fcSaveState() {
localStorage.setItem('smf-fc-state', JSON.stringify({
known: [...fcState.known],
hard: [...fcState.hard]
}));
}
function fcFilterCards(filter) {
if (filter === 'all') return fcData;
if (filter === 'hard') return fcData.filter(c => fcState.hard.has(c.id));
if (filter === 'new') return fcData.filter(c => !fcState.known.has(c.id) && !fcState.hard.has(c.id));
return fcData.filter(c => c.category === filter);
}
function fcSetFilter(filter) {
fcState.filter = filter;
fcState.cards = fcFilterCards(filter);
// shuffle once when filter changes
fcShuffle(fcState.cards);
fcState.index = 0;
fcState.flipped = false;
fcRender();
}
function fcShuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
function fcRender() {
const total = fcState.cards.length;
document.getElementById('fcCurrent').textContent = total > 0 ? (fcState.index + 1) : 0;
document.getElementById('fcTotal').textContent = total;
document.getElementById('fcKnown').textContent = fcState.known.size;
const card = document.getElementById('fcCard');
if (total === 0) {
document.getElementById('fcFront').textContent = 'Ingen kort i denne kategorien';
document.getElementById('fcBack').textContent = 'Prøv et annet filter';
document.getElementById('fcCategory').textContent = '—';
card.classList.remove('fc-card--flipped');
return;
}
const c = fcState.cards[fcState.index];
document.getElementById('fcFront').textContent = c.front;
document.getElementById('fcBack').innerHTML = c.back;
document.getElementById('fcCategory').textContent = (c.subcategory || categoryLabel(c.category));
card.classList.toggle('fc-card--flipped', fcState.flipped);
}
function categoryLabel(cat) {
return {
etikk: 'Etikk',
baerekraft: 'Bærekraft',
samfunn: 'Samfunnsansvar',
verktoy: 'Verktøy',
case: 'Case'
}[cat] || cat;
}
function fcRenderFilters() {
const container = document.getElementById('fcFilters');
if (!container) return;
const filters = [
{ id: 'all', label: 'Alle' },
{ id: 'new', label: 'Nye' },
{ id: 'hard', label: 'Glemt' },
{ id: 'etikk', label: 'Etikk' },
{ id: 'baerekraft', label: 'Bærekraft' },
{ id: 'samfunn', label: 'Samfunnsansvar' },
{ id: 'verktoy', label: 'Verktøy' },
{ id: 'case', label: 'Case' }
];
container.innerHTML = filters.map(f => {
const active = fcState.filter === f.id ? 'fc-filter--active' : '';
return `<button class="fc-filter ${active}" data-filter="${f.id}">${f.label}</button>`;
}).join('');
}
function fcNext() {
if (!fcState.cards.length) return;
fcState.flipped = false;
fcState.index = (fcState.index + 1) % fcState.cards.length;
fcRender();
}
function fcPrev() {
if (!fcState.cards.length) return;
fcState.flipped = false;
fcState.index = (fcState.index - 1 + fcState.cards.length) % fcState.cards.length;
fcRender();
}
function fcMark(level) {
if (!fcState.cards.length) return;
const c = fcState.cards[fcState.index];
if (level === 'hard') {
fcState.hard.add(c.id);
fcState.known.delete(c.id);
} else if (level === 'good') {
fcState.hard.delete(c.id);
} else if (level === 'easy') {
fcState.known.add(c.id);
fcState.hard.delete(c.id);
}
fcSaveState();
fcNext();
}
async function fcInit() {
await fcLoad();
fcState.cards = fcFilterCards(fcState.filter);
fcShuffle(fcState.cards);
fcState.index = 0;
fcState.flipped = false;
fcRenderFilters();
fcRender();
// Card click → flip
const card = document.getElementById('fcCard');
card.addEventListener('click', () => {
fcState.flipped = !fcState.flipped;
fcRender();
});
document.getElementById('fcNext').addEventListener('click', (e) => { e.stopPropagation(); fcNext(); });
document.getElementById('fcPrev').addEventListener('click', (e) => { e.stopPropagation(); fcPrev(); });
document.getElementById('fcHard').addEventListener('click', (e) => { e.stopPropagation(); fcMark('hard'); });
document.getElementById('fcGood').addEventListener('click', (e) => { e.stopPropagation(); fcMark('good'); });
document.getElementById('fcEasy').addEventListener('click', (e) => { e.stopPropagation(); fcMark('easy'); });
document.getElementById('fcFilters').addEventListener('click', (e) => {
const btn = e.target.closest('[data-filter]');
if (btn) {
fcSetFilter(btn.dataset.filter);
fcRenderFilters();
}
});
// Keyboard
const handler = (e) => {
if (location.hash !== '#/flashcards') {
document.removeEventListener('keydown', handler);
return;
}
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); fcState.flipped = !fcState.flipped; fcRender(); }
else if (e.key === 'ArrowRight') { e.preventDefault(); fcNext(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); fcPrev(); }
else if (e.key === '1') { fcMark('hard'); }
else if (e.key === '2') { fcMark('good'); }
else if (e.key === '3') { fcMark('easy'); }
};
document.addEventListener('keydown', handler);
}
SMF.fcInit = fcInit;

225
app/js/quiz.js Normal file
View File

@@ -0,0 +1,225 @@
// =====================================================
// Quiz — multiple choice with explanations
// =====================================================
let quizData = null;
let quizState = {
filter: 'all',
questions: [],
current: 0,
selected: null,
answered: false,
correct: 0,
wrong: 0,
finished: false
};
async function quizLoad() {
if (quizData) return quizData;
try {
const res = await fetch('data/quiz.json');
quizData = await res.json();
} catch (e) {
console.error('Klarte ikke laste quiz:', e);
quizData = [];
}
return quizData;
}
function quizFilterQuestions(filter) {
if (filter === 'all') return [...quizData];
return quizData.filter(q => q.category === filter);
}
function quizShuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function quizRenderStart() {
const stage = document.getElementById('quizStage');
const counts = {
all: quizData.length,
etikk: quizData.filter(q => q.category === 'etikk').length,
baerekraft: quizData.filter(q => q.category === 'baerekraft').length,
samfunn: quizData.filter(q => q.category === 'samfunn').length,
verktoy: quizData.filter(q => q.category === 'verktoy').length
};
stage.innerHTML = `
<header class="lesson-header reveal" style="text-align:center; border:none; padding-bottom:var(--sp-5)">
<div class="lesson-header__num">Studiemodus</div>
<h1 class="lesson-header__title"><em>Selvtest</em></h1>
<div class="lesson-header__meta" style="justify-content:center"><span>Velg kategori — flervalg med forklaringer</span></div>
</header>
<div class="themes__grid reveal" style="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--sp-4); margin-top: var(--sp-7)">
<button class="theme-card" data-cat="all" style="--theme-color: var(--accent); --theme-color-bg: transparent; text-align: left">
<div class="theme-card__eyebrow">Mix</div>
<h3 class="theme-card__title">Alle spørsmål</h3>
<div class="theme-card__foot"><span>${counts.all} spørsmål</span><span class="theme-card__arrow">Start →</span></div>
</button>
<button class="theme-card theme-card--etikk" data-cat="etikk">
<div class="theme-card__eyebrow">I</div>
<h3 class="theme-card__title"><em>Etikk</em></h3>
<div class="theme-card__foot"><span>${counts.etikk} spørsmål</span><span class="theme-card__arrow">Start →</span></div>
</button>
<button class="theme-card theme-card--baerekraft" data-cat="baerekraft">
<div class="theme-card__eyebrow">II</div>
<h3 class="theme-card__title"><em>Bærekraft</em></h3>
<div class="theme-card__foot"><span>${counts.baerekraft} spørsmål</span><span class="theme-card__arrow">Start →</span></div>
</button>
<button class="theme-card theme-card--samfunn" data-cat="samfunn">
<div class="theme-card__eyebrow">III</div>
<h3 class="theme-card__title">Samfunns<em>ansvar</em></h3>
<div class="theme-card__foot"><span>${counts.samfunn} spørsmål</span><span class="theme-card__arrow">Start →</span></div>
</button>
<button class="theme-card theme-card--verktoy" data-cat="verktoy">
<div class="theme-card__eyebrow">IV</div>
<h3 class="theme-card__title"><em>Verktøy</em></h3>
<div class="theme-card__foot"><span>${counts.verktoy} spørsmål</span><span class="theme-card__arrow">Start →</span></div>
</button>
</div>
`;
stage.querySelectorAll('[data-cat]').forEach(btn => {
btn.addEventListener('click', () => {
quizStart(btn.dataset.cat);
});
});
}
function quizStart(category) {
quizState.filter = category;
quizState.questions = quizShuffle(quizFilterQuestions(category));
quizState.current = 0;
quizState.selected = null;
quizState.answered = false;
quizState.correct = 0;
quizState.wrong = 0;
quizState.finished = false;
quizRenderCurrent();
}
function quizRenderCurrent() {
const stage = document.getElementById('quizStage');
if (quizState.finished) return quizRenderResult();
const total = quizState.questions.length;
const q = quizState.questions[quizState.current];
if (!q) return;
const progress = ((quizState.current) / total) * 100;
const letters = ['A', 'B', 'C', 'D', 'E'];
stage.innerHTML = `
<div class="quiz-bar"><div class="quiz-bar__fill" style="width:${progress}%"></div></div>
<div class="quiz-meta">
<span><strong>${quizState.current + 1}</strong> / ${total}</span>
<span>${categoryLabelQuiz(q.category)}</span>
<span>Riktig: <strong>${quizState.correct}</strong></span>
</div>
<h2 class="quiz-question reveal">${q.question}</h2>
<div class="quiz-options" id="quizOptions">
${q.options.map((opt, i) => `
<button class="quiz-option reveal" data-i="${i}">
<span class="quiz-option__letter">${letters[i]}</span>
<span>${opt}</span>
</button>
`).join('')}
</div>
<div id="quizExplanation"></div>
<div class="quiz-actions">
<button class="btn btn--ghost" id="quizCancel">Avslutt</button>
<button class="btn btn--primary" id="quizNext" disabled>Neste →</button>
</div>
`;
stage.querySelectorAll('.quiz-option').forEach(opt => {
opt.addEventListener('click', () => {
if (quizState.answered) return;
const i = parseInt(opt.dataset.i, 10);
quizSelect(i);
});
});
document.getElementById('quizNext').addEventListener('click', quizAdvance);
document.getElementById('quizCancel').addEventListener('click', () => {
if (confirm('Avslutte testen?')) quizRenderStart();
});
}
function quizSelect(i) {
const q = quizState.questions[quizState.current];
quizState.selected = i;
quizState.answered = true;
const opts = document.querySelectorAll('.quiz-option');
opts.forEach((opt, idx) => {
opt.classList.add('quiz-option--disabled');
if (idx === q.correct) opt.classList.add('quiz-option--correct');
if (idx === i && i !== q.correct) opt.classList.add('quiz-option--wrong');
if (idx === i) opt.classList.add('quiz-option--selected');
});
if (i === q.correct) quizState.correct++;
else quizState.wrong++;
const exp = document.getElementById('quizExplanation');
exp.innerHTML = `
<div class="quiz-explanation reveal">
<div class="quiz-explanation__label">${i === q.correct ? 'Riktig — ' : 'Forklaring — '}</div>
<div>${q.explanation}</div>
</div>
`;
document.getElementById('quizNext').disabled = false;
}
function quizAdvance() {
quizState.current++;
quizState.selected = null;
quizState.answered = false;
if (quizState.current >= quizState.questions.length) {
quizState.finished = true;
}
quizRenderCurrent();
}
function quizRenderResult() {
const stage = document.getElementById('quizStage');
const total = quizState.questions.length;
const pct = total ? Math.round((quizState.correct / total) * 100) : 0;
let verdict, vColor;
if (pct >= 90) { verdict = 'Fremragende.'; vColor = 'var(--theme-baerekraft)'; }
else if (pct >= 80) { verdict = 'Meget god.'; vColor = 'var(--theme-baerekraft)'; }
else if (pct >= 65) { verdict = 'Solid forståelse.'; vColor = 'var(--theme-samfunn)'; }
else if (pct >= 50) { verdict = 'Greit, men trener videre.'; vColor = 'var(--theme-samfunn)'; }
else { verdict = 'Repeter mer av dette temaet.'; vColor = 'var(--theme-etikk)'; }
stage.innerHTML = `
<div class="quiz-result reveal">
<div class="quiz-result__score">${quizState.correct}<span style="color:var(--muted)">/${total}</span></div>
<div class="quiz-result__label">${pct} % riktig</div>
<div class="quiz-result__verdict" style="color:${vColor}">${verdict}</div>
<div class="quiz-actions" style="justify-content:center; margin-top: var(--sp-7)">
<button class="btn" id="quizRestart">Test på nytt</button>
<button class="btn btn--primary" id="quizMore">Andre kategori</button>
</div>
</div>
`;
document.getElementById('quizRestart').addEventListener('click', () => quizStart(quizState.filter));
document.getElementById('quizMore').addEventListener('click', () => quizRenderStart());
}
function categoryLabelQuiz(cat) {
return {
etikk: 'Etikk',
baerekraft: 'Bærekraft',
samfunn: 'Samfunnsansvar',
verktoy: 'Verktøy & implementering'
}[cat] || cat;
}
async function quizInit() {
await quizLoad();
quizRenderStart();
}
SMF.quizInit = quizInit;

263
app/js/render.js Normal file
View File

@@ -0,0 +1,263 @@
// =====================================================
// Render — markdown loading and lesson rendering
// =====================================================
const noteCache = new Map();
async function loadNote(file) {
if (noteCache.has(file)) return noteCache.get(file);
const res = await fetch(`notes/${file}`);
if (!res.ok) throw new Error(`Klarte ikke laste ${file}`);
const text = await res.text();
noteCache.set(file, text);
return text;
}
// Split markdown by week sections — looking for headers like "## Uke X — ..." or "# UKE X"
function extractWeekSection(markdown, weekId) {
const lines = markdown.split('\n');
// Match #, ##, ### at start; "Uke" or "UKE"; the week id as separate token
const headerRe = new RegExp(`^#{1,3}\\s+UKE\\s+${weekId}(?!\\d)`, 'i');
let start = -1;
let startLevel = 0;
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(headerRe);
if (m) {
start = i;
// Count leading hashes
const hashMatch = lines[i].match(/^(#+)/);
startLevel = hashMatch ? hashMatch[1].length : 2;
break;
}
}
if (start === -1) return null;
// Find next header at same or higher level that starts a NEW week
const nextHeaderRe = new RegExp(`^#{1,${startLevel}}\\s+UKE\\s+\\d+`, 'i');
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (nextHeaderRe.test(lines[i])) { end = i; break; }
}
let section = lines.slice(start, end).join('\n');
// Strip the first heading entirely (we show the title in the lesson-header)
section = section.replace(/^#{1,3}\s+[^\n]+\n+/, '');
// Normalize remaining heading levels: bring everything to H2/H3/H4 starting fresh.
// Original starting level (after stripped header) was startLevel + 1 typically.
// We want the deepest visible header in the section to start at H2.
const subLines = section.split('\n');
// Find the minimum heading level inside section
let minLevel = Infinity;
for (const l of subLines) {
const m = l.match(/^(#{1,6})\s/);
if (m) minLevel = Math.min(minLevel, m[1].length);
}
if (minLevel === Infinity) return section;
const shift = 2 - minLevel; // bring minLevel up to 2
if (shift !== 0) {
section = subLines.map(l => {
const m = l.match(/^(#{1,6})\s/);
if (!m) return l;
const newLevel = Math.min(6, Math.max(1, m[1].length + shift));
return '#'.repeat(newLevel) + l.slice(m[1].length);
}).join('\n');
}
return section;
}
// Custom marked renderer for tighter HTML
function renderMarkdown(md) {
marked.setOptions({
gfm: true,
breaks: false,
headerIds: true,
mangle: false
});
return marked.parse(md);
}
// ============= Home view =============
function renderHome() {
const tpl = document.getElementById('t-home').content.cloneNode(true);
return tpl;
}
// ============= Lesson view =============
async function renderLesson(weekId) {
const tpl = document.getElementById('t-lesson').content.cloneNode(true);
const week = SMF.WEEKS.find(w => w.id === weekId);
if (!week) {
tpl.getElementById('lessonNum').textContent = 'Ikke funnet';
tpl.getElementById('lessonTitle').textContent = 'Ukjent uke';
return tpl;
}
const themeColors = SMF.THEME_COLORS[week.theme] || SMF.THEME_COLORS.all;
// Header
const num = tpl.getElementById('lessonNum');
num.textContent = `Uke ${String(weekId).padStart(2, '0')} · ${themeLabel(week.theme)}`;
num.style.color = themeColors.color;
const title = tpl.getElementById('lessonTitle');
title.innerHTML = formatTitleWithEm(week.title);
// Meta
const meta = tpl.getElementById('lessonMeta');
const tag = document.createElement('span');
tag.className = 'tag';
tag.textContent = themeLabel(week.theme);
tag.style.setProperty('--theme-color', themeColors.color);
tag.style.setProperty('--theme-color-bg', themeColors.bg);
meta.appendChild(tag);
// Body — load and render
const body = tpl.getElementById('lessonBody');
try {
const md = await loadNote(week.file);
let weekMd = extractWeekSection(md, weekId);
if (!weekMd) {
// Fallback: entire file (Uke 17 case)
weekMd = md.replace(/^#\s+[^\n]+\n+/, '');
}
body.innerHTML = renderMarkdown(weekMd);
} catch (e) {
body.innerHTML = `<p>Klarte ikke laste ukens innhold: ${e.message}</p>`;
}
// Pager
const idx = SMF.WEEKS.findIndex(w => w.id === weekId);
const prev = idx > 0 ? SMF.WEEKS[idx - 1] : null;
const next = idx < SMF.WEEKS.length - 1 ? SMF.WEEKS[idx + 1] : null;
const pager = tpl.getElementById('lessonPager');
if (prev) {
const a = document.createElement('a');
a.href = `#/uke/${prev.id}`;
a.className = 'pager-link pager-link--prev';
a.innerHTML = `<div class="pager-link__dir">← Forrige · Uke ${prev.id}</div><div class="pager-link__title">${prev.title}</div>`;
pager.appendChild(a);
} else {
pager.appendChild(spacer());
}
if (next) {
const a = document.createElement('a');
a.href = `#/uke/${next.id}`;
a.className = 'pager-link pager-link--next';
a.innerHTML = `<div class="pager-link__dir">Neste · Uke ${next.id} →</div><div class="pager-link__title">${next.title}</div>`;
pager.appendChild(a);
} else {
pager.appendChild(spacer());
}
return tpl;
}
function spacer() {
const d = document.createElement('div');
d.style.flex = '1';
return d;
}
function themeLabel(theme) {
return {
etikk: 'Etikk',
baerekraft: 'Bærekraft',
samfunn: 'Samfunnsansvar',
verktoy: 'Verktøy & implementering',
all: 'Oversikt'
}[theme] || theme;
}
function formatTitleWithEm(title) {
// Wrap "Etikk", "bærekraft" osv. in <em> for visual rhythm
const parts = title.split(/\s+/);
if (parts.length === 1) return `<em>${title}</em>`;
// pick the last word for em treatment
return parts.slice(0, -1).join(' ') + ' <em>' + parts.at(-1) + '</em>';
}
// ============= Tema view =============
async function renderTema(temaId) {
const tpl = document.getElementById('t-tema').content.cloneNode(true);
const tema = SMF.getTheme(temaId);
if (!tema) return tpl;
const themeColors = SMF.THEME_COLORS[temaId];
const header = tpl.getElementById('temaHeader');
header.innerHTML = `
<div class="lesson-header__num" style="color:${themeColors.color}">${tema.eyebrow}</div>
<h1 class="lesson-header__title">${tema.title}</h1>
<div class="lesson-header__meta">
<span>Uker: ${tema.weeks.join(' · ')}</span>
</div>
`;
const body = tpl.getElementById('temaBody');
// Intro blockquote
const intro = document.createElement('blockquote');
intro.textContent = tema.intro;
body.appendChild(intro);
// Concepts
const conceptsH = document.createElement('h2');
conceptsH.textContent = 'Kjernebegreper';
body.appendChild(conceptsH);
tema.keyConcepts.forEach(c => {
const wrap = document.createElement('div');
wrap.className = 'concept';
wrap.innerHTML = `<div class="concept__term">${c.term}</div><div class="concept__def">${c.def}</div>`;
body.appendChild(wrap);
});
// Weeks
const weeksH = document.createElement('h2');
weeksH.textContent = 'Uker i dette temaet';
body.appendChild(weeksH);
const weekList = document.createElement('div');
weekList.className = 'lesson-pager';
weekList.style.flexWrap = 'wrap';
weekList.style.borderTop = '0';
weekList.style.paddingTop = '0';
weekList.style.marginTop = '0';
tema.weeks.forEach(wid => {
const w = SMF.WEEKS.find(x => x.id === wid);
if (!w) return;
const a = document.createElement('a');
a.href = `#/uke/${w.id}`;
a.className = 'pager-link';
a.style.flexBasis = '260px';
a.style.flexGrow = '0';
a.innerHTML = `<div class="pager-link__dir">Uke ${w.id}</div><div class="pager-link__title">${w.title}</div>`;
weekList.appendChild(a);
});
body.appendChild(weekList);
return tpl;
}
// ============= TL;DR view =============
async function renderTldr() {
const tpl = document.getElementById('t-tldr').content.cloneNode(true);
const body = tpl.getElementById('tldrBody');
try {
const md = await loadNote('tldr.md');
// Strip the first H1 since we have it in the hero
const stripped = md.replace(/^#\s+[^\n]+\n+/, '');
body.innerHTML = renderMarkdown(stripped);
} catch (e) {
body.innerHTML = `<p>Klarte ikke laste tl;dr: ${e.message}</p>`;
}
return tpl;
}
SMF.renderHome = renderHome;
SMF.renderLesson = renderLesson;
SMF.renderTema = renderTema;
SMF.renderTldr = renderTldr;
SMF.loadNote = loadNote;
SMF.renderMarkdown = renderMarkdown;
SMF.extractWeekSection = extractWeekSection;
SMF.themeLabel = themeLabel;

202
app/js/search.js Normal file
View File

@@ -0,0 +1,202 @@
// =====================================================
// Search — index across all notes and concepts
// =====================================================
let searchIndex = null;
async function buildSearchIndex() {
if (searchIndex) return searchIndex;
const idx = [];
// Index concepts from THEMES
Object.values(SMF.THEMES).forEach(theme => {
theme.keyConcepts.forEach(c => {
idx.push({
type: 'concept',
category: theme.label,
title: c.term,
snippet: c.def,
href: `#/tema/${theme.id}`,
searchText: `${c.term} ${c.def}`.toLowerCase()
});
});
});
// Index weeks
SMF.WEEKS.forEach(w => {
idx.push({
type: 'uke',
category: SMF.themeLabel(w.theme),
title: `Uke ${w.id}: ${w.title}`,
snippet: '',
href: `#/uke/${w.id}`,
searchText: `uke ${w.id} ${w.title}`.toLowerCase()
});
});
// Load all notes and extract headings + paragraphs
const noteFiles = ['uke02-03-04.md', 'uke05-06.md', 'uke07-08.md', 'uke10-11.md', 'uke12-16.md', 'uke17-eksamen.md'];
for (const file of noteFiles) {
try {
const md = await SMF.loadNote(file);
const lines = md.split('\n');
let currentWeek = null;
let currentH = null;
let buffer = [];
const flush = () => {
if (buffer.length && currentH) {
const txt = buffer.join(' ').trim();
if (txt.length > 20) {
idx.push({
type: 'avsnitt',
category: currentWeek ? `Uke ${currentWeek}` : 'Pensum',
title: currentH,
snippet: txt.slice(0, 220) + (txt.length > 220 ? '…' : ''),
href: currentWeek ? `#/uke/${currentWeek}` : `#/uke/17`,
searchText: (currentH + ' ' + txt).toLowerCase()
});
}
}
buffer = [];
};
for (const line of lines) {
const ukeMatch = line.match(/^##\s+Uke\s+(\d+)/i);
if (ukeMatch) {
flush();
currentWeek = parseInt(ukeMatch[1], 10);
currentH = line.replace(/^#+\s+/, '');
continue;
}
const hMatch = line.match(/^#{3,4}\s+(.+)/);
if (hMatch) {
flush();
currentH = hMatch[1].trim();
continue;
}
if (line.trim() && !line.startsWith('|') && !line.startsWith('---')) {
buffer.push(line.replace(/[*_`]/g, ''));
}
}
flush();
} catch (e) {
console.warn('Could not load note for indexing:', file, e);
}
}
searchIndex = idx;
return idx;
}
function searchQuery(q) {
if (!searchIndex || !q || q.length < 2) return [];
const qLow = q.toLowerCase();
const terms = qLow.split(/\s+/).filter(t => t.length > 1);
if (!terms.length) return [];
const scored = [];
for (const item of searchIndex) {
let score = 0;
let titleHits = 0;
const titleLow = item.title.toLowerCase();
terms.forEach(t => {
if (titleLow.includes(t)) { score += 10; titleHits++; }
if (item.searchText.includes(t)) { score += 2; }
});
// Bonus: type-priority — concepts > weeks > paragraphs
if (item.type === 'concept') score += 3;
else if (item.type === 'uke') score += 2;
// Bonus: title contains whole query
if (titleLow.includes(qLow)) score += 8;
if (score > 0) scored.push({ item, score, titleHits });
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, 12).map(s => s.item);
}
function highlight(text, q) {
if (!q) return text;
const terms = q.toLowerCase().split(/\s+/).filter(t => t.length > 1);
let out = text;
terms.forEach(t => {
const re = new RegExp(`(${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
out = out.replace(re, '<mark>$1</mark>');
});
return out;
}
function attachSearch() {
const input = document.getElementById('searchInput');
const results = document.getElementById('searchResults');
if (!input || !results) return;
let timer = null;
input.addEventListener('input', async (e) => {
const q = e.target.value.trim();
if (!q || q.length < 2) {
results.classList.remove('search-results--open');
results.innerHTML = '';
return;
}
await buildSearchIndex();
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
const matches = searchQuery(q);
if (!matches.length) {
results.innerHTML = `<div class="search-result"><div class="search-result__title">Ingen treff på "${q}"</div></div>`;
} else {
results.innerHTML = matches.map(m => `
<a class="search-result" href="${m.href}">
<div class="search-result__type">${m.type} · ${m.category}</div>
<div class="search-result__title">${highlight(m.title, q)}</div>
${m.snippet ? `<div class="search-result__snippet">${highlight(m.snippet, q)}</div>` : ''}
</a>
`).join('');
}
results.classList.add('search-results--open');
}, 80);
});
input.addEventListener('focus', () => {
if (input.value.length >= 2) {
results.classList.add('search-results--open');
}
});
// Hide on outside click
document.addEventListener('click', (e) => {
if (!input.contains(e.target) && !results.contains(e.target)) {
results.classList.remove('search-results--open');
}
});
// Click result -> close
results.addEventListener('click', (e) => {
const a = e.target.closest('.search-result');
if (a) {
results.classList.remove('search-results--open');
input.value = '';
input.blur();
}
});
// Keyboard: '/' to focus
document.addEventListener('keydown', (e) => {
if (e.key === '/' && document.activeElement !== input && !e.metaKey && !e.ctrlKey) {
const tag = document.activeElement?.tagName;
if (tag !== 'INPUT' && tag !== 'TEXTAREA') {
e.preventDefault();
input.focus();
input.select();
}
}
if (e.key === 'Escape') {
results.classList.remove('search-results--open');
input.blur();
}
});
}
SMF.attachSearch = attachSearch;
SMF.buildSearchIndex = buildSearchIndex;