update
This commit is contained in:
@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.middleware.gzip import GZipMiddleware
|
||||||
|
|
||||||
from logsetup import setup_logging
|
from logsetup import setup_logging
|
||||||
|
|
||||||
@@ -22,9 +23,24 @@ async def lifespan(app: FastAPI):
|
|||||||
await close_db()
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
|
class CachedStatic(StaticFiles):
|
||||||
|
"""StaticFiles mit Cache-Control: gehashte Assets dauerhaft (immutable),
|
||||||
|
index.html nie cachen (verweist immer auf die aktuellen Asset-Hashes)."""
|
||||||
|
async def get_response(self, path, scope):
|
||||||
|
resp = await super().get_response(path, scope)
|
||||||
|
if path.startswith("assets/"):
|
||||||
|
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||||
|
else:
|
||||||
|
resp.headers["Cache-Control"] = "no-cache"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Creator", lifespan=lifespan)
|
app = FastAPI(title="Creator", lifespan=lifespan)
|
||||||
|
|
||||||
|
# gzip für JS/CSS-Bundle + große JSON-Antworten (~1,39 MB JS → ~400 KB).
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||||
|
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
|
||||||
if FRONTEND_DIST.exists():
|
if FRONTEND_DIST.exists():
|
||||||
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
|
app.mount("/", CachedStatic(directory=FRONTEND_DIST, html=True), name="frontend")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js'
|
import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js'
|
||||||
import { renderMarkdown } from '../markdown.js'
|
import { renderMarkdown } from '../markdown.js'
|
||||||
import { useChat } from '../composables/useChat.js'
|
import { useChat } from '../composables/useChat.js'
|
||||||
@@ -27,12 +27,47 @@ const loadError = ref(null)
|
|||||||
const scrollEl = ref(null)
|
const scrollEl = ref(null)
|
||||||
const lernstand = ref({}) // Prüfungs-Stand pro Baustein-Titel — VOR dem immediate-Watch (loadContent nutzt es)
|
const lernstand = ref({}) // Prüfungs-Stand pro Baustein-Titel — VOR dem immediate-Watch (loadContent nutzt es)
|
||||||
|
|
||||||
|
// --- Lazy-Render + Markdown-Cache: nur sichtbare Sections parsen, jede nur einmal.
|
||||||
|
// Behebt das Blockieren beim Öffnen (160× marked/highlight.js) und Re-Parse bei jedem Update. ---
|
||||||
|
const mdCache = new Map() // `${modus}:${num}` → html
|
||||||
|
const sichtbar = reactive({}) // num → true (bleibt true, sobald je sichtbar)
|
||||||
|
let mdObserver = null
|
||||||
|
|
||||||
|
function htmlFor(s) {
|
||||||
|
const key = `${props.ansichtModus}:${s.num}`
|
||||||
|
let h = mdCache.get(key)
|
||||||
|
if (h === undefined) {
|
||||||
|
h = renderMarkdown(props.ansichtModus === 'kompakt' ? (s.kompakt || s.md) : s.md)
|
||||||
|
mdCache.set(key, h)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sections rendern erst, wenn sie (fast) im Viewport sind — Observer auf den Scroll-Container.
|
||||||
|
function setupLazy() {
|
||||||
|
mdObserver?.disconnect()
|
||||||
|
if (!scrollEl.value) return
|
||||||
|
mdObserver = new IntersectionObserver((entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (!e.isIntersecting) continue
|
||||||
|
sichtbar[Number(e.target.dataset.num)] = true
|
||||||
|
mdObserver.unobserve(e.target)
|
||||||
|
}
|
||||||
|
}, { root: scrollEl.value, rootMargin: '800px 0px' })
|
||||||
|
for (const el of scrollEl.value.querySelectorAll('.section-card')) mdObserver.observe(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(content, () => nextTick(setupLazy))
|
||||||
|
onUnmounted(() => mdObserver?.disconnect())
|
||||||
|
|
||||||
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
|
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
|
||||||
|
|
||||||
async function loadContent() {
|
async function loadContent() {
|
||||||
content.value = null
|
content.value = null
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
lernstand.value = {}
|
lernstand.value = {}
|
||||||
|
mdCache.clear()
|
||||||
|
for (const k in sichtbar) delete sichtbar[k]
|
||||||
const g = props.previewGuide
|
const g = props.previewGuide
|
||||||
if (!g || g.status !== 'done') return
|
if (!g || g.status !== 'done') return
|
||||||
try {
|
try {
|
||||||
@@ -198,6 +233,7 @@ function extractContext() {
|
|||||||
<article
|
<article
|
||||||
v-for="s in ch.sections"
|
v-for="s in ch.sections"
|
||||||
:key="s.num"
|
:key="s.num"
|
||||||
|
:data-num="s.num"
|
||||||
:class="['section-card', lernstand[s.title]?.gemeistert ? 'gemeistert' : (lernstand[s.title]?.verstanden ? 'verstanden' : (lernstand[s.title]?.absolviert ? 'absolviert' : ''))]"
|
:class="['section-card', lernstand[s.title]?.gemeistert ? 'gemeistert' : (lernstand[s.title]?.verstanden ? 'verstanden' : (lernstand[s.title]?.absolviert ? 'absolviert' : ''))]"
|
||||||
>
|
>
|
||||||
<h3 :class="{ 'baustein-klick': istPruefbar(s) }" @click="istPruefbar(s) && openFokus(s, 'pruefung')">
|
<h3 :class="{ 'baustein-klick': istPruefbar(s) }" @click="istPruefbar(s) && openFokus(s, 'pruefung')">
|
||||||
@@ -208,7 +244,8 @@ function extractContext() {
|
|||||||
<span v-else-if="lernstand[s.title]?.absolviert" class="baustein-done" title="Prüfung bestanden">✓ Absolviert</span>
|
<span v-else-if="lernstand[s.title]?.absolviert" class="baustein-done" title="Prüfung bestanden">✓ Absolviert</span>
|
||||||
</template>
|
</template>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="section-body markdown" v-html="renderMarkdown(ansichtModus === 'kompakt' ? (s.kompakt || s.md) : s.md)"></div>
|
<div v-if="sichtbar[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
|
||||||
|
<div v-else class="section-body skeleton"></div>
|
||||||
<BausteinPanel
|
<BausteinPanel
|
||||||
v-if="istPruefbar(s)"
|
v-if="istPruefbar(s)"
|
||||||
mode="trigger"
|
mode="trigger"
|
||||||
@@ -444,6 +481,10 @@ function extractContext() {
|
|||||||
.section-card h3.baustein-klick { cursor: pointer; width: fit-content; }
|
.section-card h3.baustein-klick { cursor: pointer; width: fit-content; }
|
||||||
.section-card h3.baustein-klick:hover { color: var(--accent); }
|
.section-card h3.baustein-klick:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Platzhalter für noch nicht gerenderte Sections (Lazy-Render). Stabile Höhe,
|
||||||
|
damit der IntersectionObserver die folgenden Karten gestaffelt erkennt. */
|
||||||
|
.section-body.skeleton { min-height: 160px; }
|
||||||
|
|
||||||
.empty-preview {
|
.empty-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user