update
This commit is contained in:
@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
|
||||
from logsetup import setup_logging
|
||||
|
||||
@@ -22,9 +23,24 @@ async def lifespan(app: FastAPI):
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
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>
|
||||
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 { renderMarkdown } from '../markdown.js'
|
||||
import { useChat } from '../composables/useChat.js'
|
||||
@@ -27,12 +27,47 @@ const loadError = ref(null)
|
||||
const scrollEl = ref(null)
|
||||
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 })
|
||||
|
||||
async function loadContent() {
|
||||
content.value = null
|
||||
loadError.value = null
|
||||
lernstand.value = {}
|
||||
mdCache.clear()
|
||||
for (const k in sichtbar) delete sichtbar[k]
|
||||
const g = props.previewGuide
|
||||
if (!g || g.status !== 'done') return
|
||||
try {
|
||||
@@ -198,6 +233,7 @@ function extractContext() {
|
||||
<article
|
||||
v-for="s in ch.sections"
|
||||
: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' : ''))]"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
</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
|
||||
v-if="istPruefbar(s)"
|
||||
mode="trigger"
|
||||
@@ -444,6 +481,10 @@ function extractContext() {
|
||||
.section-card h3.baustein-klick { cursor: pointer; width: fit-content; }
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user