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:
263
app/js/render.js
Normal file
263
app/js/render.js
Normal 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;
|
||||
Reference in New Issue
Block a user