Adressen: Slug und Version im Pfad statt Parameter
Seite: /<slug>[/v<N>][/lauf|/buch|/lesen][/<Abschnitt>], Zurück-Taste je Abschnitt. API: /api/themen/<slug>/v<N>/… mit Version als Pflichtteil des Pfads; /api/topics entfällt. Slugs stehen in thema.json und werden beim Start nachgetragen. Unbekannte API-Pfade liefern 404 statt der Seite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -4,16 +4,38 @@ import { api } from '../api.js'
|
||||
import { render, zeit } from '../markdown.js'
|
||||
import Lesemodus from './Lesemodus.vue'
|
||||
|
||||
const props = defineProps({ topic: String, version: Number, laeuft: Boolean, fortschritt: Number })
|
||||
const props = defineProps({ slug: String, version: Number, tab: String, nr: String, laeuft: Boolean, fortschritt: Number })
|
||||
const emit = defineEmits(['tab', 'nr'])
|
||||
const daten = ref(null)
|
||||
const lesemodus = ref(false)
|
||||
const lesemodus = computed(() => props.tab === 'lesen')
|
||||
// Abschnittsnummer in der Adresse (3.4, 3, vorwort) ↔ Element-ID (a03-4, k03, vorwort)
|
||||
function idVonNr(nr) {
|
||||
if (!nr || !daten.value) return ''
|
||||
if (nr === 'vorwort') return 'vorwort'
|
||||
for (const k of daten.value.kapitel) {
|
||||
if (String(k.ord) === nr) return 'k' + k.id
|
||||
const a = k.abschnitte.find((x) => x.nr === nr)
|
||||
if (a) return 'a' + a.id
|
||||
}
|
||||
return ''
|
||||
}
|
||||
function nrVonId(id) {
|
||||
if (!id || !daten.value) return ''
|
||||
if (id === 'vorwort') return 'vorwort'
|
||||
for (const k of daten.value.kapitel) {
|
||||
if ('k' + k.id === id) return String(k.ord)
|
||||
const a = k.abschnitte.find((x) => 'a' + x.id === id)
|
||||
if (a) return a.nr
|
||||
}
|
||||
return ''
|
||||
}
|
||||
// Vollbild nur aus der Klickgeste heraus (Browser-Vorgabe); iOS-Safari kennt die API nicht.
|
||||
function lesemodusStart() {
|
||||
lesemodus.value = true
|
||||
emit('tab', 'lesen')
|
||||
document.documentElement.requestFullscreen?.().catch(() => {})
|
||||
}
|
||||
function lesemodusEnde() {
|
||||
lesemodus.value = false
|
||||
emit('tab', 'buch')
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
const navAuf = ref(false)
|
||||
@@ -39,25 +61,27 @@ let ladeSeq = 0
|
||||
let merkTimer = null
|
||||
function merken(id) {
|
||||
clearTimeout(merkTimer)
|
||||
merkTimer = setTimeout(() => api.positionSetzen(props.topic, { abschnitt: id }, props.version).catch(() => {}), 1500)
|
||||
merkTimer = setTimeout(() => api.positionSetzen(props.slug, props.version, { abschnitt: id }).catch(() => {}), 1500)
|
||||
}
|
||||
const cache = new Map() // Schlüssel: Abschnitt + Textlänge → unveränderte Abschnitte werden nicht neu gerendert
|
||||
|
||||
onUnmounted(() => { beobachter?.disconnect() })
|
||||
|
||||
async function laden() {
|
||||
if (!props.topic) { daten.value = null; return }
|
||||
if (!props.slug || !props.version) { daten.value = null; return }
|
||||
const seq = ++ladeSeq
|
||||
const [d, pos] = await Promise.all([api.guide(props.topic, props.version), api.position(props.topic, props.version).catch(() => ({}))])
|
||||
const [d, pos] = await Promise.all([api.buch(props.slug, props.version), api.position(props.slug, props.version).catch(() => ({}))])
|
||||
if (seq !== ladeSeq) return
|
||||
daten.value = d
|
||||
await nextTick()
|
||||
spy()
|
||||
// Deep-Link (#a03-1 / #k03) hat Vorrang vor der gemerkten Position; beides nur beim ersten Laden
|
||||
const ziel = location.hash.slice(1) || pos.abschnitt
|
||||
// Abschnitt in der Adresse hat Vorrang vor der gemerkten Position; beides nur beim ersten Laden
|
||||
const ziel = idVonNr(props.nr) || pos.abschnitt
|
||||
if (ziel && !aktiv.value) document.getElementById(ziel)?.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
watch(() => [props.topic, props.version], () => { aktiv.value = ''; cache.clear(); laden() }, { immediate: true })
|
||||
watch(() => [props.slug, props.version], () => { aktiv.value = ''; cache.clear(); laden() }, { immediate: true })
|
||||
// Zurück-Taste oder getippte Adresse: zum Abschnitt springen, wenn er nicht schon aktiv ist
|
||||
watch(() => props.nr, (n) => { const id = idVonNr(n); if (id && id !== aktiv.value) { aktiv.value = id; document.getElementById(id)?.scrollIntoView({ block: 'start' }) } })
|
||||
watch(() => props.laeuft, (l, alt) => { if (alt && !l) laden() })
|
||||
// Während eines Laufs nur nachladen, wenn sich der Fortschritt geändert hat
|
||||
watch(() => props.fortschritt, (n, alt) => { if (props.laeuft && n !== alt) laden() })
|
||||
@@ -94,7 +118,7 @@ const kapBadge = { geschrieben: ['gelb', 'geschrieben'], geprueft: ['blau', 'gep
|
||||
function spy() {
|
||||
beobachter?.disconnect()
|
||||
beobachter = new IntersectionObserver((es) => {
|
||||
for (const e of es) if (e.isIntersecting) { aktiv.value = e.target.id; merken(e.target.id) }
|
||||
for (const e of es) if (e.isIntersecting) { aktiv.value = e.target.id; merken(e.target.id); emit('nr', { nr: nrVonId(e.target.id), push: false }) }
|
||||
}, { root: lese.value, rootMargin: '0px 0px -75% 0px' })
|
||||
document.querySelectorAll('.buch [data-spy]').forEach((el) => beobachter.observe(el))
|
||||
}
|
||||
@@ -102,7 +126,7 @@ function springe(id) {
|
||||
navAuf.value = false
|
||||
aktiv.value = id
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
history.replaceState(null, '', location.pathname + location.search + '#' + id)
|
||||
emit('nr', { nr: nrVonId(id), push: true })
|
||||
}
|
||||
function taste(e) { if (e.key === 'Escape') navAuf.value = false }
|
||||
onMounted(() => window.addEventListener('keydown', taste))
|
||||
@@ -116,8 +140,8 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
|
||||
<button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button>
|
||||
<div class="werkzeuge">
|
||||
<button class="klein" title="Lesemodus: Abschnitt für Abschnitt" @click="lesemodusStart" :disabled="!abschnitte.length">Lesen ▶</button>
|
||||
<a class="knopf klein sekundaer" title="Als Markdown-Datei herunterladen" :href="api.guideMdUrl(topic, version)" download>MD</a>
|
||||
<a v-if="daten?.pdf" class="knopf klein sekundaer" title="Als PDF herunterladen" :href="api.pdfUrl(topic, version)" download>PDF</a>
|
||||
<a class="knopf klein sekundaer" title="Als Markdown-Datei herunterladen" :href="api.buchMdUrl(slug, version)" download>MD</a>
|
||||
<a v-if="daten?.pdf" class="knopf klein sekundaer" title="Als PDF herunterladen" :href="api.pdfUrl(slug, version)" download>PDF</a>
|
||||
</div>
|
||||
<template v-if="daten">
|
||||
<div class="meta">
|
||||
@@ -142,8 +166,8 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
|
||||
<div class="mobil-leiste">
|
||||
<button class="sekundaer klein" @click="navAuf = !navAuf">☰ Inhalt</button>
|
||||
<button class="klein" @click="lesemodusStart" :disabled="!abschnitte.length">Lesen ▶</button>
|
||||
<a class="knopf klein sekundaer" :href="api.guideMdUrl(topic, version)" download>MD</a>
|
||||
<a v-if="daten?.pdf" class="knopf klein sekundaer" :href="api.pdfUrl(topic, version)" download>PDF</a>
|
||||
<a class="knopf klein sekundaer" :href="api.buchMdUrl(slug, version)" download>MD</a>
|
||||
<a v-if="daten?.pdf" class="knopf klein sekundaer" :href="api.pdfUrl(slug, version)" download>PDF</a>
|
||||
</div>
|
||||
<article v-if="daten" class="buch">
|
||||
<h1>{{ daten.titel }}</h1>
|
||||
@@ -190,6 +214,6 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<Lesemodus v-if="lesemodus && daten" :daten="daten" :topic="topic" :version="version" @schliessen="lesemodusEnde" />
|
||||
<Lesemodus v-if="lesemodus && daten" :daten="daten" :slug="slug" :version="version" @schliessen="lesemodusEnde" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { render, zeit } from '../markdown.js'
|
||||
import { api } from '../api.js'
|
||||
|
||||
const props = defineProps({ daten: Object, topic: String, version: Number })
|
||||
const props = defineProps({ daten: Object, slug: String, version: Number })
|
||||
const emit = defineEmits(['schliessen'])
|
||||
const buehne = ref(null)
|
||||
const pos = ref(0)
|
||||
@@ -53,7 +53,7 @@ watch(pos, (p) => {
|
||||
buehne.value?.scrollTo({ top: 0 })
|
||||
clearTimeout(merkTimer)
|
||||
const key = screens.value[p]?.key
|
||||
if (key) merkTimer = setTimeout(() => api.positionSetzen(props.topic, { lesemodus: key }, props.version).catch(() => {}), 1500)
|
||||
if (key) merkTimer = setTimeout(() => api.positionSetzen(props.slug, props.version, { lesemodus: key }).catch(() => {}), 1500)
|
||||
})
|
||||
|
||||
function taste(e) {
|
||||
@@ -82,7 +82,7 @@ function wischEnde(e) {
|
||||
}
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', taste)
|
||||
const merk = (await api.position(props.topic, props.version).catch(() => ({}))).lesemodus
|
||||
const merk = (await api.position(props.slug, props.version).catch(() => ({}))).lesemodus
|
||||
// Alte Positionen waren ein Index, neue sind ein key.
|
||||
const i = typeof merk === 'string' ? screens.value.findIndex((s) => s.key === merk) : Number(merk || 0)
|
||||
pos.value = Math.min(Math.max(i, 0), screens.value.length - 1)
|
||||
|
||||
Reference in New Issue
Block a user