Webapp v3 + topics_index.json

Obsidian-style Backlinks, Soundbite-Export, Timeline-Ansicht.
Topic-Index: 728 Absätze getaggt, 14434 Querverweise.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-20 08:03:25 +02:00
parent 82dbb517bb
commit c2e03dcdd1
3 changed files with 30718 additions and 1 deletions

15189
data/topics_index.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -218,6 +218,50 @@
}
#audio-bar .bar-transcript-btn:hover { border-color: var(--accent); color: var(--text); }
/* ── Backlinks (Obsidian-style) ── */
.backlinks {
margin-top: 8px; padding: 8px 0;
border-top: 1px solid var(--border);
}
.backlinks-title {
font-size: 10px; color: var(--text-muted); text-transform: uppercase;
letter-spacing: 0.05em; margin-bottom: 6px;
}
.backlink {
font-size: 11px; color: var(--accent); cursor: pointer;
padding: 3px 8px; border-radius: 4px; display: block;
transition: background 0.15s;
}
.backlink:hover { background: var(--surface2); }
.backlink .bl-episode { font-weight: 600; }
.backlink .bl-preview { color: var(--text-muted); font-style: italic; }
.topic-tag {
display: inline-block; padding: 1px 7px; border-radius: 10px;
font-size: 9px; background: var(--surface2); color: var(--text-muted);
margin: 1px; border: 1px solid var(--border);
}
/* ── Soundbite Export ── */
.export-btn {
background: transparent; border: 1px solid var(--border);
color: var(--text-muted); padding: 3px 8px; border-radius: 4px;
font-size: 10px; cursor: pointer; transition: all 0.2s;
}
.export-btn:hover { border-color: var(--accent); color: var(--text); }
/* ── View Tabs ── */
.view-tabs {
display: flex; gap: 4px; margin-left: 12px;
}
.view-tab {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text-muted); padding: 4px 10px; border-radius: 4px;
font-size: 11px; cursor: pointer; transition: all 0.2s;
}
.view-tab:hover { border-color: var(--accent); }
.view-tab.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.welcome { text-align: center; padding: 40px 20px; color: var(--text-muted); }
.welcome h2 { color: var(--text); margin-bottom: 8px; }
.welcome p { font-size: 13px; line-height: 1.6; }
@ -237,6 +281,10 @@
<header>
<h1><span id="app-title">Podcast</span> Mindmap</h1>
<input type="search" class="search-box" id="search-input" placeholder="Transkripte durchsuchen…">
<div class="view-tabs">
<button class="view-tab active" id="tab-mindmap" onclick="switchView('mindmap')">Mindmap</button>
<button class="view-tab" id="tab-timeline" onclick="switchView('timeline')">Timeline</button>
</div>
<div class="filter-bar" id="staffel-filters"></div>
</header>
<div id="mindmap"><svg id="svg"></svg></div>
@ -879,7 +927,8 @@ function buildQuoteCard(q, color) {
<div class="quote-text">"${escHtml(q.verbatim || q.text)}"</div>
<div class="quote-meta">
<span>${q.speaker} · ${q.episode}${timeStr ? ' · ' + timeStr : ''}${topBadge}</span>
${hasAudio ? `<button class="play-btn" onclick="event.stopPropagation();playQuoteById('${q.id}')"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></button>` : ''}
${hasAudio ? `<button class="export-btn" onclick="event.stopPropagation();exportSoundbite('${q.id}')" title="Audio-Clip herunterladen">Clip ↓</button>
<button class="play-btn" onclick="event.stopPropagation();playQuoteById('${q.id}')"><svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></button>` : ''}
</div>
</div>`;
}
@ -900,6 +949,296 @@ function fmtTime(sec) {
function escHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ============================================================
// #2: Obsidian-style Backlinks
// ============================================================
let TOPICS = null;
async function loadTopics() {
if (TOPICS) return;
try {
const r = await fetch('topics_index.json');
TOPICS = await r.json();
} catch (e) {
try {
const r = await fetch('data/topics_index.json');
TOPICS = await r.json();
} catch (e2) { TOPICS = { tagged_paragraphs: {}, crossrefs: [] }; }
}
}
function buildBacklinks(episodeKey, paraIdx) {
if (!TOPICS || !TOPICS.crossrefs) return '';
const refs = TOPICS.crossrefs.filter(r =>
(r.source.episode === episodeKey && r.source.paragraph === paraIdx) ||
(r.target.episode === episodeKey && r.target.paragraph === paraIdx)
).slice(0, 5);
if (refs.length === 0) return '';
let html = '<div class="backlinks"><div class="backlinks-title">Verwandte Stellen</div>';
refs.forEach(ref => {
const other = (ref.source.episode === episodeKey && ref.source.paragraph === paraIdx)
? ref.target : ref.source;
const otherEpId = other.episode.split('-')[0];
const ep = DATA.episodes.find(e => e.id === otherEpId);
const epName = ep ? `${ep.id}: ${ep.title}` : other.episode;
// Get preview text
let preview = '';
if (TOPICS.tagged_paragraphs[other.episode] && TOPICS.tagged_paragraphs[other.episode][other.paragraph]) {
preview = TOPICS.tagged_paragraphs[other.episode][other.paragraph].text_preview;
}
html += `<span class="backlink" onclick="TranscriptView.show('${otherEpId}')">`;
html += `<span class="bl-episode">${epName}</span> `;
html += `<span class="bl-preview">${escHtml(preview.substring(0, 60))}…</span>`;
html += `</span>`;
});
html += '</div>';
return html;
}
function buildTopicTags(episodeKey, paraIdx) {
if (!TOPICS || !TOPICS.tagged_paragraphs[episodeKey]) return '';
const para = TOPICS.tagged_paragraphs[episodeKey][paraIdx];
if (!para || !para.tags || para.tags.length === 0) return '';
return '<div style="margin-top:4px">' +
para.tags.map(t => `<span class="topic-tag">${t.replace('_', ' ')}</span>`).join('') +
'</div>';
}
// Patch TranscriptView.show to include backlinks
const _origTranscriptShow = TranscriptView.show.bind(TranscriptView);
TranscriptView.show = async function(episodeId, seekTime) {
await loadTopics();
await _origTranscriptShow(episodeId, seekTime);
// Find the episode key in TOPICS
const epKey = Object.keys(TOPICS.tagged_paragraphs || {}).find(k => k.startsWith(episodeId));
if (!epKey) return;
// Add topic tags and backlinks to each paragraph
document.querySelectorAll('.transcript-para').forEach(el => {
const idx = parseInt(el.dataset.idx);
const tags = buildTopicTags(epKey, idx);
const links = buildBacklinks(epKey, idx);
if (tags || links) {
el.insertAdjacentHTML('beforeend', tags + links);
}
});
};
// ============================================================
// #6: Soundbite Export
// ============================================================
async function exportSoundbite(quoteId) {
const q = DATA.quotes.find(q => q.id === quoteId);
if (!q || !q.audioFile || q.startTime === null) return;
const btn = document.querySelector(`#card-${quoteId} .export-btn`);
if (btn) btn.textContent = 'Lädt…';
try {
const response = await fetch(`audio/${q.audioFile}`);
const arrayBuffer = await response.arrayBuffer();
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
const start = q.startTime;
const end = q.endTime || (q.startTime + 30);
const duration = end - start;
const sampleRate = audioBuffer.sampleRate;
const startSample = Math.floor(start * sampleRate);
const numSamples = Math.floor(duration * sampleRate);
// Create new buffer with just the clip
const channels = audioBuffer.numberOfChannels;
const clipBuffer = audioCtx.createBuffer(channels, numSamples, sampleRate);
for (let ch = 0; ch < channels; ch++) {
const src = audioBuffer.getChannelData(ch);
const dst = clipBuffer.getChannelData(ch);
for (let i = 0; i < numSamples; i++) {
dst[i] = src[startSample + i] || 0;
}
}
// Encode as WAV
const wav = encodeWAV(clipBuffer);
const blob = new Blob([wav], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${q.episode}-${q.speaker.replace(/\s+/g, '_')}-${Math.floor(start)}s.wav`;
a.click();
URL.revokeObjectURL(url);
if (btn) btn.textContent = 'Clip ↓';
audioCtx.close();
} catch (e) {
console.error('Export failed:', e);
if (btn) btn.textContent = 'Fehler';
}
}
function encodeWAV(buffer) {
const numChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const format = 1; // PCM
const bitDepth = 16;
const blockAlign = numChannels * bitDepth / 8;
const byteRate = sampleRate * blockAlign;
const dataSize = buffer.length * blockAlign;
const headerSize = 44;
const wav = new ArrayBuffer(headerSize + dataSize);
const view = new DataView(wav);
// RIFF header
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + dataSize, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, format, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitDepth, true);
writeString(view, 36, 'data');
view.setUint32(40, dataSize, true);
// Interleave samples
let offset = 44;
for (let i = 0; i < buffer.length; i++) {
for (let ch = 0; ch < numChannels; ch++) {
const sample = Math.max(-1, Math.min(1, buffer.getChannelData(ch)[i]));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
offset += 2;
}
}
return wav;
}
function writeString(view, offset, str) {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
}
// ============================================================
// #7: Timeline View
// ============================================================
let timelineBuilt = false;
function switchView(view) {
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
document.getElementById(`tab-${view}`).classList.add('active');
if (view === 'mindmap') {
document.getElementById('mindmap').style.display = '';
if (timelineBuilt) {
const tlEl = document.getElementById('timeline-container');
if (tlEl) tlEl.style.display = 'none';
}
} else if (view === 'timeline') {
document.getElementById('mindmap').style.display = 'none';
buildTimeline();
}
}
function buildTimeline() {
let container = document.getElementById('timeline-container');
if (!container) {
container = document.createElement('div');
container.id = 'timeline-container';
container.style.cssText = 'grid-row:2; overflow-y:auto; padding:20px; background:var(--bg);';
document.getElementById('mindmap').parentNode.insertBefore(container, document.getElementById('mindmap').nextSibling);
}
container.style.display = '';
let html = '<div style="max-width:900px;margin:0 auto">';
DATA.staffeln.forEach(staffel => {
const eps = DATA.episodes.filter(e => e.staffel === staffel.id);
html += `<div style="margin-bottom:24px">`;
html += `<h3 style="color:${staffel.color};font-size:14px;margin-bottom:12px">Staffel ${staffel.id}: ${staffel.name}</h3>`;
eps.forEach(ep => {
const quotes = DATA.quotes.filter(q => q.episode === ep.id);
const topQuotes = quotes.filter(q => q.isTopQuote);
html += `<div style="display:flex;gap:12px;margin-bottom:16px;align-items:flex-start">`;
// Episode marker
html += `<div style="min-width:60px;text-align:right">`;
html += `<div style="font-size:12px;font-weight:600;color:${staffel.color}">${ep.id}</div>`;
html += `<div style="font-size:10px;color:var(--text-muted)">${ep.guest}</div>`;
html += `</div>`;
// Timeline bar
html += `<div style="flex:1;position:relative">`;
html += `<div style="font-size:13px;font-weight:500;margin-bottom:4px;cursor:pointer" onclick="showEpisodeById('${ep.id}')">${ep.title}</div>`;
// Quote dots on timeline
html += `<div style="height:24px;background:var(--surface2);border-radius:12px;position:relative;overflow:hidden">`;
// Find max time for this episode
const maxTime = Math.max(...quotes.filter(q => q.endTime).map(q => q.endTime), 3600);
quotes.forEach(q => {
if (q.startTime === null) return;
const left = (q.startTime / maxTime) * 100;
const isTop = q.isTopQuote;
html += `<div style="position:absolute;left:${left}%;top:50%;transform:translate(-50%,-50%);
width:${isTop ? 10 : 6}px;height:${isTop ? 10 : 6}px;border-radius:50%;
background:${isTop ? '#f59e0b' : staffel.color};opacity:${isTop ? 1 : 0.5};
cursor:pointer" title="${escHtml((q.verbatim || q.text).substring(0, 60))}"
onclick="event.stopPropagation();showQuoteInPanel('${q.id}')"></div>`;
});
html += `</div>`; // timeline bar
// Top quotes below bar
if (topQuotes.length > 0) {
topQuotes.forEach(q => {
html += `<div style="font-size:11px;color:var(--text-muted);font-style:italic;margin-top:4px;padding-left:8px;border-left:2px solid #f59e0b">`;
html += `"${escHtml((q.verbatim || q.text).substring(0, 100))}…" <span style="color:var(--text)">${q.speaker}</span>`;
html += `</div>`;
});
}
html += `</div>`; // content
html += `</div>`; // row
});
html += `</div>`; // staffel
});
html += '</div>';
container.innerHTML = html;
timelineBuilt = true;
}
function showQuoteInPanel(quoteId) {
const q = DATA.quotes.find(q => q.id === quoteId);
if (!q) return;
const ep = DATA.episodes.find(e => e.id === q.episode);
if (ep) showEpisode(ep);
// Scroll to and highlight the quote card
setTimeout(() => {
const card = document.getElementById(`card-${quoteId}`);
if (card) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.style.outline = '2px solid var(--accent)';
setTimeout(() => card.style.outline = '', 2000);
}
}, 100);
}
</script>
</body>
</html>

15189
webapp/topics_index.json Normal file

File diff suppressed because it is too large Load Diff