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:
parent
82dbb517bb
commit
c2e03dcdd1
15189
data/topics_index.json
Normal file
15189
data/topics_index.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -218,6 +218,50 @@
|
|||||||
}
|
}
|
||||||
#audio-bar .bar-transcript-btn:hover { border-color: var(--accent); color: var(--text); }
|
#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 { text-align: center; padding: 40px 20px; color: var(--text-muted); }
|
||||||
.welcome h2 { color: var(--text); margin-bottom: 8px; }
|
.welcome h2 { color: var(--text); margin-bottom: 8px; }
|
||||||
.welcome p { font-size: 13px; line-height: 1.6; }
|
.welcome p { font-size: 13px; line-height: 1.6; }
|
||||||
@ -237,6 +281,10 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1><span id="app-title">Podcast</span> Mindmap</h1>
|
<h1><span id="app-title">Podcast</span> Mindmap</h1>
|
||||||
<input type="search" class="search-box" id="search-input" placeholder="Transkripte durchsuchen…">
|
<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>
|
<div class="filter-bar" id="staffel-filters"></div>
|
||||||
</header>
|
</header>
|
||||||
<div id="mindmap"><svg id="svg"></svg></div>
|
<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-text">"${escHtml(q.verbatim || q.text)}"</div>
|
||||||
<div class="quote-meta">
|
<div class="quote-meta">
|
||||||
<span>${q.speaker} · ${q.episode}${timeStr ? ' · ' + timeStr : ''}${topBadge}</span>
|
<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>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@ -900,6 +949,296 @@ function fmtTime(sec) {
|
|||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// #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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
15189
webapp/topics_index.json
Normal file
15189
webapp/topics_index.json
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user