This commit is contained in:
team3
2026-06-30 00:14:18 +02:00
parent 3e3559aa8f
commit c794fcaccf
152 changed files with 9485 additions and 9583 deletions

View File

@@ -1,10 +1,10 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, resetBausteineAbStep as apiResetBausteineAbStep, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicFortschritt, fetchGuideLocks, fetchGuideSteps, fetchFolders, updateQuelle as apiUpdateQuelle } from './api.js'
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksFromStep as apiResetBausteineAbStep, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchGuideSteps, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue'
import BausteineUebersicht from './components/BausteineUebersicht.vue'
import BlocksOverview from './components/BlocksOverview.vue'
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
import ElementsOverview from './components/ElementsOverview.vue'
import GeneralExamPanel from './components/GeneralExamPanel.vue'
@@ -15,39 +15,38 @@ const selectedTopic = ref(null)
const previewGuide = ref(null)
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
const sidebarSticky = ref(false)
const fokusOffen = ref(false) // Baustein-Vollbild aktiv → Sidebar als Overlay über das Fokus-Overlay heben
const focusOpen = ref(false) // block fullscreen activelift sidebar as overlay above the focus overlay
const darkMode = ref(
localStorage.getItem('darkMode') === null
? window.matchMedia('(prefers-color-scheme: dark)').matches
: localStorage.getItem('darkMode') === 'true',
)
const EMPTY_BAUSTEINE = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [], feine_steps: [] }
const bausteine = ref({ ...EMPTY_BAUSTEINE })
const activeBausteine = ref([])
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [], feine_steps: [] }
const blocks = ref({ ...EMPTY_BLOCKS })
const activeBlocks = ref([])
const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([])
const folders = ref({ projekt: [], uni: [] }) // Ordner für die Quellen-Auswahl
const bausteineView = ref(false) // Bausteine-Übersicht im Hauptbereich
const allgemeinView = ref(false) // Allgemeine Prüfung (themenweit) im Hauptbereich
const ansichtModus = ref('kompakt') // kompakt | erklärend — je Thema, Default kompakt
const stufeAnsicht = ref(Number(localStorage.getItem('stufe')) || 4) // 1=A · 2=F · 3=E · 4=V (Stufen-Ansicht)
const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
const mainView = ref('blocks') // blocks | elements | general | detail — exclusive main-area view
const viewMode = ref('compact') // compact | erklärend — per topic, default compact
const levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view)
const stats = ref(null)
const fortschritt = ref({})
const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle)
const guideStepsDone = ref({}) // höchster fertiger Schritt-Index je Format (artefakt-basiert)
const uiError = ref(null) // abgewiesene Aktionen (409/400) sichtbar machen
const elementsOpen = ref(false) // rechte Sidebar
const elementsView = ref(false) // Übersicht im Hauptbereich
const elementsVersion = ref(0) // Erhöhung = Übersicht neu laden
const elementOpenId = ref(null) // Element aus Übersicht in Sidebar öffnen
const progress = ref({})
const locks = ref({}) // lock reasons per format (backend = single rule source)
const guideStepsDone = ref({}) // highest finished step index per format (artifact-based)
const uiError = ref(null) // surface rejected actions (409/400)
const elementsOpen = ref(false) // right sidebar
const elementsVersion = ref(0) // increment = reload overview
const elementOpenId = ref(null) // open element from overview in sidebar
const elementOpenTick = ref(0)
// Run a loader, log + swallow its error (a failed background load must not break the UI).
async function guard(label, fn) {
try { return await fn() } catch (e) { console.error(label, e) }
}
async function loadStats() {
try {
stats.value = await fetchStats()
} catch (e) {
console.error('Fehler beim Laden der Statistik:', e)
}
await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
}
function setProvider(id) {
@@ -56,25 +55,21 @@ function setProvider(id) {
}
async function loadFolders() {
try {
await guard('Failed to load folders:', async () => {
const [projekt, uni] = await Promise.all([fetchFolders('projekt'), fetchFolders('uni')])
folders.value = { projekt, uni }
} catch (e) {
console.error('Fehler beim Laden der Ordner:', e)
}
})
}
async function loadProviders() {
try {
await guard('Failed to load providers:', async () => {
providers.value = await fetchProviders()
const current = providers.value.find((p) => p.id === provider.value)
if (current && !current.available) {
const fallback = providers.value.find((p) => p.available)
if (fallback) setProvider(fallback.id)
}
} catch (e) {
console.error('Fehler beim Laden der Provider:', e)
}
})
}
function applyTheme() {
@@ -136,12 +131,12 @@ async function loadTopics() {
try {
backendTopics.value = await fetchTopics()
} catch (e) {
console.error('Fehler beim Laden der Themen:', e)
console.error('Failed to load topics:', e)
}
}
// Weggeklickte Fehler bleiben weggeklickt — auch über Reloads (localStorage).
// Nicht weggeklickte Fehler bleiben sichtbar, bis der Nutzer sie schließt.
// Dismissed errors stay dismissed — even across reloads (localStorage).
// Errors that aren't dismissed stay visible until the user closes them.
const dismissedErrors = ref(new Set(JSON.parse(localStorage.getItem('dismissedErrors') || '[]')))
function persistDismissed() {
@@ -156,7 +151,7 @@ function handleDismissError(guideId) {
async function loadGuides() {
try {
guides.value = await fetchGuides()
// IDs prunen, deren Guide nicht mehr als Fehler existiert
// Prune IDs whose guide no longer exists as an error
const errorIds = new Set(guides.value.filter((g) => g.status === 'error').map((g) => g.id))
if ([...dismissedErrors.value].some((id) => !errorIds.has(id))) {
dismissedErrors.value = new Set([...dismissedErrors.value].filter((id) => errorIds.has(id)))
@@ -164,27 +159,27 @@ async function loadGuides() {
}
loadStats()
} catch (e) {
console.error('Fehler beim Laden:', e)
console.error('Failed to load:', e)
}
}
async function loadBausteine() {
async function loadBlocks() {
try {
activeBausteine.value = await fetchActiveBausteine()
activeBlocks.value = await fetchActiveBlocks()
if (selectedTopic.value) {
bausteine.value = await fetchBausteineStatus(selectedTopic.value)
fortschritt.value = await fetchTopicFortschritt(selectedTopic.value)
blocks.value = await fetchBlocksStatus(selectedTopic.value)
progress.value = await fetchTopicProgress(selectedTopic.value)
locks.value = await fetchGuideLocks(selectedTopic.value)
guideStepsDone.value = await fetchGuideSteps(selectedTopic.value)
} else {
bausteine.value = { ...EMPTY_BAUSTEINE }
fortschritt.value = {}
blocks.value = { ...EMPTY_BLOCKS }
progress.value = {}
locks.value = {}
guideStepsDone.value = {}
}
if (activeBausteine.value.length && !polling.running()) startPolling()
if (activeBlocks.value.length && !polling.running()) startPolling()
} catch (e) {
console.error('Fehler beim Laden der Bausteine:', e)
console.error('Failed to load blocks:', e)
}
}
@@ -193,67 +188,57 @@ function selectTopic(topic) {
previewGuide.value = null
sidebarSticky.value = false
elementsOpen.value = false
elementsView.value = false
bausteineView.value = true // Thema-Klick → Baustein-Übersicht (Guide erst auf Pill-Klick)
allgemeinView.value = false
mainView.value = 'blocks' // topic click → blocks overview (guide only on pill click)
elementOpenId.value = null
ansichtModus.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'kompakt'
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
localStorage.setItem('lastTopic', topic)
loadBausteine()
loadBlocks()
}
// Beim Reload dort landen, wo man vorher war (Thema + Format)
// On reload, land where you were before (topic + format)
watch(previewGuide, (g) => {
if (g) localStorage.setItem('lastFormat', g.format)
})
async function createTopic(topic) {
await apiCreateTopic(topic)
await loadTopics()
selectedTopic.value = topic
previewGuide.value = null
loadBausteine()
}
async function handleCancelBausteine() {
async function handleCancelBlocks() {
if (!selectedTopic.value) return
await apiCancelBausteine(selectedTopic.value)
await loadBausteine()
await loadBlocks()
}
async function handleResetBausteine() {
async function handleResetBlocks() {
if (!selectedTopic.value) return
await apiDeleteBausteine(selectedTopic.value)
await loadBausteine()
await loadBlocks()
}
async function handleResetAbStep(step) {
async function handleResetFromStep(step) {
if (!selectedTopic.value) return
uiError.value = null
try {
await apiResetBausteineAbStep(selectedTopic.value, step) // nur zurücksetzen, kein Neu-Generieren
await apiResetBausteineAbStep(selectedTopic.value, step) // only reset, no regeneration
} catch (e) {
uiError.value = e.message
return
}
await loadBausteine()
await loadBlocks()
}
async function handleBausteineClick({ instructions, abPhase = null, abStep = null }) {
async function handleBlocksClick({ instructions, abPhase = null, abStep = null }) {
if (!selectedTopic.value) return
uiError.value = null
try {
// Quelle ist hier schon festgeschrieben; abPhase/abStep steuern den Re-Run-Umfang.
// Source is already fixed here; abPhase/abStep control the re-run scope.
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, abPhase, abStep)
} catch (e) {
uiError.value = e.message
return
}
await loadBausteine()
await loadBlocks()
startPolling()
}
async function handleCreateThema({ topic, instructions, sourceType, sourceOrt }) {
async function handleCreateTopic({ topic, instructions, sourceType, sourceOrt }) {
uiError.value = null
try {
await apiCreateBausteine(topic, instructions, provider.value, sourceType, sourceOrt)
@@ -266,7 +251,7 @@ async function handleCreateThema({ topic, instructions, sourceType, sourceOrt })
startPolling()
}
async function handleUpdateQuelle({ topic, type, ort, spec }) {
async function handleUpdateSource({ topic, type, ort, spec }) {
uiError.value = null
try {
await apiUpdateQuelle(topic, { type, ort, spec })
@@ -274,30 +259,28 @@ async function handleUpdateQuelle({ topic, type, ort, spec }) {
uiError.value = e.message
return
}
await loadBausteine() // Schritt-Anzeige folgt ggf. der neuen Quelle (z.B. Link → „Quelle laden")
await loadBlocks() // the step display may follow the new source (e.g. link → "load source")
}
function setAnsicht(modus) {
ansichtModus.value = modus
function setView(modus) {
viewMode.value = modus
if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus)
}
function setStufe(k) {
stufeAnsicht.value = k
localStorage.setItem('stufe', String(k))
function setLevel(k) {
levelView.value = k
localStorage.setItem('level', String(k))
}
function handleOpenBausteineView() {
function handleOpenBlocksView() {
if (!selectedTopic.value) return
bausteineView.value = true
elementsView.value = false
allgemeinView.value = false
mainView.value = 'blocks'
previewGuide.value = null
}
async function handleFormatClick({ format, instructions, abStep = null }) {
if (!selectedTopic.value) return
// Kein Duplikat-Start: läuft für Thema+Format schon eine Generierung, ignorieren
// No duplicate start: if a generation is already running for topic+format, ignore
const running = guides.value.some(
(g) => g.topic === selectedTopic.value && g.format === format
&& (g.status === 'generating' || g.status === 'queued'),
@@ -316,25 +299,19 @@ async function handleFormatClick({ format, instructions, abStep = null }) {
function handlePreview(guide) {
previewGuide.value = guide
elementsView.value = false
bausteineView.value = false
allgemeinView.value = false
mainView.value = 'detail'
}
function handleGeneralExam() {
if (!selectedTopic.value) return
allgemeinView.value = true
bausteineView.value = false
elementsView.value = false
mainView.value = 'general'
previewGuide.value = null
}
function handleOpenElements() {
if (!selectedTopic.value) return
elementsView.value = true
bausteineView.value = false
allgemeinView.value = false
// Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element.
mainView.value = 'elements'
// Right sidebar stays closed — it opens only when an element is clicked.
}
function handleOpenElementDetail(el) {
@@ -352,8 +329,8 @@ async function handleDeleteGuide(guideId, slots = false) {
}
const polling = usePolling(
() => Promise.all([loadGuides(), loadBausteine(), loadTopics()]),
() => hasActiveGuides.value || activeBausteine.value.length > 0,
() => Promise.all([loadGuides(), loadBlocks(), loadTopics()]),
() => hasActiveGuides.value || activeBlocks.value.length > 0,
)
const startPolling = polling.start
@@ -381,7 +358,7 @@ onMounted(async () => {
await Promise.all([loadGuides(), loadTopics(), loadProviders(), loadFolders()])
const savedTopic = localStorage.getItem('lastTopic')
if (savedTopic && topics.value.includes(savedTopic)) {
selectTopic(savedTopic) // landet auf der Baustein-Übersicht
selectTopic(savedTopic) // lands on the blocks overview
} else if (!selectedTopic.value && topics.value.length) {
selectTopic(topics.value[0])
}
@@ -389,14 +366,14 @@ onMounted(async () => {
</script>
<template>
<div class="layout" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': sidebarSticky, 'sidebar-over-fokus': fokusOffen && sidebarSticky }">
<div class="layout" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': sidebarSticky, 'sidebar-over-fokus': focusOpen && sidebarSticky }">
<div v-if="!sidebarPinned" class="hover-zone" @click="clickHoverZone"></div>
<div v-if="(!sidebarPinned && sidebarSticky) || (fokusOffen && sidebarSticky)" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
<div v-if="(!sidebarPinned && sidebarSticky) || (focusOpen && sidebarSticky)" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
<TopicSidebar
:topics="topics"
:selectedTopic="selectedTopic"
:stats="stats"
:fortschritt="fortschritt"
:fortschritt="progress"
:locks="locks"
:guideStepsDone="guideStepsDone"
:uiError="uiError"
@@ -404,29 +381,28 @@ onMounted(async () => {
:latestByFormat="latestByFormat"
:allGuides="guides"
:dismissedErrors="dismissedErrors"
:bausteine="bausteine"
:activeBausteine="activeBausteine"
:blocks="blocks"
:activeBausteine="activeBlocks"
:pinned="sidebarPinned"
:dark="darkMode"
:provider="provider"
:providers="providers"
:folders="folders"
:ansichtModus="ansichtModus"
:stufeAnsicht="stufeAnsicht"
:ansichtModus="viewMode"
:stufeAnsicht="levelView"
@setProvider="setProvider"
@toggleDark="toggleDark"
@setAnsicht="setAnsicht"
@setStufe="setStufe"
@setAnsicht="setView"
@setStufe="setLevel"
@generalExam="handleGeneralExam"
@select="selectTopic"
@create="createTopic"
@createThema="handleCreateThema"
@updateQuelle="handleUpdateQuelle"
@openBausteineView="handleOpenBausteineView"
@createThema="handleCreateTopic"
@updateSource="handleUpdateSource"
@openBausteineView="handleOpenBlocksView"
@formatClick="handleFormatClick"
@bausteineClick="handleBausteineClick"
@cancelBausteine="handleCancelBausteine"
@resetBausteine="handleResetBausteine"
@bausteineClick="handleBlocksClick"
@cancelBlocks="handleCancelBlocks"
@resetBausteine="handleResetBlocks"
@deleteTopic="handleDeleteTopic"
@cancelGuide="handleCancel"
@deleteGuide="handleDeleteGuide"
@@ -437,33 +413,33 @@ onMounted(async () => {
@togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave"
/>
<BausteineUebersicht
v-if="selectedTopic && bausteineView"
<BlocksOverview
v-if="selectedTopic && mainView === 'blocks'"
:topic="selectedTopic"
:steps="bausteine.feine_steps || []"
:generating="bausteine.generating"
:progress="bausteine.progress"
:ready="bausteine.ready"
:partial="bausteine.partial"
@close="bausteineView = false"
@restartFrom="(i) => handleBausteineClick({ instructions: '', abStep: i })"
@resetFrom="handleResetAbStep"
@restartAll="() => handleBausteineClick({ abPhase: bausteine.ready ? 1 : null })"
@removeAll="handleResetBausteine"
@cancel="handleCancelBausteine"
:steps="blocks.feine_steps || []"
:generating="blocks.generating"
:progress="blocks.progress"
:ready="blocks.ready"
:partial="blocks.partial"
@close="mainView = 'detail'"
@restartFrom="(i) => handleBlocksClick({ instructions: '', abStep: i })"
@resetFrom="handleResetFromStep"
@restartAll="() => handleBlocksClick({ abPhase: blocks.ready ? 1 : null })"
@removeAll="handleResetBlocks"
@cancel="handleCancelBlocks"
/>
<ElementsOverview
v-else-if="selectedTopic && elementsView"
v-else-if="selectedTopic && mainView === 'elements'"
:topic="selectedTopic"
:version="elementsVersion"
@open="handleOpenElementDetail"
/>
<GeneralExamPanel
v-else-if="selectedTopic && allgemeinView"
v-else-if="selectedTopic && mainView === 'general'"
:topic="selectedTopic"
:provider="provider"
@progressChanged="loadStats(); loadBausteine()"
@fokus-active="fokusOffen = $event"
@progressChanged="loadStats(); loadBlocks()"
@fokus-active="focusOpen = $event"
/>
<TopicDetail
v-else-if="selectedTopic"
@@ -472,16 +448,16 @@ onMounted(async () => {
:provider="provider"
:elementsOpen="elementsOpen"
:doneByFormat="doneByFormat"
:themaAbgeschlossen="!!fortschritt.abgeschlossen"
:ansichtModus="ansichtModus"
:stufeAnsicht="stufeAnsicht"
@progressChanged="loadStats(); loadBausteine()"
@setAnsicht="setAnsicht"
:themaAbgeschlossen="!!progress.completed"
:ansichtModus="viewMode"
:stufeAnsicht="levelView"
@progressChanged="loadStats(); loadBlocks()"
@setAnsicht="setView"
@open-sidebar="sidebarSticky = true"
@fokus-active="fokusOffen = $event"
@fokus-active="focusOpen = $event"
/>
<div v-else class="empty-main">
<p>Thema in der Sidebar anlegen oder auswählen.</p>
<p>Create or select a topic in the sidebar.</p>
</div>
<div
v-if="elementsOpen && selectedTopic"
@@ -520,11 +496,11 @@ onMounted(async () => {
--success-soft: #d1fae5;
--success-soft-hover: #a7f3d0;
--success-border: #34d399;
/* 4 Lernstufen: grün/blau/lila/gold = Anfänger/Fortgeschritten/Experte/Meister */
--stufe-anfaenger: #22c55e;
--stufe-fortgeschritten: #3b82f6;
--stufe-experte: #8b5cf6;
--stufe-meister: #d4af37;
/* 4 learning levels: green/blue/purple/gold = Beginner/Advanced/Expert/Master */
--level-beginner: #22c55e;
--level-advanced: #3b82f6;
--level-expert: #8b5cf6;
--level-master: #d4af37;
--warning: #92400e;
--warning-soft: #fef3c7;
--warning-border: #fbbf24;
@@ -600,8 +576,8 @@ textarea::placeholder {
cursor: pointer;
}
/* Unsichtbare Fläche hinter der offenen Floating-Sidebar.
Tipp/Klick daneben schließt sie — ohne sie gibt es auf Touch keinen Ausweg. */
/* Invisible surface behind the open floating sidebar.
A tap/click next to it closes it — without it there's no way out on touch. */
.sidebar-backdrop {
position: fixed;
inset: 0;
@@ -626,8 +602,8 @@ textarea::placeholder {
transform: translateX(0);
}
/* Baustein-Vollbild: Sidebar als Overlay ÜBER das Fokus-Overlay (z-index 40) heben.
Greift für gepinnt UND floating. */
/* Block fullscreen: lift the sidebar as an overlay ABOVE the focus overlay (z-index 40).
Applies for pinned AND floating. */
.layout.sidebar-over-fokus > .sidebar {
position: fixed;
left: 0;
@@ -650,8 +626,8 @@ textarea::placeholder {
font-size: 1rem;
}
/* Nur sichtbar, wenn die Elemente-Sidebar mobil als Overlay liegt.
Tipp daneben schließt sie. */
/* Only visible when the elements sidebar sits as an overlay on mobile.
A tap next to it closes it. */
.elements-backdrop {
display: none;
}