This commit is contained in:
team3
2026-06-24 07:19:01 +02:00
parent 9e888c216a
commit 12125d0f2d
2 changed files with 60 additions and 3 deletions

View File

@@ -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")

View File

@@ -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;