update
This commit is contained in:
@@ -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 active → lift 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;
|
||||
}
|
||||
|
||||
@@ -37,27 +37,27 @@ export async function createGuide(topic, format, instructions = '', provider = '
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchActiveBausteine() {
|
||||
const res = await fetch(`${BASE}/bausteine/active`)
|
||||
export async function fetchActiveBlocks() {
|
||||
const res = await fetch(`${BASE}/blocks/active`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchBausteineStatus(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/status?topic=${encodeURIComponent(topic)}`)
|
||||
export async function fetchBlocksStatus(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/status?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createBausteine(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null, abStep = null) {
|
||||
const res = await fetch(`${BASE}/bausteine`, {
|
||||
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null, abStep = null) {
|
||||
const res = await fetch(`${BASE}/blocks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_ort: sourceOrt, ab_phase: abPhase, ab_step: abStep }),
|
||||
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, ab_phase: abPhase, ab_step: abStep }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function resetBausteineAbStep(topic, abStep) {
|
||||
const res = await fetch(`${BASE}/bausteine/reset-step`, {
|
||||
export async function resetBlocksFromStep(topic, abStep) {
|
||||
const res = await fetch(`${BASE}/blocks/reset-step`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, ab_step: abStep }),
|
||||
@@ -65,51 +65,51 @@ export async function resetBausteineAbStep(topic, abStep) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function cancelBausteine(topic) {
|
||||
await fetch(`${BASE}/bausteine/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
export async function cancelBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function deleteBausteine(topic) {
|
||||
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
||||
export async function deleteBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// --- Baustein-Lernen: Chat, Prüfung ---
|
||||
// --- Block-Learning: Chat, Exam ---
|
||||
|
||||
export async function fetchBausteinLernstand(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/lernstand?topic=${encodeURIComponent(topic)}`)
|
||||
export async function fetchBlockLearnState(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/learnstate?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function chatBaustein({ topic, baustein, section, section_kompakt = '', messages, provider }) {
|
||||
const res = await fetch(`${BASE}/bausteine/chat`, {
|
||||
export async function chatBlock({ topic, block, section, section_compact = '', messages, provider }) {
|
||||
const res = await fetch(`${BASE}/blocks/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, baustein, section, section_kompakt, messages, provider }),
|
||||
body: JSON.stringify({ topic, block, section, section_compact, messages, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function pruefeBaustein({
|
||||
topic, baustein, section, section_kompakt = '', provider,
|
||||
aktion = 'frage', frage = '', letzte_bewertung = '', vermeide = [],
|
||||
nachgefragt = false, begruendung = '', muster = '', cap = 6, messages = [], gruendlich = false,
|
||||
auswahl = [], korrekt = [], loesung = '', alternativen = [], eingabe = '', schwer = false,
|
||||
export async function examBlock({
|
||||
topic, block, section, section_compact = '', provider,
|
||||
action = 'question', question = '', last_rating = '', avoid = [],
|
||||
asked_again = false, reason = '', pattern = '', cap = 6, messages = [], thorough = false,
|
||||
selection = [], correct = [], solution = '', alternatives = [], input = '', schwer = false,
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/bausteine/pruefung`, {
|
||||
const res = await fetch(`${BASE}/blocks/exam`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, vermeide, nachgefragt, begruendung, muster, cap, messages, provider, gruendlich, auswahl, korrekt, loesung, alternativen, eingabe, schwer }),
|
||||
body: JSON.stringify({ topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchFrageMuster(topic, baustein) {
|
||||
const res = await fetch(`${BASE}/bausteine/frage-muster?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}`)
|
||||
export async function fetchQuestionPattern(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/question-pattern?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchTopicFortschritt(topic) {
|
||||
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`)
|
||||
export async function fetchTopicProgress(topic) {
|
||||
const res = await fetch(`${BASE}/topics/progress?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
@@ -128,22 +128,22 @@ export async function fetchFolders(kind) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchQuelle(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/quelle?topic=${encodeURIComponent(topic)}`)
|
||||
export async function fetchSource(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/source?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function updateQuelle(topic, { type, ort = '', spec = '' }) {
|
||||
const res = await fetch(`${BASE}/bausteine/quelle`, {
|
||||
export async function updateSource(topic, { type, ort = '', spec = '' }) {
|
||||
const res = await fetch(`${BASE}/blocks/source`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, type, ort, spec }),
|
||||
body: JSON.stringify({ topic, type, location: ort, spec }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function fetchBausteineUebersicht(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/uebersicht?topic=${encodeURIComponent(topic)}`)
|
||||
export async function fetchBlocksOverview(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
@@ -155,40 +155,40 @@ export async function deleteGuide(id, slots = false) {
|
||||
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function fetchGuideContent(id, ebene = 4) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/content?ebene=${ebene}`)
|
||||
if (!res.ok) throw new Error(`Inhalt nicht verfügbar (${res.status})`)
|
||||
export async function fetchGuideContent(id, level = 4) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/content?level=${level}`)
|
||||
if (!res.ok) throw new Error(`Content not available (${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Lern-Artefakte (Karteikarten/Beispiele/Diagramme) je Thema, gruppiert nach Baustein-Norm.
|
||||
// Lern-Artefakte (Flashcards/Examples/Diagramme) je Thema, gruppiert nach Block-Norm.
|
||||
export async function fetchArtefakte(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/artefakte?topic=${encodeURIComponent(topic)}`)
|
||||
const res = await fetch(`${BASE}/blocks/artefakte?topic=${encodeURIComponent(topic)}`)
|
||||
if (!res.ok) return { artefakte: {} }
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Einen Markdown-Block on-demand gegen die Guide-Regeln prüfen (Fokus, Rechtsklick).
|
||||
export async function pruefeBlock(id, { baustein, stelle, block, hinweis = '', provider }) {
|
||||
// Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).
|
||||
export async function pruefeBlock(id, { block, spot, snippet, hint = '', provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ baustein, stelle, block, hinweis, provider }),
|
||||
body: JSON.stringify({ block, spot, snippet, hint, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Geprüften Block persistent übernehmen (alt → neu im jeweiligen Feld).
|
||||
export async function uebernehmeBlock(id, { baustein, stelle, alt, neu, provider }) {
|
||||
// Geprüften Block persistent übernehmen (alt → new im jeweiligen Feld).
|
||||
export async function uebernehmeBlock(id, { block, spot, alt, revised, provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ baustein, stelle, alt, neu, provider }),
|
||||
body: JSON.stringify({ block, spot, alt, revised, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Lern-Fortschritt eines Bausteins auf null setzen (Score/Streak/Flags/offene Frage).
|
||||
export async function resetBausteinFortschritt(topic, baustein) {
|
||||
const res = await fetch(`${BASE}/bausteine/fortschritt?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}`, {
|
||||
// Reset a block's learning progress to zero (score/streak/flags/open question).
|
||||
export async function resetBlockProgress(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/progress?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
@@ -262,7 +262,7 @@ export async function styleElement(id, provider = 'claude') {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Stil-Prüfung fehlgeschlagen (${res.status})`)
|
||||
if (!res.ok) throw new Error(`Stil-Exam fehlgeschlagen (${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
@@ -282,6 +282,6 @@ export async function checkElement(id, provider = 'claude') {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Prüfung fehlgeschlagen (${res.status})`)
|
||||
if (!res.ok) throw new Error(`Exam fehlgeschlagen (${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
<script setup>
|
||||
import BausteinPanel from './BausteinPanel.vue'
|
||||
import FlashcardWidget from './FlashcardWidget.vue'
|
||||
import WorkedExampleBlock from './WorkedExampleBlock.vue'
|
||||
import { renderMarkdown, renderBloecke } from '../markdown.js'
|
||||
import { stufeFuer, STUFEN } from '../stufen.js'
|
||||
import { pruefeBlock, uebernehmeBlock, resetBausteinFortschritt } from '../api.js'
|
||||
import { clearPruef } from '../pruefungCache.js'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
|
||||
const props = defineProps({
|
||||
baustein: { type: Object, required: true }, // { title, md, num }
|
||||
artefakte: { type: Object, default: null }, // { karteikarte[], beispiel[], diagramm } für diesen Baustein
|
||||
topic: { type: String, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
status: { type: Object, default: null },
|
||||
cap: { type: Number, default: 6 },
|
||||
guideId: { type: String, default: '' },
|
||||
tab: { type: String, default: 'pruefung' },
|
||||
ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — linke Guide-Spalte
|
||||
hasPrev: { type: Boolean, default: false },
|
||||
hasNext: { type: Boolean, default: false },
|
||||
fortschritt: { type: Object, default: () => ({ total: 0, anfaenger: 0, fortgeschritten: 0, experte: 0, meister: 0 }) },
|
||||
})
|
||||
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
const emit = defineEmits(['close', 'prev', 'next', 'statusChanged', 'setAnsicht', 'openSidebar', 'sectionUpdated'])
|
||||
|
||||
// --- Reset pro Baustein: Fragen-Session (Slot) bzw. Fortschritt (Score) ---
|
||||
const { isArmed, armOrRun } = useConfirm()
|
||||
const resetN = ref(0) // Hochzählen → Panel-Remount (frischer Slot, Muster neu)
|
||||
const pruefKey = computed(() => `${props.topic}::${props.baustein.title}`)
|
||||
|
||||
// Frage-Session neu: Slot verwerfen → Remount lädt Muster + Pool frisch (behebt Erklären-only).
|
||||
function fragenResetten() {
|
||||
clearPruef(pruefKey.value)
|
||||
resetN.value++
|
||||
}
|
||||
|
||||
// Fortschritt auf 0: DB-Zeile löschen, Badge/Lernstand nullen, Session frisch.
|
||||
async function fortschrittResetten() {
|
||||
try {
|
||||
await resetBausteinFortschritt(props.topic, props.baustein.title)
|
||||
emit('statusChanged', { baustein: props.baustein.title, gute_antworten: 0, streak: 0, cap: props.cap })
|
||||
clearPruef(pruefKey.value)
|
||||
resetN.value++
|
||||
} catch (e) { /* still bleiben — Reset fehlgeschlagen */ }
|
||||
}
|
||||
|
||||
const guideEl = ref(null) // linke Guide-Spalte (Scroll-Ziel für ALT+↑/↓)
|
||||
const rightEl = ref(null) // rechte Spalte (Prüfungs-Panel mit Eingabefeld)
|
||||
|
||||
// ALT ist der Modifier der Fokus-Ansicht. Plain Pfeile bleiben Browser-Standard.
|
||||
// ALT+←/→ blättert Baustein, ALT+↑/↓ scrollt den Guide. Ein reiner ALT-Tipp
|
||||
// (drücken+loslassen ohne andere Taste) toggelt den Fokus aufs Eingabefeld.
|
||||
let altAlone = false
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Alt') { altAlone = true; e.preventDefault(); return } // unterdrückt Firefox-Menüleiste
|
||||
if (e.ctrlKey || e.metaKey || !e.altKey) return // plain/andere → normal
|
||||
altAlone = false // ALT+irgendwas = kein Lone-Tap
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); if (!e.repeat && props.hasPrev) emit('prev') }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); if (!e.repeat && props.hasNext) emit('next') }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); guideEl.value?.scrollBy(0, -120) }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); guideEl.value?.scrollBy(0, 120) }
|
||||
}
|
||||
function onKeyUp(e) {
|
||||
if (e.key === 'Alt') { e.preventDefault(); if (altAlone) { altAlone = false; toggleInput() } }
|
||||
}
|
||||
function resetAlt() { altAlone = false } // Fenster-Blur (ALT+Tab) → kein falscher Tipp
|
||||
function toggleInput() {
|
||||
// Antwortfeld der aktuellen Form: Erklären = textarea, Lückentext-frei = input.
|
||||
const el = rightEl.value?.querySelector('textarea, input')
|
||||
if (!el) return
|
||||
document.activeElement === el ? el.blur() : el.focus()
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', resetAlt)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', resetAlt)
|
||||
})
|
||||
|
||||
const pct = (n) => (100 * n / (props.fortschritt.total || 1)) + '%'
|
||||
|
||||
// Akzentfarbe nach Stufe des aktuellen Bausteins (grün/blau/lila/gold) oder neutral.
|
||||
const standFarbe = computed(() => {
|
||||
const s = props.status || {}
|
||||
return stufeFuer(s.gute_antworten || 0, s.cap || props.cap)?.farbe || 'var(--border)'
|
||||
})
|
||||
const stufe = computed(() => {
|
||||
const s = props.status || {}
|
||||
return stufeFuer(s.gute_antworten || 0, s.cap || props.cap)
|
||||
})
|
||||
|
||||
// --- Block-Prüfen (Rechtsklick auf einen Abschnitt) ---
|
||||
const angezeigterText = computed(() =>
|
||||
props.ansicht === 'kompakt' ? (props.baustein.kompakt || props.baustein.md) : props.baustein.md,
|
||||
)
|
||||
const bloecke = computed(() => renderBloecke(angezeigterText.value))
|
||||
// Feld, in das übernommen wird (muss zum angezeigten Text passen).
|
||||
const stelle = computed(() => (props.ansicht === 'kompakt' && props.baustein.kompakt) ? 'kompakt' : 'ausführlich')
|
||||
|
||||
const menu = reactive({ show: false, x: 0, y: 0, index: null })
|
||||
function blockMenue(i, e) { menu.show = true; menu.x = e.clientX; menu.y = e.clientY; menu.index = i }
|
||||
function menueZu() { menu.show = false }
|
||||
|
||||
// Vorschlag je Block-Index: { neu, laeuft, fehler, editOffen, hinweis }
|
||||
const vorschlaege = reactive({})
|
||||
|
||||
async function blockPruefen(i, zusatz = '') {
|
||||
const raw = bloecke.value[i]?.raw
|
||||
if (!raw || !props.guideId) return
|
||||
menueZu()
|
||||
vorschlaege[i] = { neu: '', laeuft: true, fehler: '', editOffen: false, hinweis: '' }
|
||||
try {
|
||||
const res = await pruefeBlock(props.guideId, { baustein: props.baustein.title, stelle: stelle.value, block: raw, hinweis: zusatz, provider: props.provider })
|
||||
vorschlaege[i] = { neu: res.neu, laeuft: false, fehler: '', editOffen: false, hinweis: '' }
|
||||
} catch (e) {
|
||||
vorschlaege[i] = { neu: '', laeuft: false, fehler: e.message || 'Prüfung fehlgeschlagen', editOffen: false, hinweis: '' }
|
||||
}
|
||||
}
|
||||
|
||||
async function blockUebernehmen(i) {
|
||||
const v = vorschlaege[i]; const raw = bloecke.value[i]?.raw
|
||||
if (!v || v.laeuft || !raw) return
|
||||
v.laeuft = true; v.fehler = ''
|
||||
try {
|
||||
const res = await uebernehmeBlock(props.guideId, { baustein: props.baustein.title, stelle: stelle.value, alt: raw, neu: v.neu, provider: props.provider })
|
||||
if (res.gefunden) emit('sectionUpdated', { title: props.baustein.title, kompakt: res.kompakt, md: res.md })
|
||||
else { v.laeuft = false; v.fehler = 'Stelle nicht gefunden — evtl. schon geändert.' }
|
||||
} catch (e) { v.laeuft = false; v.fehler = e.message || 'Übernehmen fehlgeschlagen' }
|
||||
}
|
||||
|
||||
function blockVerwerfen(i) { delete vorschlaege[i] }
|
||||
function blockEdit(i) { const v = vorschlaege[i]; if (v) v.editOffen = !v.editOffen }
|
||||
function blockEditSenden(i) {
|
||||
const z = (vorschlaege[i]?.hinweis || '').trim()
|
||||
if (z) blockPruefen(i, z)
|
||||
}
|
||||
|
||||
// Baustein/Inhalt-Wechsel → Vorschläge + Menü zurücksetzen (Block-Indizes neu).
|
||||
watch(() => `${props.baustein.title}|${props.baustein.md}|${props.baustein.kompakt || ''}`, () => {
|
||||
for (const k of Object.keys(vorschlaege)) delete vorschlaege[k]
|
||||
menueZu()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fokus-overlay" :style="{ '--stand': standFarbe }">
|
||||
<div class="fokus-bar">
|
||||
<div class="fokus-bar-inner">
|
||||
<button class="fokus-btn" title="Navigation öffnen" @click="$emit('openSidebar')">☰</button>
|
||||
<button class="fokus-btn" :disabled="!hasPrev" title="Voriger Baustein" @click="$emit('prev')">←</button>
|
||||
<button class="fokus-btn" :disabled="!hasNext" title="Nächster Baustein" @click="$emit('next')">→</button>
|
||||
<span class="fokus-titel">{{ baustein.title }}</span>
|
||||
<span v-if="stufe" class="stand-badge" :style="{ color: stufe.farbe, borderColor: stufe.farbe }">{{ stufe.kurz }} {{ stufe.label }}</span>
|
||||
<span class="fokus-spacer"></span>
|
||||
<button class="fokus-btn" title="Fragen resetten (neue Frage-Session)" @click="fragenResetten">↻</button>
|
||||
<button
|
||||
class="fokus-btn"
|
||||
:class="{ armed: isArmed('reset-fortschritt') }"
|
||||
:title="isArmed('reset-fortschritt') ? 'Nochmal klicken: Fortschritt auf 0 setzen' : 'Fortschritt resetten (Score auf 0)'"
|
||||
@click="armOrRun('reset-fortschritt', fortschrittResetten)"
|
||||
>⟲</button>
|
||||
<button class="fokus-btn" title="Vollansicht beenden" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fokus-xp" :title="`${fortschritt.anfaenger}/${fortschritt.total} ab Anfänger · ${fortschritt.fortgeschritten} Fortgeschritten · ${fortschritt.experte} Experte · ${fortschritt.meister} Meister`">
|
||||
<div class="xp-seg" :style="{ width: pct(fortschritt.meister), background: 'var(--stufe-meister)' }"></div>
|
||||
<div class="xp-seg" :style="{ width: pct(fortschritt.experte - fortschritt.meister), background: 'var(--stufe-experte)' }"></div>
|
||||
<div class="xp-seg" :style="{ width: pct(fortschritt.fortgeschritten - fortschritt.experte), background: 'var(--stufe-fortgeschritten)' }"></div>
|
||||
<div class="xp-seg" :style="{ width: pct(fortschritt.anfaenger - fortschritt.fortgeschritten), background: 'var(--stufe-anfaenger)' }"></div>
|
||||
</div>
|
||||
<div class="fokus-body">
|
||||
<div ref="guideEl" class="fokus-col left">
|
||||
<div class="markdown">
|
||||
<template v-for="(b, i) in bloecke" :key="i">
|
||||
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenue(i, $event)"></div>
|
||||
<div v-if="vorschlaege[i]" class="block-vorschlag">
|
||||
<div v-if="vorschlaege[i].laeuft" class="bv-status">Prüfe Abschnitt…</div>
|
||||
<template v-else>
|
||||
<p v-if="vorschlaege[i].fehler" class="bv-fehler">{{ vorschlaege[i].fehler }}</p>
|
||||
<div class="markdown bv-neu" v-html="renderMarkdown(vorschlaege[i].neu)"></div>
|
||||
<div class="bv-aktionen">
|
||||
<button class="bv-btn ja" title="Übernehmen" @click="blockUebernehmen(i)">✓</button>
|
||||
<button class="bv-btn" title="Verwerfen" @click="blockVerwerfen(i)">✗</button>
|
||||
<button class="bv-btn" :class="{ aktiv: vorschlaege[i].editOffen }" title="Hinweis ergänzen" @click="blockEdit(i)">✏️</button>
|
||||
</div>
|
||||
<div v-if="vorschlaege[i].editOffen" class="bv-edit">
|
||||
<input v-model="vorschlaege[i].hinweis" class="bv-input" placeholder="Zusatz-Info → erneut prüfen" @keyup.enter="blockEditSenden(i)" />
|
||||
<button class="bv-btn ja" title="Erneut prüfen" @click="blockEditSenden(i)">↻</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="artefakte">
|
||||
<WorkedExampleBlock :beispiele="artefakte.beispiel || []" />
|
||||
<FlashcardWidget :karten="artefakte.karteikarte || []" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="menu.show" class="menu-overlay" @click="menueZu" @contextmenu.prevent="menueZu">
|
||||
<div class="block-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }" @click.stop>
|
||||
<button class="bm-item" @click="blockPruefen(menu.index)">Prüfen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="rightEl" class="fokus-col right">
|
||||
<BausteinPanel
|
||||
mode="full"
|
||||
:key="baustein.title + '|' + tab + '|' + resetN"
|
||||
:initial-tab="tab"
|
||||
:topic="topic"
|
||||
:baustein="baustein.title"
|
||||
:section="baustein.md"
|
||||
:section-kompakt="baustein.kompakt || ''"
|
||||
:provider="provider"
|
||||
:status="status"
|
||||
:cap="cap"
|
||||
:ansicht="ansicht"
|
||||
@set-ansicht="$emit('setAnsicht', $event)"
|
||||
@status-changed="$emit('statusChanged', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fokus-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
/* Halbtransparent + Blur: die Guide-Liste dahinter scheint verschwommen durch die grauen Flächen. */
|
||||
background: color-mix(in srgb, var(--bg-preview) 60%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr; /* Header / XP-Leiste / Body */
|
||||
}
|
||||
.fokus-bar {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
/* Header-Inhalt fluchtet mit dem Body (gleiche max-width + Innenabstand). */
|
||||
.fokus-bar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
max-width: 1600px;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
.fokus-spacer { flex: 1; }
|
||||
|
||||
.stand-badge {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.12rem 0.6rem;
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
border-radius: 999px; border: 1px solid; white-space: nowrap;
|
||||
}
|
||||
.stand-badge.gruen { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.stand-badge.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||||
.stand-badge.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||
|
||||
/* Erfahrungsleiste oben: füllt sich von links — gold (gemeistert) → lila (verstanden) → grün (absolviert). */
|
||||
.fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); }
|
||||
/* 9 Trennlinien alle 10 % → 10 sichtbare Abschnitte (Füllung bleibt durchgehend). */
|
||||
.fokus-xp::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
transparent 0,
|
||||
transparent calc(10% - 2px),
|
||||
var(--panel) calc(10% - 2px),
|
||||
var(--panel) 10%
|
||||
);
|
||||
}
|
||||
.xp-seg { height: 100%; transition: width 0.3s ease; }
|
||||
.xp-seg.gold { background: #d4af37; }
|
||||
.xp-seg.lila { background: #8b5cf6; }
|
||||
.xp-seg.gruen { background: var(--success-border); }
|
||||
.fokus-titel { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
|
||||
.fokus-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.fokus-btn:hover { border-color: var(--accent); }
|
||||
.fokus-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.fokus-btn.armed { border-color: var(--danger, #dc2626); color: var(--danger, #dc2626); background: color-mix(in srgb, var(--danger, #dc2626) 12%, var(--panel)); }
|
||||
|
||||
/* Zwei abgehobene Karten auf grauem „Schreibtisch" (--bg-preview vom Overlay). */
|
||||
.fokus-body {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
max-width: 1600px;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.fokus-col {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* Karte kippt nach Baustein-Stand: farbiger Rahmen + getönter Hintergrund (grün/lila/gold). */
|
||||
background: color-mix(in srgb, var(--stand) 7%, var(--panel));
|
||||
border: 1px solid var(--stand);
|
||||
border-top: 3px solid var(--stand);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
/* Links: breite Lese-Karte, Text in Lesebreite zentriert. */
|
||||
.fokus-col.left { flex: 1; padding: 2rem 2.5rem; }
|
||||
.fokus-col.left > * { max-width: 74ch; margin-inline: auto; }
|
||||
/* Rechts: Arbeits-Karte; Panel darin randlos (Karte ist der Rahmen). */
|
||||
.fokus-col.right { flex: none; width: clamp(440px, 34%, 600px); padding: 1.5rem; }
|
||||
.fokus-col.right :deep(.bp) { margin-top: 0; }
|
||||
.fokus-col.right :deep(.bp-panel) { border: none; background: transparent; padding: 0; }
|
||||
.fokus-h2 { font-size: 1.1rem; margin: 0 0 0.75rem; }
|
||||
|
||||
/* Desktop: die rechte Karte als Flex-Spalte — Prüfungs-/Chat-Verlauf füllt die
|
||||
volle Höhe und scrollt intern, Eingabe + Buttons bleiben unten. */
|
||||
@media (min-width: 901px) {
|
||||
.fokus-col.right { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.fokus-col.right :deep(.bp) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.fokus-col.right :deep(.bp-panel) { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow-y: auto; }
|
||||
.fokus-col.right :deep(.bp-panel > div) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.fokus-col.right :deep(.bp-messages) { flex: 1; min-height: 0; max-height: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fokus-body { flex-direction: column; overflow-y: auto; }
|
||||
.fokus-col { overflow-y: visible; }
|
||||
.fokus-col.right { width: auto; }
|
||||
}
|
||||
|
||||
/* Block-Prüfen: Rechtsklick-Menü + Vorschlag unter dem Abschnitt */
|
||||
/* Abstand am Wrapper führen — inneres p ist jetzt :last-child (Margin 0). */
|
||||
.md-block { border-radius: 6px; transition: background 0.15s; margin-bottom: 0.8em; }
|
||||
.md-block:last-child { margin-bottom: 0; }
|
||||
.md-block > :first-child { margin-top: 0; }
|
||||
.md-block > :last-child { margin-bottom: 0; }
|
||||
.md-block:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||
|
||||
.menu-overlay { position: fixed; inset: 0; z-index: 60; }
|
||||
.block-menu {
|
||||
position: fixed;
|
||||
min-width: 8rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.bm-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.7rem;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bm-item:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||
|
||||
.block-vorschlag {
|
||||
margin: 0.5rem 0 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--accent) 6%, var(--panel));
|
||||
}
|
||||
.bv-status { font-size: 0.9rem; color: var(--text-soft, #888); }
|
||||
.bv-fehler { color: var(--danger, #dc2626); font-size: 0.88rem; margin: 0 0 0.5rem; }
|
||||
.bv-neu > :first-child { margin-top: 0; }
|
||||
.bv-neu > :last-child { margin-bottom: 0; }
|
||||
.bv-aktionen { display: flex; gap: 0.4rem; margin-top: 0.6rem; }
|
||||
.bv-btn {
|
||||
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bv-btn:hover { border-color: var(--accent); }
|
||||
.bv-btn.aktiv { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--panel)); }
|
||||
.bv-btn.ja { border-color: var(--success-border); color: var(--success); }
|
||||
.bv-edit { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
|
||||
.bv-input {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
418
frontend/src/components/BlockFocus.vue
Normal file
418
frontend/src/components/BlockFocus.vue
Normal file
@@ -0,0 +1,418 @@
|
||||
<script setup>
|
||||
import BlockPanel from './BlockPanel.vue'
|
||||
import FlashcardWidget from './FlashcardWidget.vue'
|
||||
import WorkedExampleBlock from './WorkedExampleBlock.vue'
|
||||
import { renderMarkdown, renderBlocks } from '../markdown.js'
|
||||
import { stufeFuer, LEVELS } from '../levels.js'
|
||||
import { pruefeBlock, uebernehmeBlock, resetBlockProgress } from '../api.js'
|
||||
import { clearPruef } from '../pruefungCache.js'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
|
||||
const props = defineProps({
|
||||
block: { type: Object, required: true }, // { title, md, num }
|
||||
artefakte: { type: Object, default: null }, // { flashcard[], example[], diagramm } for this block
|
||||
topic: { type: String, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
status: { type: Object, default: null },
|
||||
cap: { type: Number, default: 6 },
|
||||
guideId: { type: String, default: '' },
|
||||
tab: { type: String, default: 'exam' },
|
||||
ansicht: { type: String, default: 'compact' }, // compact | erklärend — left guide column
|
||||
hasPrev: { type: Boolean, default: false },
|
||||
hasNext: { type: Boolean, default: false },
|
||||
fortschritt: { type: Object, default: () => ({ total: 0, beginner: 0, advanced: 0, expert: 0, master: 0 }) },
|
||||
})
|
||||
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
const emit = defineEmits(['close', 'prev', 'next', 'statusChanged', 'setAnsicht', 'openSidebar', 'sectionUpdated'])
|
||||
|
||||
// --- Reset per block: questions session (slot) resp. progress (score) ---
|
||||
const { isArmed, armOrRun } = useConfirm()
|
||||
const resetN = ref(0) // increment → panel remount (fresh slot, patterns reset)
|
||||
const examKey = computed(() => `${props.topic}::${props.block.title}`)
|
||||
|
||||
// New question session: discard slot → remount loads patterns + pool fresh (fixes explain-only).
|
||||
function resetQuestions() {
|
||||
clearPruef(examKey.value)
|
||||
resetN.value++
|
||||
}
|
||||
|
||||
// Progress to 0: delete DB row, zero badge/learn state, fresh session.
|
||||
async function resetProgress() {
|
||||
try {
|
||||
await resetBlockProgress(props.topic, props.block.title)
|
||||
emit('statusChanged', { block: props.block.title, good_answers: 0, streak: 0, cap: props.cap })
|
||||
clearPruef(examKey.value)
|
||||
resetN.value++
|
||||
} catch (e) { /* stay quiet — reset failed */ }
|
||||
}
|
||||
|
||||
const guideEl = ref(null) // left guide column (scroll target for ALT+↑/↓)
|
||||
const rightEl = ref(null) // right column (exam panel with input field)
|
||||
|
||||
// ALT is the modifier of the focus view. Plain arrows stay browser-default.
|
||||
// ALT+←/→ pages blocks, ALT+↑/↓ scrolls the guide. A bare ALT tap
|
||||
// (press+release without another key) toggles focus on the input field.
|
||||
let altAlone = false
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Alt') { altAlone = true; e.preventDefault(); return } // suppresses the Firefox menu bar
|
||||
if (e.ctrlKey || e.metaKey || !e.altKey) return // plain/other → normal
|
||||
altAlone = false // ALT+anything = no lone tap
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); if (!e.repeat && props.hasPrev) emit('prev') }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); if (!e.repeat && props.hasNext) emit('next') }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); guideEl.value?.scrollBy(0, -120) }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); guideEl.value?.scrollBy(0, 120) }
|
||||
}
|
||||
function onKeyUp(e) {
|
||||
if (e.key === 'Alt') { e.preventDefault(); if (altAlone) { altAlone = false; toggleInput() } }
|
||||
}
|
||||
function resetAlt() { altAlone = false } // window blur (ALT+Tab) → no false tap
|
||||
function toggleInput() {
|
||||
// Answer field of the current form: explain = textarea, free gap-text = input.
|
||||
const el = rightEl.value?.querySelector('textarea, input')
|
||||
if (!el) return
|
||||
document.activeElement === el ? el.blur() : el.focus()
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', resetAlt)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', resetAlt)
|
||||
})
|
||||
|
||||
const pct = (n) => (100 * n / (props.fortschritt.total || 1)) + '%'
|
||||
|
||||
// Accent color by the current block's level (green/blue/purple/gold) or neutral.
|
||||
const levelColor = computed(() => {
|
||||
const s = props.status || {}
|
||||
return stufeFuer(s.good_answers || 0, s.cap || props.cap)?.farbe || 'var(--border)'
|
||||
})
|
||||
const level = computed(() => {
|
||||
const s = props.status || {}
|
||||
return stufeFuer(s.good_answers || 0, s.cap || props.cap)
|
||||
})
|
||||
|
||||
// --- Check block (right-click on a section) ---
|
||||
const displayedText = computed(() =>
|
||||
props.ansicht === 'compact' ? (props.block.compact || props.block.md) : props.block.md,
|
||||
)
|
||||
const blocks = computed(() => renderBlocks(displayedText.value))
|
||||
// Field that edits are applied to (must match the displayed text).
|
||||
const spot = computed(() => (props.ansicht === 'compact' && props.block.compact) ? 'compact' : 'ausführlich')
|
||||
|
||||
const menu = reactive({ show: false, x: 0, y: 0, index: null })
|
||||
function blockMenu(i, e) { menu.show = true; menu.x = e.clientX; menu.y = e.clientY; menu.index = i }
|
||||
function closeMenu() { menu.show = false }
|
||||
|
||||
// Suggestion per block index: { new, running, error, editOpen, hint }
|
||||
const suggestions = reactive({})
|
||||
|
||||
async function checkBlock(i, extra = '') {
|
||||
const raw = blocks.value[i]?.raw
|
||||
if (!raw || !props.guideId) return
|
||||
closeMenu()
|
||||
suggestions[i] = { revised: '', running: true, error: '', editOpen: false, hint: '' }
|
||||
try {
|
||||
const res = await pruefeBlock(props.guideId, { block: props.block.title, spot: spot.value, snippet: raw, hint: extra, provider: props.provider })
|
||||
suggestions[i] = { revised: res.revised, running: false, error: '', editOpen: false, hint: '' }
|
||||
} catch (e) {
|
||||
suggestions[i] = { revised: '', running: false, error: e.message || 'Exam failed', editOpen: false, hint: '' }
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBlock(i) {
|
||||
const v = suggestions[i]; const raw = blocks.value[i]?.raw
|
||||
if (!v || v.running || !raw) return
|
||||
v.running = true; v.error = ''
|
||||
try {
|
||||
const res = await uebernehmeBlock(props.guideId, { block: props.block.title, spot: spot.value, alt: raw, revised: v.revised, provider: props.provider })
|
||||
if (res.found) emit('sectionUpdated', { title: props.block.title, compact: res.compact, md: res.md })
|
||||
else { v.running = false; v.error = 'Spot not found — may have already changed.' }
|
||||
} catch (e) { v.running = false; v.error = e.message || 'Apply failed' }
|
||||
}
|
||||
|
||||
function discardBlock(i) { delete suggestions[i] }
|
||||
function editBlock(i) { const v = suggestions[i]; if (v) v.editOpen = !v.editOpen }
|
||||
function sendBlockEdit(i) {
|
||||
const z = (suggestions[i]?.hint || '').trim()
|
||||
if (z) checkBlock(i, z)
|
||||
}
|
||||
|
||||
// Block/content switch → reset suggestions + menu (block indices change).
|
||||
watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}`, () => {
|
||||
for (const k of Object.keys(suggestions)) delete suggestions[k]
|
||||
closeMenu()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fokus-overlay" :style="{ '--stand': levelColor }">
|
||||
<div class="fokus-bar">
|
||||
<div class="fokus-bar-inner">
|
||||
<button class="fokus-btn" title="Open navigation" @click="$emit('openSidebar')">☰</button>
|
||||
<button class="fokus-btn" :disabled="!hasPrev" title="Previous block" @click="$emit('prev')">←</button>
|
||||
<button class="fokus-btn" :disabled="!hasNext" title="Next block" @click="$emit('next')">→</button>
|
||||
<span class="fokus-title">{{ block.title }}</span>
|
||||
<span v-if="level" class="stand-badge" :style="{ color: level.farbe, borderColor: level.farbe }">{{ level.kurz }} {{ level.label }}</span>
|
||||
<span class="fokus-spacer"></span>
|
||||
<button class="fokus-btn" title="Reset questions (new question session)" @click="resetQuestions">↻</button>
|
||||
<button
|
||||
class="fokus-btn"
|
||||
:class="{ armed: isArmed('reset-fortschritt') }"
|
||||
:title="isArmed('reset-fortschritt') ? 'Click again: set progress to 0' : 'Reset progress (score to 0)'"
|
||||
@click="armOrRun('reset-fortschritt', resetProgress)"
|
||||
>⟲</button>
|
||||
<button class="fokus-btn" title="Exit full view" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fokus-xp" :title="`${fortschritt.beginner}/${fortschritt.total} from Beginner · ${fortschritt.advanced} Advanced · ${fortschritt.expert} Expert · ${fortschritt.master} Master`">
|
||||
<div class="xp-seg" :style="{ width: pct(fortschritt.master), background: 'var(--level-master)' }"></div>
|
||||
<div class="xp-seg" :style="{ width: pct(fortschritt.expert - fortschritt.master), background: 'var(--level-expert)' }"></div>
|
||||
<div class="xp-seg" :style="{ width: pct(fortschritt.advanced - fortschritt.expert), background: 'var(--level-advanced)' }"></div>
|
||||
<div class="xp-seg" :style="{ width: pct(fortschritt.beginner - fortschritt.advanced), background: 'var(--level-beginner)' }"></div>
|
||||
</div>
|
||||
<div class="fokus-body">
|
||||
<div ref="guideEl" class="fokus-col left">
|
||||
<div class="markdown">
|
||||
<template v-for="(b, i) in blocks" :key="i">
|
||||
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenu(i, $event)"></div>
|
||||
<div v-if="suggestions[i]" class="block-vorschlag">
|
||||
<div v-if="suggestions[i].running" class="bv-status">Check Section…</div>
|
||||
<template v-else>
|
||||
<p v-if="suggestions[i].error" class="bv-fehler">{{ suggestions[i].error }}</p>
|
||||
<div class="markdown bv-new" v-html="renderMarkdown(suggestions[i].revised)"></div>
|
||||
<div class="bv-aktionen">
|
||||
<button class="bv-btn ja" title="Apply" @click="applyBlock(i)">✓</button>
|
||||
<button class="bv-btn" title="Discard" @click="discardBlock(i)">✗</button>
|
||||
<button class="bv-btn" :class="{ aktiv: suggestions[i].editOpen }" title="Add hint" @click="editBlock(i)">✏️</button>
|
||||
</div>
|
||||
<div v-if="suggestions[i].editOpen" class="bv-edit">
|
||||
<input v-model="suggestions[i].hint" class="bv-input" placeholder="Extra info → check again" @keyup.enter="sendBlockEdit(i)" />
|
||||
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(i)">↻</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="artefakte">
|
||||
<WorkedExampleBlock :examples="artefakte.example || []" />
|
||||
<FlashcardWidget :cards="artefakte.flashcard || []" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="menu.show" class="menu-overlay" @click="closeMenu" @contextmenu.prevent="closeMenu">
|
||||
<div class="block-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }" @click.stop>
|
||||
<button class="bm-item" @click="checkBlock(menu.index)">Check</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="rightEl" class="fokus-col right">
|
||||
<BlockPanel
|
||||
mode="full"
|
||||
:key="block.title + '|' + tab + '|' + resetN"
|
||||
:initial-tab="tab"
|
||||
:topic="topic"
|
||||
:block="block.title"
|
||||
:section="block.md"
|
||||
:section-compact="block.compact || ''"
|
||||
:provider="provider"
|
||||
:status="status"
|
||||
:cap="cap"
|
||||
:ansicht="ansicht"
|
||||
@set-ansicht="$emit('setAnsicht', $event)"
|
||||
@status-changed="$emit('statusChanged', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fokus-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
/* Semi-transparent + blur: the guide list behind shows blurred through the gray surfaces. */
|
||||
background: color-mix(in srgb, var(--bg-preview) 60%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr; /* header / XP bar / body */
|
||||
}
|
||||
.fokus-bar {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
/* Header content aligns with the body (same max-width + inner padding). */
|
||||
.fokus-bar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
max-width: 1600px;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
.fokus-spacer { flex: 1; }
|
||||
|
||||
.stand-badge {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.12rem 0.6rem;
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
border-radius: 999px; border: 1px solid; white-space: nowrap;
|
||||
}
|
||||
.stand-badge.gruen { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.stand-badge.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||||
.stand-badge.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||
|
||||
/* Experience bar on top: fills from the left — gold (mastered) → purple (understood) → green (completed). */
|
||||
.fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); }
|
||||
/* 9 divider lines every 10% → 10 visible segments (fill stays continuous). */
|
||||
.fokus-xp::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
transparent 0,
|
||||
transparent calc(10% - 2px),
|
||||
var(--panel) calc(10% - 2px),
|
||||
var(--panel) 10%
|
||||
);
|
||||
}
|
||||
.xp-seg { height: 100%; transition: width 0.3s ease; }
|
||||
.xp-seg.gold { background: #d4af37; }
|
||||
.xp-seg.lila { background: #8b5cf6; }
|
||||
.xp-seg.gruen { background: var(--success-border); }
|
||||
.fokus-title { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
|
||||
.fokus-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.fokus-btn:hover { border-color: var(--accent); }
|
||||
.fokus-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.fokus-btn.armed { border-color: var(--danger, #dc2626); color: var(--danger, #dc2626); background: color-mix(in srgb, var(--danger, #dc2626) 12%, var(--panel)); }
|
||||
|
||||
/* Two raised cards on a gray "desk" (--bg-preview from the overlay). */
|
||||
.fokus-body {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
max-width: 1600px;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.fokus-col {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* Card tilts by block level: colored border + tinted background (green/purple/gold). */
|
||||
background: color-mix(in srgb, var(--stand) 7%, var(--panel));
|
||||
border: 1px solid var(--stand);
|
||||
border-top: 3px solid var(--stand);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
/* Left: wide reading card, text centered at reading width. */
|
||||
.fokus-col.left { flex: 1; padding: 2rem 2.5rem; }
|
||||
.fokus-col.left > * { max-width: 74ch; margin-inline: auto; }
|
||||
/* Right: work card; the panel inside is borderless (the card is the frame). */
|
||||
.fokus-col.right { flex: none; width: clamp(440px, 34%, 600px); padding: 1.5rem; }
|
||||
.fokus-col.right :deep(.bp) { margin-top: 0; }
|
||||
.fokus-col.right :deep(.bp-panel) { border: none; background: transparent; padding: 0; }
|
||||
.fokus-h2 { font-size: 1.1rem; margin: 0 0 0.75rem; }
|
||||
|
||||
/* Desktop: the right card as a flex column — exam/chat history fills the
|
||||
full height and scrolls internally, input + buttons stay at the bottom. */
|
||||
@media (min-width: 901px) {
|
||||
.fokus-col.right { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.fokus-col.right :deep(.bp) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.fokus-col.right :deep(.bp-panel) { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow-y: auto; }
|
||||
.fokus-col.right :deep(.bp-panel > div) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.fokus-col.right :deep(.bp-messages) { flex: 1; min-height: 0; max-height: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fokus-body { flex-direction: column; overflow-y: auto; }
|
||||
.fokus-col { overflow-y: visible; }
|
||||
.fokus-col.right { width: auto; }
|
||||
}
|
||||
|
||||
/* Check block: right-click menu + suggestion below the section */
|
||||
/* Keep the spacing on the wrapper — the inner p is now :last-child (margin 0). */
|
||||
.md-block { border-radius: 6px; transition: background 0.15s; margin-bottom: 0.8em; }
|
||||
.md-block:last-child { margin-bottom: 0; }
|
||||
.md-block > :first-child { margin-top: 0; }
|
||||
.md-block > :last-child { margin-bottom: 0; }
|
||||
.md-block:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||
|
||||
.menu-overlay { position: fixed; inset: 0; z-index: 60; }
|
||||
.block-menu {
|
||||
position: fixed;
|
||||
min-width: 8rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.bm-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.7rem;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bm-item:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||
|
||||
.block-vorschlag {
|
||||
margin: 0.5rem 0 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--accent) 6%, var(--panel));
|
||||
}
|
||||
.bv-status { font-size: 0.9rem; color: var(--text-soft, #888); }
|
||||
.bv-fehler { color: var(--danger, #dc2626); font-size: 0.88rem; margin: 0 0 0.5rem; }
|
||||
.bv-new > :first-child { margin-top: 0; }
|
||||
.bv-new > :last-child { margin-bottom: 0; }
|
||||
.bv-aktionen { display: flex; gap: 0.4rem; margin-top: 0.6rem; }
|
||||
.bv-btn {
|
||||
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bv-btn:hover { border-color: var(--accent); }
|
||||
.bv-btn.aktiv { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--panel)); }
|
||||
.bv-btn.ja { border-color: var(--success-border); color: var(--success); }
|
||||
.bv-edit { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
|
||||
.bv-input {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
966
frontend/src/components/BlockPanel.vue
Normal file
966
frontend/src/components/BlockPanel.vue
Normal file
@@ -0,0 +1,966 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { chatBlock, examBlock, fetchQuestionPattern } from '../api.js'
|
||||
import { usePruefSlot } from '../pruefungCache.js'
|
||||
import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
|
||||
import { stufeFuer, malusRegel } from '../levels.js'
|
||||
import { useChat, istUnten } from '../composables/useChat.js'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
block: { type: String, required: true },
|
||||
section: { type: String, default: '' }, // detailed version
|
||||
sectionKompakt: { type: String, default: '' }, // compact version (key points) — exam/chat context
|
||||
provider: { type: String, default: 'claude' },
|
||||
status: { type: Object, default: null }, // {good_answers, streak, completed, understood, mastered}
|
||||
cap: { type: Number, default: 6 }, // score cap = max of the highest format (6/12/18/30)
|
||||
// 'trigger' (list): only tab bar, click opens the fullscreen focus.
|
||||
// 'full' (focus): tabs + panel content as before.
|
||||
mode: { type: String, default: 'trigger' },
|
||||
initialTab: { type: String, default: null },
|
||||
ansicht: { type: String, default: 'compact' }, // compact | erklärend — controls the left guide column
|
||||
})
|
||||
|
||||
const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht'])
|
||||
|
||||
// Learning levels relative to the cap (levels.js): green/blue/purple/gold @ 20/40/60/100 %.
|
||||
// Exam is ALWAYS random from 5 forms (with pattern); cap = 4×relevant subblocks.
|
||||
// Four question forms, random. No more easy/hard — the difficulty is set by the backend
|
||||
// via the learner level (addressee role). gapchoice = term choice · gaptext = free.
|
||||
const MODES = ['quiz', 'gapchoice', 'gaptext', 'erklaeren']
|
||||
const st = computed(() => props.status || { good_answers: 0, streak: 0, cap: props.cap })
|
||||
const score = computed(() => st.value.good_answers || 0)
|
||||
const cap = computed(() => st.value.cap || props.cap)
|
||||
const scoreDisplay = computed(() => Math.min(score.value, cap.value)) // legacy data may exceed cap
|
||||
const level = computed(() => stufeFuer(score.value, cap.value)) // object {key,label,kurz,farbe} | null
|
||||
const atCap = computed(() => score.value >= cap.value)
|
||||
// With question pattern random from 5 forms; without pattern (old block) only Explain.
|
||||
const currentBand = computed(() => patternMode.value ? 'zufall' : 'erklaeren')
|
||||
const inRandom = computed(() => currentBand.value === 'zufall')
|
||||
const activeMode = computed(() => currentBand.value) // 'zufall' | 'erklaeren'
|
||||
const activeForm = computed(() => inRandom.value ? 'quiz' : 'erklaeren') // placeholder; drawn object overrides
|
||||
// Displayed form: follows the active form, freezes while a drawn question is visible.
|
||||
const displayForm = ref(null)
|
||||
const shownForm = computed(() => displayForm.value || activeForm.value)
|
||||
const displayBand = ref(null)
|
||||
const sectionChange = computed(() => displayBand.value && displayBand.value !== currentBand.value)
|
||||
// Difficulty of the currently shown widget (from the drawn object).
|
||||
const currentHard = computed(() => {
|
||||
if (shownForm.value === 'quiz' && quizCurrent.value) return quizCurrent.value.schwer
|
||||
if (shownForm.value === 'gaptext' && clozeCurrent.value) return clozeCurrent.value.schwer
|
||||
return false
|
||||
})
|
||||
const modeRule = computed(() => {
|
||||
const m = malusRegel(score.value, cap.value) // '0' | '−1' | '−2' | '−3' by progress
|
||||
if (shownForm.value === 'quiz') return currentHard.value ? `x of 4 · +3/${m}` : `1 of 4 · +1/${m}`
|
||||
if (shownForm.value === 'gaptext') return currentHard.value ? `free text · +3/${m}` : `term from 4 · +1/${m}`
|
||||
return `+1/+2/+3 · error ${m}`
|
||||
})
|
||||
const bandTarget = computed(() => cap.value)
|
||||
const FORM_NAME = { quiz: 'Quiz', gaptext: 'Cloze', erklaeren: 'Explain' }
|
||||
|
||||
// --- Toggle area ---
|
||||
const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'chat' | 'exam'
|
||||
|
||||
// Click on a tab: in the list open the focus, in the focus toggle the tab.
|
||||
function tabClick(tab) {
|
||||
if (props.mode === 'trigger') { emit('openFokus', tab); return }
|
||||
activeTab.value = activeTab.value === tab ? null : tab
|
||||
}
|
||||
|
||||
// --- Block chat (ephemeral) ---
|
||||
const chat = useChat((msgs) => chatBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
messages: msgs, provider: props.provider,
|
||||
}))
|
||||
|
||||
// --- Exam: guided dialog ---
|
||||
// Phases: 'idle' (request question) | 'question_offen' (answer/ask) | 'bewertet' (discuss/re-evaluate/continue)
|
||||
// Durable state lives in a SHARED slot per block (module store): survives
|
||||
// remounting and a late-arriving question is immediately reactive and visible.
|
||||
const examKey = `${props.topic}::${props.block}`
|
||||
const slot = usePruefSlot(examKey)
|
||||
const examMessages = slot.messages // {role, kind: 'question'|'nachfrage'|'answer'|'feedback'|'discussion'|'fehler', content, rating?}
|
||||
const examPhase = slot.phase
|
||||
const currentQuestion = slot.aktuelleFrage // anchors evaluation/discussion
|
||||
const lastFeedback = slot.letztesFeedback // context for the discussion about an evaluation
|
||||
const patternSource = slot.musterQuelle // immutable full list of question patterns (empty = fallback)
|
||||
const patternPool = slot.musterPool // working copy, without replacement; empty → reset
|
||||
const patternLoaded = slot.musterGeladen
|
||||
const poolForm = slot.poolForm // form the pool is filled for
|
||||
const quizCurrent = slot.quizAktuell // running quiz question (widget state)
|
||||
const clozeCurrent = slot.lueckAktuell // running cloze task (widget state)
|
||||
// Transient (per instance): input draft, loading spinner.
|
||||
const examInput = ref('')
|
||||
const examLoading = ref(false)
|
||||
const asked_again = ref(false) // the running question used asked_again → gain max +1
|
||||
const thoroughMsg = ref(null) // feedback bubble whose thorough field is open
|
||||
const thoroughText = ref('') // optional reason for "check thoroughly"
|
||||
|
||||
const examMessagesEl = ref(null)
|
||||
const examInputEl = ref(null)
|
||||
const examStick = ref(true) // only auto-scroll when the user is (almost) at the bottom
|
||||
let examRun = 0
|
||||
|
||||
function onExamScroll() {
|
||||
if (examMessagesEl.value) examStick.value = istUnten(examMessagesEl.value)
|
||||
}
|
||||
|
||||
function applyExam(res) {
|
||||
// res.streak only set on answer_check (binding, persisted); otherwise leave streak unchanged.
|
||||
emit('statusChanged', { block: props.block, ...st.value, good_answers: res.good_answers, streak: res.streak ?? st.value.streak, cap: res.cap ?? cap.value })
|
||||
}
|
||||
|
||||
async function examScroll() {
|
||||
await nextTick()
|
||||
if (examMessagesEl.value && examStick.value) examMessagesEl.value.scrollTop = examMessagesEl.value.scrollHeight
|
||||
}
|
||||
|
||||
// Only real conversation turns go to the backend; feedback stays a pure UI artifact.
|
||||
function examDialog() {
|
||||
return examMessages.value
|
||||
.filter((m) => m.kind !== 'feedback' && m.kind !== 'fehler')
|
||||
.map((m) => ({ role: m.role, content: m.content }))
|
||||
}
|
||||
|
||||
async function examSend(payload, onOk) {
|
||||
const run = ++examRun
|
||||
examStick.value = true // own action = to the end; scrolling up while waiting sets it back to false
|
||||
examLoading.value = true
|
||||
examScroll()
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
provider: props.provider, messages: examDialog(), ...payload,
|
||||
})
|
||||
if (run !== examRun) return
|
||||
onOk(res)
|
||||
applyExam(res)
|
||||
examScroll()
|
||||
nextTick(() => examInputEl.value?.focus())
|
||||
} catch {
|
||||
if (run === examRun) examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Didn\'t work — please try again.' })
|
||||
} finally {
|
||||
if (run === examRun) examLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Show question (fresh or queued). Base/streak is tracked by the server per question.
|
||||
function showQuestion(text) {
|
||||
currentQuestion.value = text
|
||||
lastFeedback.value = ''
|
||||
asked_again.value = false
|
||||
thoroughMsg.value = null
|
||||
examMessages.value.push({ role: 'assistant', kind: 'question', content: text })
|
||||
examPhase.value = 'question_offen'
|
||||
}
|
||||
|
||||
// Dedup: detect already-asked questions (the generator repeats itself on small blocks).
|
||||
function normQuestion(t) { return (t || '').toLowerCase().replace(/\s+/g, ' ').trim() }
|
||||
function askedQuestions() {
|
||||
return new Set(examMessages.value.filter((m) => m.kind === 'question').map((m) => normQuestion(m.content)))
|
||||
}
|
||||
|
||||
// --- Questions pool: TARGET pre-formulated question OBJECTS of the active form (in the slot) ---
|
||||
// Each object: {form, question, options?|sentence?|solution?|alternatives?}. When the
|
||||
// active form changes, the pool is cleared and refilled for the new form.
|
||||
const POOL_TARGET = 5
|
||||
const pool = slot.pool
|
||||
const inflight = slot.inflight
|
||||
const examError = ref('') // error hint for quiz/cloze (no bubble history)
|
||||
let poolMiss = 0 // fallback only: consecutive duplicates/errors — cap against an infinite loop
|
||||
|
||||
// Pattern mode active once loaded and patterns present; otherwise fallback (only Explain).
|
||||
const patternMode = computed(() => patternLoaded.value && patternSource.value.length > 0)
|
||||
|
||||
function objText(o) { return (o && (o.question || o.sentence)) || '' }
|
||||
|
||||
// Render cloze sentence: split at "___", parts inline (with $…$ math), gap as a span.
|
||||
// This keeps the gap and markdown does not touch the underscores.
|
||||
function clozeSentenceHtml(sentence) {
|
||||
sentence = String(sentence || '')
|
||||
const m = sentence.match(/_{3,}/) // first gap (three or more underscores)
|
||||
if (!m) return renderMarkdownInline(sentence)
|
||||
// Is the gap INSIDE a $…$ formula (odd number of $ before it)? Then do not split
|
||||
// (that tears the formula) — instead replace it with a KaTeX line, render the sentence whole.
|
||||
const inFormula = ((sentence.slice(0, m.index).match(/\$/g) || []).length % 2) === 1
|
||||
if (inFormula) return renderMarkdownInline(sentence.replace(/_{3,}/, '\\rule{2.5em}{0.4pt}'))
|
||||
// Gap in text: split + gap span.
|
||||
return sentence.split(/_{3,}/).map((t) => renderMarkdownInline(t)).join('<span class="bp-luecke">______</span>')
|
||||
}
|
||||
|
||||
function isKnown(o) {
|
||||
const n = normQuestion(objText(o))
|
||||
return !n || askedQuestions().has(n) || pool.value.some((f) => normQuestion(objText(f)) === n)
|
||||
}
|
||||
|
||||
// Already-asked + queued questions — fallback mode only (Explain without pattern).
|
||||
function avoidList() {
|
||||
const asked = examMessages.value.filter((m) => m.kind === 'question').map((m) => m.content)
|
||||
return [...asked, ...pool.value.map(objText)]
|
||||
}
|
||||
|
||||
// Load pattern sidecar once per block (slot-cached). Empty → fallback.
|
||||
async function loadPatterns() {
|
||||
if (patternLoaded.value) return
|
||||
try {
|
||||
const res = await fetchQuestionPattern(props.topic, props.block)
|
||||
patternSource.value = (res.pattern || []).map((m) => (m.question || '').trim()).filter(Boolean)
|
||||
patternLoaded.value = true // ONLY on success — otherwise a failed attempt freezes the session to Explain-only
|
||||
} catch {
|
||||
patternSource.value = [] // patternLoaded stays false → next turn retries
|
||||
}
|
||||
}
|
||||
|
||||
function mischen(arr) { // Fisher-Yates
|
||||
const a = [...arr]
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[a[i], a[j]] = [a[j], a[i]]
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Draw a pattern without replacement; pool empty → reset (reshuffle). null = fallback.
|
||||
function takePattern() {
|
||||
if (!patternSource.value.length) return null
|
||||
if (!patternPool.value.length) patternPool.value = mischen(patternSource.value)
|
||||
return patternPool.value.shift()
|
||||
}
|
||||
|
||||
// Pool key = form + difficulty; in the random band a mixed pool ('zufall').
|
||||
const poolKey = computed(() => inRandom.value ? 'zufall' : 'erklaeren')
|
||||
|
||||
// Mode of the next question: in the random band (score 19–30) random from all 5 modes.
|
||||
function nextMode() {
|
||||
return inRandom.value ? MODES[Math.floor(Math.random() * MODES.length)] : activeMode.value
|
||||
}
|
||||
|
||||
// Generate a question object of a given mode (from a drawn seed; Explain has a fallback).
|
||||
function buildSingleQuestion(mode = nextMode()) {
|
||||
const form = mode === 'quiz' ? 'quiz' : mode.startsWith('gap') ? 'gaptext' : 'erklaeren'
|
||||
const istLueckFrei = mode === 'gaptext' // free text vs. gapchoice = term from 4
|
||||
const pattern = takePattern()
|
||||
const base = {
|
||||
topic: props.topic, block: props.block, section: props.section,
|
||||
section_compact: props.sectionKompakt, provider: props.provider,
|
||||
}
|
||||
if (form === 'quiz' && pattern) {
|
||||
return examBlock({ ...base, action: 'quiz_question', pattern }) // single choice, level controls
|
||||
.then((r) => ({ form, schwer: false, question: r.question, options: r.options }))
|
||||
}
|
||||
if (form === 'gaptext' && pattern) {
|
||||
return examBlock({ ...base, action: 'gap_question', pattern, schwer: istLueckFrei })
|
||||
.then((r) => istLueckFrei
|
||||
? ({ form, schwer: true, question: r.sentence, sentence: r.sentence, solution: r.solution, alternatives: r.alternatives })
|
||||
: ({ form, schwer: false, question: r.sentence, sentence: r.sentence, options: r.options }))
|
||||
}
|
||||
if (pattern) { // erklaeren from pattern
|
||||
return examBlock({ ...base, action: 'question', pattern }).then((r) => ({ form: 'erklaeren', question: r.question }))
|
||||
}
|
||||
// Fallback (no pattern sidecar): Explain live with an avoid list.
|
||||
return examBlock({ ...base, action: 'question', messages: examDialog(), avoid: avoidList() })
|
||||
.then((r) => ({ form: 'erklaeren', question: r.question }))
|
||||
}
|
||||
|
||||
// Clear the pool when form or difficulty changed (band boundary or ALT+E).
|
||||
function checkPoolForm() {
|
||||
if (poolForm.value !== poolKey.value) {
|
||||
pool.value = []
|
||||
poolForm.value = poolKey.value
|
||||
poolMiss = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the pool in PARALLEL: each draws a DISTINCT pattern → no collision.
|
||||
function fillPool() {
|
||||
checkPoolForm()
|
||||
const keyStart = poolKey.value
|
||||
const missCap = patternMode.value ? Infinity : 3
|
||||
while (pool.value.length + inflight.value < POOL_TARGET && poolMiss < missCap) {
|
||||
inflight.value++
|
||||
buildSingleQuestion()
|
||||
.then((obj) => {
|
||||
if (obj && objText(obj) && poolKey.value === keyStart && (patternMode.value || !isKnown(obj))) { pool.value.push(obj); poolMiss = 0 }
|
||||
else poolMiss++
|
||||
})
|
||||
.catch(() => { poolMiss++ })
|
||||
.finally(() => { inflight.value--; fillPool() })
|
||||
}
|
||||
}
|
||||
|
||||
// Draw a question from the pool — random, never the fastest.
|
||||
// First load (pool empty + nothing in progress): generate 5, await ALL, then draw.
|
||||
async function drawQuestion() {
|
||||
const firstLoad = !pool.value.length && !inflight.value
|
||||
fillPool() // start/keep up to 5
|
||||
examLoading.value = true
|
||||
try {
|
||||
const missCap = patternMode.value ? Infinity : 3
|
||||
if (firstLoad) {
|
||||
// only at the start: wait until all 5 are done (speed must not decide the form)
|
||||
while ((inflight.value > 0 || pool.value.length < POOL_TARGET) && poolMiss < missCap) {
|
||||
if (!inflight.value) fillPool()
|
||||
await new Promise((r) => setTimeout(r, 80))
|
||||
}
|
||||
} else if (!pool.value.length) {
|
||||
// pool drained (fast user): wait for the first finished one
|
||||
while (!pool.value.length && (inflight.value > 0 || poolMiss < missCap)) {
|
||||
if (!inflight.value) fillPool()
|
||||
await new Promise((r) => setTimeout(r, 80))
|
||||
}
|
||||
}
|
||||
if (!pool.value.length) return null
|
||||
const i = Math.floor(Math.random() * pool.value.length)
|
||||
return pool.value.splice(i, 1)[0]
|
||||
} finally { examLoading.value = false }
|
||||
}
|
||||
|
||||
// Show a drawn object in the matching form (Explain bubble or quiz/cloze widget).
|
||||
function showQuestionObj(obj) {
|
||||
examError.value = ''
|
||||
displayForm.value = obj.form // bind display to the drawn question (freeze the form)
|
||||
displayBand.value = currentBand.value // band at draw time (before the answer)
|
||||
if (obj.form === 'quiz') {
|
||||
quizCurrent.value = { question: obj.question, options: obj.options, schwer: obj.schwer, gewaehlt: [], done: false, points: null, rating: null, feedback: '' }
|
||||
} else if (obj.form === 'gaptext') {
|
||||
clozeCurrent.value = { sentence: obj.sentence, schwer: obj.schwer, options: obj.options || null, solution: obj.solution || '', alternatives: obj.alternatives || [], input: '', gewaehlt: [], done: false, points: null, rating: null, feedback: '' }
|
||||
if (obj.schwer) nextTick(() => document.getElementById('bp-gap-input')?.focus())
|
||||
} else {
|
||||
showQuestion(obj.question)
|
||||
nextTick(() => examInputEl.value?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
// First / next question: load patterns, roll the form, fetch exactly that form.
|
||||
async function showNextQuestion() {
|
||||
if (examLoading.value) return
|
||||
poolMiss = 0
|
||||
examError.value = ''
|
||||
await loadPatterns()
|
||||
const obj = await drawQuestion() // first load: await all 5; then draw randomly
|
||||
if (obj) {
|
||||
showQuestionObj(obj)
|
||||
examScroll()
|
||||
} else if (activeForm.value === 'erklaeren') {
|
||||
examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'No question — please try again.' })
|
||||
} else {
|
||||
examError.value = 'No question — please try again.'
|
||||
}
|
||||
fillPool()
|
||||
}
|
||||
const requestQuestion = showNextQuestion
|
||||
|
||||
// Milestone exceeded: release the freeze (result was visible) without drawing a new
|
||||
// question → idle state of the new band ("request question").
|
||||
function toNextSection() {
|
||||
displayForm.value = null
|
||||
displayBand.value = null
|
||||
quizCurrent.value = null
|
||||
clozeCurrent.value = null
|
||||
examMessages.value = [] // discard the old Explain history (new section)
|
||||
examPhase.value = 'idle'
|
||||
examError.value = ''
|
||||
}
|
||||
|
||||
// --- Quiz: easy = single select (exactly 1) · hard = multi select. Deterministic. ---
|
||||
function quizToggle(i) {
|
||||
const q = quizCurrent.value
|
||||
if (!q || q.done) return
|
||||
if (!q.schwer) { q.gewaehlt = q.gewaehlt.includes(i) ? [] : [i]; return }
|
||||
const idx = q.gewaehlt.indexOf(i)
|
||||
if (idx >= 0) q.gewaehlt.splice(idx, 1)
|
||||
else q.gewaehlt.push(i)
|
||||
}
|
||||
|
||||
async function quizAnswer() {
|
||||
const q = quizCurrent.value
|
||||
if (!q || q.done || examLoading.value) return
|
||||
examLoading.value = true
|
||||
examError.value = ''
|
||||
try {
|
||||
const correct = q.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0)
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
provider: props.provider, action: 'quiz_answer', question: q.question, cap: props.cap,
|
||||
selection: q.gewaehlt, correct, schwer: q.schwer,
|
||||
})
|
||||
q.done = true; q.points = res.points; q.rating = res.rating; q.feedback = res.feedback
|
||||
applyExam(res)
|
||||
} catch {
|
||||
examError.value = 'Didn\'t work — please try again.'
|
||||
} finally {
|
||||
examLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cloze: easy = term from 4 (single select) · hard = free text ---
|
||||
function clozeToggle(i) {
|
||||
const l = clozeCurrent.value
|
||||
if (!l || l.done || l.schwer) return
|
||||
l.gewaehlt = l.gewaehlt.includes(i) ? [] : [i]
|
||||
}
|
||||
|
||||
async function clozeAnswer() {
|
||||
const l = clozeCurrent.value
|
||||
if (!l || l.done || examLoading.value) return
|
||||
if (l.schwer ? !l.input.trim() : !l.gewaehlt.length) return
|
||||
examLoading.value = true
|
||||
examError.value = ''
|
||||
try {
|
||||
const specific = l.schwer
|
||||
? { schwer: true, solution: l.solution, alternatives: l.alternatives, input: l.input }
|
||||
: { schwer: false, selection: l.gewaehlt, correct: l.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0) }
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
provider: props.provider, action: 'gap_answer', question: l.sentence, cap: props.cap, ...specific,
|
||||
})
|
||||
l.done = true; l.points = res.points; l.rating = res.rating; l.feedback = res.feedback
|
||||
applyExam(res)
|
||||
} catch {
|
||||
examError.value = 'Didn\'t work — please try again.'
|
||||
} finally {
|
||||
examLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function askFollowUp() {
|
||||
const text = examInput.value.trim()
|
||||
if (!text || examLoading.value) return
|
||||
asked_again.value = true // help used → gain for this question max +1
|
||||
examMessages.value.push({ role: 'user', kind: 'nachfrage', content: text })
|
||||
examInput.value = ''
|
||||
examSend(
|
||||
{ action: 'discussion', question: currentQuestion.value, last_rating: lastFeedback.value },
|
||||
(res) => examMessages.value.push({ role: 'assistant', kind: 'discussion', content: res.reply }),
|
||||
)
|
||||
}
|
||||
|
||||
let lastFeedbackMsg = null // last shown evaluation bubble
|
||||
let evalRun = 0 // only the most recent quick evaluation may display
|
||||
|
||||
function ratingPayload() {
|
||||
return { question: currentQuestion.value, cap: props.cap, asked_again: asked_again.value }
|
||||
}
|
||||
|
||||
// Agent 1 (fast): show immediate level + points, then Agent 2 (precise) in the background.
|
||||
async function quickEvaluate() {
|
||||
const mine = ++evalRun
|
||||
examStick.value = true
|
||||
examLoading.value = true
|
||||
examScroll()
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
provider: props.provider, messages: examDialog(), action: 'answer', ...ratingPayload(),
|
||||
})
|
||||
if (mine !== evalRun) return
|
||||
// Agent 1 does NOT change the score — only show feedback + expected points.
|
||||
lastFeedback.value = res.feedback || ''
|
||||
examMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', rating: res.rating, points: res.points, checked: false })
|
||||
lastFeedbackMsg = examMessages.value[examMessages.value.length - 1]
|
||||
examPhase.value = 'bewertet'
|
||||
examScroll()
|
||||
nextTick(() => examInputEl.value?.focus())
|
||||
preciseEvaluate() // Agent 2 evaluates bindingly (sets the score)
|
||||
} catch {
|
||||
if (mine === evalRun) examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Didn\'t work — please try again.' })
|
||||
} finally {
|
||||
if (mine === evalRun) examLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Agent 2 (precise): evaluator + critic. Corrects bubble + score (server truth, always apply).
|
||||
// thorough=true: strong model, optionally with the learner's reason.
|
||||
async function preciseEvaluate(thorough = false, reason = '') {
|
||||
const target = lastFeedbackMsg
|
||||
if (thorough) examLoading.value = true
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
provider: props.provider, messages: examDialog(), action: 'answer_check', ...ratingPayload(), thorough, reason,
|
||||
})
|
||||
applyExam(res)
|
||||
if (target) {
|
||||
target.content = res.feedback || target.content
|
||||
target.rating = res.rating
|
||||
target.points = res.points
|
||||
target.checked = true
|
||||
}
|
||||
} catch {
|
||||
if (target) target.checked = true
|
||||
} finally {
|
||||
if (thorough) examLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function submitAnswer() {
|
||||
const text = examInput.value.trim()
|
||||
if (!text || examLoading.value) return
|
||||
examMessages.value.push({ role: 'user', kind: 'answer', content: text })
|
||||
examInput.value = ''
|
||||
quickEvaluate()
|
||||
}
|
||||
|
||||
// Was there already a discussion? Then the history may be evaluated directly.
|
||||
const hasDiscussion = computed(() => examMessages.value.some((m) => m.kind === 'nachfrage' || m.kind === 'discussion'))
|
||||
|
||||
// "Evaluate history": evaluate the dialog so far as the answer (field is ignored).
|
||||
function evaluateHistory() {
|
||||
if (examLoading.value) return
|
||||
quickEvaluate()
|
||||
}
|
||||
|
||||
// Right-click an evaluation → open the thorough field; submitting re-evaluates (with reason).
|
||||
function openThorough(msg) {
|
||||
thoroughText.value = ''
|
||||
thoroughMsg.value = msg
|
||||
nextTick(() => document.getElementById('bp-thorough-input')?.focus())
|
||||
}
|
||||
function submitThorough() {
|
||||
if (examLoading.value) return
|
||||
lastFeedbackMsg = thoroughMsg.value || lastFeedbackMsg
|
||||
const text = thoroughText.value.trim()
|
||||
thoroughMsg.value = null
|
||||
preciseEvaluate(true, text)
|
||||
}
|
||||
|
||||
function pointsLabel(p) {
|
||||
if (p == null) return ''
|
||||
return p > 0 ? `+${p}` : p < 0 ? String(p) : '±0'
|
||||
}
|
||||
|
||||
// Cancel like in chat: increment the run counter → the running result (examSend
|
||||
// and quickEvaluate) is discarded, buttons free immediately. Agent finishes server-side.
|
||||
function examCancel() {
|
||||
if (!examLoading.value) return
|
||||
examRun++
|
||||
evalRun++
|
||||
examLoading.value = false
|
||||
examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Cancelled.' })
|
||||
}
|
||||
|
||||
// ESC cancels — window listener, because after a button click the focus leaves the panel.
|
||||
function onWindowKey(e) {
|
||||
if (e.key === 'Escape' && examLoading.value) { e.preventDefault(); examCancel() }
|
||||
}
|
||||
onMounted(() => {
|
||||
if (props.mode !== 'full') return // trigger mode (list): only tab bar, no exam/text logic
|
||||
window.addEventListener('keydown', onWindowKey)
|
||||
window.addEventListener('keydown', onExamKey)
|
||||
loadPatterns() // GET (no agent) — sets the active form early correctly (quiz/cloze/explain)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onWindowKey)
|
||||
window.removeEventListener('keydown', onExamKey)
|
||||
})
|
||||
|
||||
// Quiz / cloze choice is a choice widget with keys 1–4; otherwise text field/bubble.
|
||||
const choiceWidget = computed(() => {
|
||||
if (shownForm.value === 'quiz') return quizCurrent.value
|
||||
if (shownForm.value === 'gaptext' && clozeCurrent.value && !clozeCurrent.value.schwer) return clozeCurrent.value
|
||||
return null
|
||||
})
|
||||
// After an answer: on milestone crossing go to the new section, otherwise next question.
|
||||
function continueAction() {
|
||||
if (sectionChange.value) toNextSection()
|
||||
else showNextQuestion()
|
||||
}
|
||||
// Primary action per form: no question → request · open → answer · done → continue.
|
||||
function primaryAction() {
|
||||
if (examLoading.value) return
|
||||
const f = shownForm.value
|
||||
if (f === 'quiz') { if (!quizCurrent.value) requestQuestion(); else if (!quizCurrent.value.done) quizAnswer(); else continueAction(); return }
|
||||
if (f === 'gaptext') { if (!clozeCurrent.value) requestQuestion(); else if (!clozeCurrent.value.done) clozeAnswer(); else continueAction(); return }
|
||||
if (examPhase.value === 'idle') requestQuestion()
|
||||
else if (examPhase.value === 'question_offen') { if (examInput.value.trim()) submitAnswer() }
|
||||
else continueAction()
|
||||
}
|
||||
|
||||
// 1–4 (bare): toggle option in the choice widget. Alt alone: jump to the input field.
|
||||
// Alt+2 primary (answer/next) · Alt+Q view · Alt+1/3 Explain.
|
||||
function onExamKey(e) {
|
||||
if (e.repeat) return
|
||||
if (props.mode !== 'full' || activeTab.value !== 'exam') return
|
||||
// Alt alone toggles the focus (BlockFocus lone-tap) → do NOT focus here,
|
||||
// otherwise keydown focus (here) and keyup toggle (focus) fight → "only while held".
|
||||
if (e.key === 'Alt') return
|
||||
if (!e.altKey) {
|
||||
// Bare 1–4: toggle the choice widget — but not while a text field has focus.
|
||||
if (['1', '2', '3', '4'].includes(e.key) && choiceWidget.value && !choiceWidget.value.done) {
|
||||
const ae = document.activeElement
|
||||
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return
|
||||
e.preventDefault()
|
||||
const i = Number(e.key) - 1
|
||||
if (i < choiceWidget.value.options.length) (shownForm.value === 'quiz' ? quizToggle : clozeToggle)(i)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.code === 'KeyQ') {
|
||||
e.preventDefault()
|
||||
emit('setAnsicht', props.ansicht === 'compact' ? 'erklärend' : 'compact')
|
||||
return
|
||||
}
|
||||
if (e.key === '2') { e.preventDefault(); primaryAction(); return }
|
||||
// Explain special keys
|
||||
if (e.key === '1') { e.preventDefault(); if (shownForm.value === 'erklaeren' && examPhase.value === 'question_offen' && examInput.value.trim()) askFollowUp(); return }
|
||||
if (e.key === '3') { e.preventDefault(); if (shownForm.value === 'erklaeren' && examPhase.value === 'question_offen' && hasDiscussion.value) evaluateHistory() }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bp">
|
||||
<div class="bp-toggles">
|
||||
<!-- View switcher (fullscreen only): controls the left guide column, left of the tabs -->
|
||||
<div v-if="mode === 'full'" class="bp-ansicht">
|
||||
<button :class="{ active: ansicht === 'compact' }" title="Key points" @click="emit('setAnsicht', 'compact')">Compact</button>
|
||||
<button :class="{ active: ansicht === 'erklärend' }" title="Detailed explanation" @click="emit('setAnsicht', 'erklärend')">Explanatory</button>
|
||||
</div>
|
||||
<button :class="{ active: activeTab === 'chat' }" @click="tabClick('chat')">
|
||||
Chat
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'exam' }" @click="tabClick('exam')">
|
||||
Exam
|
||||
<span v-if="level" class="bp-chip" :style="{ borderColor: level.farbe, color: level.farbe }" :title="`${level.label} (${cap})`">{{ level.kurz }} {{ scoreDisplay }}/{{ cap }}</span>
|
||||
<span v-else-if="score" class="bp-chip">{{ scoreDisplay }}/{{ cap }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'full' && activeTab" class="bp-panel">
|
||||
<!-- Block chat -->
|
||||
<div v-if="activeTab === 'chat'">
|
||||
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
|
||||
<p v-if="!chat.messages.value.length" class="bp-hint">Ask something about this block. The history is not saved.</p>
|
||||
<template v-for="(m, i) in chat.messages.value" :key="i">
|
||||
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||
<div v-else class="bp-msg user">{{ m.content }}</div>
|
||||
</template>
|
||||
<div v-if="chat.loading.value" class="bp-msg assistant bp-typing">Thinking…</div>
|
||||
</div>
|
||||
<div class="bp-input">
|
||||
<textarea
|
||||
:ref="chat.inputEl"
|
||||
v-model="chat.input.value"
|
||||
rows="2"
|
||||
placeholder="Question about the block…"
|
||||
@keydown.enter.exact.prevent="chat.send"
|
||||
></textarea>
|
||||
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
|
||||
{{ chat.loading.value ? '✕' : '➤' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exam: guided dialog -->
|
||||
<div v-else>
|
||||
<p class="bp-hint">
|
||||
<template v-if="atCap">{{ FORM_NAME[shownForm] }} · {{ scoreDisplay }}/{{ cap }} — <strong>Max</strong>.</template>
|
||||
<template v-else-if="inRandom && !displayForm">Random · {{ scoreDisplay }}/{{ cap }}</template>
|
||||
<template v-else-if="inRandom">{{ FORM_NAME[shownForm] }} · {{ scoreDisplay }}/{{ cap }} · {{ modeRule }}</template>
|
||||
<template v-else>{{ FORM_NAME[shownForm] }} · {{ score }}/{{ bandTarget }} · {{ modeRule }}</template>
|
||||
</p>
|
||||
|
||||
<!-- Quiz: question + multiple choice (widget stays even at the cap — practice without points) -->
|
||||
<template v-if="shownForm === 'quiz'">
|
||||
<div v-if="!quizCurrent" class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
|
||||
</div>
|
||||
<div v-else class="bp-quiz">
|
||||
<p class="bp-quiz-question markdown" v-html="renderMarkdownInline(quizCurrent.question)"></p>
|
||||
<div class="bp-quiz-grid">
|
||||
<button
|
||||
v-for="(o, i) in quizCurrent.options" :key="i"
|
||||
type="button"
|
||||
class="bp-quiz-opt"
|
||||
:class="{ gewaehlt: quizCurrent.gewaehlt.includes(i), correct: quizCurrent.done && o.correct, falsch: quizCurrent.done && !o.correct && quizCurrent.gewaehlt.includes(i) }"
|
||||
:disabled="quizCurrent.done"
|
||||
:title="`${i + 1}`"
|
||||
@click="quizToggle(i)"
|
||||
>
|
||||
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="examError" class="bp-error">{{ examError }}</p>
|
||||
<div class="bp-actions">
|
||||
<button v-if="!quizCurrent.done" class="bp-action primary" title="Alt+2" :disabled="examLoading" @click="quizAnswer">Answer</button>
|
||||
<template v-else>
|
||||
<span class="bp-tier" :class="quizCurrent.rating">{{ pointsLabel(quizCurrent.points) }}</span>
|
||||
<span class="bp-form-feedback">{{ quizCurrent.feedback }}</span>
|
||||
<button class="bp-action primary" @click="continueAction">{{ sectionChange ? 'To next section' : 'Next' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Cloze: sentence with gap + input -->
|
||||
<template v-else-if="shownForm === 'gaptext'">
|
||||
<div v-if="!clozeCurrent" class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
|
||||
</div>
|
||||
<div v-else class="bp-gap">
|
||||
<p class="bp-gap-sentence markdown" v-html="clozeSentenceHtml(clozeCurrent.sentence)"></p>
|
||||
<!-- hard: free text -->
|
||||
<input
|
||||
v-if="clozeCurrent.schwer"
|
||||
id="bp-gap-input"
|
||||
v-model="clozeCurrent.input"
|
||||
:disabled="clozeCurrent.done"
|
||||
placeholder="Term for the gap…"
|
||||
@keyup.enter="clozeAnswer"
|
||||
/>
|
||||
<p v-if="clozeCurrent.schwer && clozeCurrent.done && !clozeCurrent.feedback.startsWith('Correct')" class="bp-gap-solution">Solution: <span class="markdown" v-html="renderMarkdownInline(clozeCurrent.solution)"></span></p>
|
||||
<!-- easy: choose term from 4 -->
|
||||
<div v-if="!clozeCurrent.schwer" class="bp-quiz-grid">
|
||||
<button
|
||||
v-for="(o, i) in clozeCurrent.options" :key="i"
|
||||
type="button"
|
||||
class="bp-quiz-opt"
|
||||
:class="{ gewaehlt: clozeCurrent.gewaehlt.includes(i), correct: clozeCurrent.done && o.correct, falsch: clozeCurrent.done && !o.correct && clozeCurrent.gewaehlt.includes(i) }"
|
||||
:disabled="clozeCurrent.done"
|
||||
:title="`${i + 1}`"
|
||||
@click="clozeToggle(i)"
|
||||
>
|
||||
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="examError" class="bp-error">{{ examError }}</p>
|
||||
<div class="bp-actions">
|
||||
<button v-if="!clozeCurrent.done" class="bp-action primary" title="Alt+2" :disabled="examLoading || (clozeCurrent.schwer ? !clozeCurrent.input.trim() : !clozeCurrent.gewaehlt.length)" @click="clozeAnswer">Answer</button>
|
||||
<template v-else>
|
||||
<span class="bp-tier" :class="clozeCurrent.rating">{{ pointsLabel(clozeCurrent.points) }}</span>
|
||||
<span class="bp-form-feedback">{{ clozeCurrent.feedback }}</span>
|
||||
<button class="bp-action primary" @click="continueAction">{{ sectionChange ? 'To next section' : 'Next' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Explain: guided dialog (existing) -->
|
||||
<template v-else>
|
||||
<div v-if="examMessages.length" ref="examMessagesEl" class="bp-messages" @scroll="onExamScroll">
|
||||
<template v-for="(m, i) in examMessages" :key="i">
|
||||
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.rating" title="Click: check thoroughly" @click="openThorough(m)">
|
||||
<span v-if="m.points != null" class="bp-tier">{{ pointsLabel(m.points) }}</span>{{ m.content }}<span v-if="!m.checked" class="bp-pruefend"> · being checked…</span>
|
||||
<div v-if="thoroughMsg === m" class="bp-thorough" @click.stop>
|
||||
<input id="bp-thorough-input" v-model="thoroughText" placeholder="Why unsatisfied? (optional)" @keyup.enter="submitThorough" />
|
||||
<button class="bp-action primary" @click="submitThorough">Check thoroughly</button>
|
||||
<button class="bp-action" @click="thoroughMsg = null">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="m.kind === 'fehler'" class="bp-error">{{ m.content }}</div>
|
||||
<div v-else-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||
<div v-else class="bp-msg user">{{ m.content }}</div>
|
||||
</template>
|
||||
<div v-if="examLoading" class="bp-msg assistant bp-typing">…</div>
|
||||
</div>
|
||||
|
||||
<div v-if="examPhase === 'idle'" class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="examPhase === 'question_offen'" class="bp-input">
|
||||
<textarea
|
||||
ref="examInputEl"
|
||||
v-model="examInput"
|
||||
rows="2"
|
||||
placeholder="Answer — or ask if unclear…"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<template v-else-if="examPhase === 'question_offen'">
|
||||
<button class="bp-action" title="Alt+1" :disabled="!examInput.trim()" @click="askFollowUp"><span class="bp-kbd">1</span>Ask</button>
|
||||
<button class="bp-action primary" title="Alt+2" :disabled="!examInput.trim()" @click="submitAnswer"><span class="bp-kbd">2</span>Submit answer</button>
|
||||
<button v-if="hasDiscussion" class="bp-action" title="Alt+3" @click="evaluateHistory"><span class="bp-kbd">3</span>Evaluate history</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="bp-action primary" title="Alt+2" @click="continueAction"><span class="bp-kbd">2</span>{{ sectionChange ? 'To next section' : 'Next question' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bp { margin-top: 0.75rem; }
|
||||
|
||||
.bp-toggles { display: flex; gap: 0.4rem; }
|
||||
.bp-toggles button {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bp-toggles button:hover { border-color: var(--border-strong); color: var(--text); }
|
||||
.bp-toggles button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
|
||||
/* View switcher (Compact/Explanatory) — segmented, left of the tabs */
|
||||
.bp-ansicht { display: inline-flex; margin-right: 0.5rem; }
|
||||
.bp-ansicht button {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-soft);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bp-ansicht button:first-child { border-radius: 999px 0 0 999px; }
|
||||
.bp-ansicht button:last-child { border-radius: 0 999px 999px 0; }
|
||||
.bp-ansicht button + button { border-left: none; }
|
||||
.bp-ansicht button:hover { color: var(--text); }
|
||||
.bp-ansicht button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
|
||||
|
||||
.bp-chip {
|
||||
font-size: 0.7rem;
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.bp-chip.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||||
.bp-chip.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||
|
||||
.bp-panel {
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.bp-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
|
||||
.bp-hint-key { font-size: 0.72rem; opacity: 0.7; white-space: nowrap; }
|
||||
.bp-error { font-size: 0.85rem; color: var(--danger); margin: 0.5rem 0 0; }
|
||||
|
||||
.bp-action {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bp-action:hover { border-color: var(--accent); }
|
||||
.bp-action:disabled { opacity: 0.5; cursor: default; }
|
||||
.bp-action.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.bp-action.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
.bp-action.cancel { background: var(--danger); border-color: var(--danger); color: var(--on-accent); }
|
||||
|
||||
.bp-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
|
||||
.bp-actions .bp-action { margin-top: 0; }
|
||||
|
||||
.bp-kbd {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 1rem; height: 1rem; padding: 0 0.2rem; margin-right: 0.35rem;
|
||||
font-size: 0.65rem; font-weight: 600; border-radius: 3px;
|
||||
background: var(--panel); border: 1px solid var(--border); color: var(--text-muted);
|
||||
}
|
||||
.bp-action.primary .bp-kbd { background: color-mix(in srgb, var(--on-accent) 20%, transparent); border-color: transparent; color: var(--on-accent); }
|
||||
|
||||
.bp-messages { display: flex; flex-direction: column; gap: 0.4rem; max-height: 320px; overflow-y: auto; }
|
||||
.bp-msg {
|
||||
max-width: 88%;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.bp-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
|
||||
.bp-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
|
||||
.bp-typing { color: var(--text-faint); font-style: italic; }
|
||||
|
||||
/* Evaluation of the last answer — separated above the next question */
|
||||
.bp-feedback {
|
||||
align-self: flex-start;
|
||||
max-width: 88%;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.bp-pruefend { font-style: italic; opacity: 0.7; font-size: 0.92em; }
|
||||
.bp-feedback { cursor: pointer; }
|
||||
.bp-feedback.gut { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.bp-feedback.neutral { background: var(--warning-soft); border-color: var(--warning-border); color: var(--warning); }
|
||||
.bp-feedback.schlecht { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); color: var(--danger); }
|
||||
.bp-tier { font-weight: 700; text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.03em; margin-right: 0.4rem; opacity: 0.85; }
|
||||
.bp-tier.gut { color: var(--success); }
|
||||
.bp-tier.neutral { color: var(--warning); }
|
||||
.bp-tier.schlecht { color: var(--danger); }
|
||||
|
||||
/* Quiz: question + multiple choice */
|
||||
.bp-quiz, .bp-gap { margin-top: 0.6rem; }
|
||||
.bp-quiz-question, .bp-gap-sentence { font-size: 0.9rem; font-weight: 600; margin: 0 0 0.5rem; line-height: 1.5; }
|
||||
.bp-luecke { font-weight: 700; letter-spacing: 1px; color: var(--accent); padding: 0 0.15rem; }
|
||||
.bp-quiz-opt .markdown, .bp-quiz-question.markdown, .bp-gap-sentence.markdown { display: inline; }
|
||||
.bp-quiz-opt .markdown { min-width: 0; overflow-wrap: anywhere; }
|
||||
.bp-quiz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.bp-quiz-opt {
|
||||
display: flex; gap: 0.45rem; align-items: flex-start; text-align: left;
|
||||
min-width: 0; overflow-wrap: anywhere;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--panel); color: var(--text); cursor: pointer; font-size: 0.85rem;
|
||||
}
|
||||
.bp-quiz-opt:disabled { cursor: default; }
|
||||
.bp-quiz-opt.gewaehlt { border-color: var(--accent); background: var(--accent-soft, rgba(99, 102, 241, 0.12)); }
|
||||
.bp-quiz-opt.correct { background: var(--success-soft); border-color: var(--success-border); }
|
||||
.bp-quiz-opt.falsch { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); }
|
||||
.bp-form-feedback { font-size: 0.82rem; color: var(--text-muted); flex: 1; }
|
||||
|
||||
/* Cloze: sentence + input */
|
||||
.bp-gap input {
|
||||
width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; font: inherit; font-size: 0.88rem;
|
||||
border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text);
|
||||
}
|
||||
.bp-gap input:disabled { opacity: 0.7; }
|
||||
.bp-gap-solution { font-size: 0.82rem; color: var(--success); margin: 0.35rem 0 0; }
|
||||
/* Thorough-check field (via right-click) inside the evaluation bubble */
|
||||
.bp-thorough { display: flex; gap: 0.3rem; margin-top: 0.4rem; align-items: center; }
|
||||
.bp-thorough input {
|
||||
flex: 1; min-width: 0; padding: 0.3rem 0.5rem; font: inherit; font-size: 0.8rem;
|
||||
border: 1px solid var(--border-strong); border-radius: 6px; background: var(--panel); color: var(--text);
|
||||
}
|
||||
.bp-thorough .bp-action { margin-top: 0; }
|
||||
|
||||
.bp-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
|
||||
.bp-input textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
}
|
||||
.bp-input button {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bp-input button:disabled { opacity: 0.5; cursor: default; }
|
||||
.bp-input button.cancel { background: var(--danger); }
|
||||
</style>
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { fetchBausteineUebersicht } from '../api.js'
|
||||
import { fetchBlocksOverview } from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
steps: { type: Array, default: () => [] }, // feine Teilschritte {label, phase, state}
|
||||
steps: { type: Array, default: () => [] }, // fine sub-steps {label, phase, state}
|
||||
generating: { type: Boolean, default: false },
|
||||
progress: { type: String, default: null },
|
||||
ready: { type: Boolean, default: false },
|
||||
@@ -12,46 +12,46 @@ const props = defineProps({
|
||||
})
|
||||
const emit = defineEmits(['close', 'restartFrom', 'resetFrom', 'restartAll', 'removeAll', 'cancel'])
|
||||
|
||||
// Teilschritte nach Phase gruppieren, globalen Index für den Re-Run mitführen.
|
||||
const phasenGruppen = computed(() => {
|
||||
// Group sub-steps by phase, carrying the global index for the re-run.
|
||||
const phaseGroups = computed(() => {
|
||||
const out = []
|
||||
props.steps.forEach((s, i) => {
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.phase === s.phase) last.schritte.push({ ...s, idx: i })
|
||||
else out.push({ phase: s.phase, schritte: [{ ...s, idx: i }] })
|
||||
if (last && last.phase === s.phase) last.steps.push({ ...s, idx: i })
|
||||
else out.push({ phase: s.phase, steps: [{ ...s, idx: i }] })
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
const gewaehlt = ref(null) // markierter Startpunkt (Schritt-Index)
|
||||
const confirm = ref(null) // welche destruktive Aktion gerade „Sicher?" zeigt
|
||||
const gewaehltLabel = computed(() => props.steps[gewaehlt.value]?.label || '')
|
||||
const selected = ref(null) // marked start point (step index)
|
||||
const confirm = ref(null) // which destructive action currently shows "Sure?"
|
||||
const selectedLabel = computed(() => props.steps[selected.value]?.label || '')
|
||||
|
||||
function stepKlick(idx) {
|
||||
function stepClick(idx) {
|
||||
if (props.generating) return
|
||||
gewaehlt.value = gewaehlt.value === idx ? null : idx
|
||||
selected.value = selected.value === idx ? null : idx
|
||||
confirm.value = null
|
||||
}
|
||||
// 2-Klick-Bestätigung für destruktive Aktionen: erster Klick „armt", zweiter führt aus.
|
||||
function arm(aktion, fn) {
|
||||
if (confirm.value === aktion) { confirm.value = null; fn() }
|
||||
else confirm.value = aktion
|
||||
// 2-click confirmation for destructive actions: first click "arms", second runs it.
|
||||
function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
function neuAbHier() { const i = gewaehlt.value; gewaehlt.value = null; confirm.value = null; emit('restartFrom', i) }
|
||||
function loeschAbHier() { const i = gewaehlt.value; gewaehlt.value = null; confirm.value = null; emit('resetFrom', i) }
|
||||
function regenerateFromHere() { const i = selected.value; selected.value = null; confirm.value = null; emit('restartFrom', i) }
|
||||
function deleteFromHere() { const i = selected.value; selected.value = null; confirm.value = null; emit('resetFrom', i) }
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
// Lernpfad-Stufen: Reihenfolge + Label (Farbe via CSS-Klasse st-<key>).
|
||||
const STUFEN = [
|
||||
{ key: 'anfaenger', label: 'Anfänger' },
|
||||
{ key: 'fortgeschritten', label: 'Fortgeschritten' },
|
||||
{ key: 'experte', label: 'Experte' },
|
||||
// Learning-path levels: order + label (color via CSS class st-<key>).
|
||||
const LEVELS = [
|
||||
{ key: 'beginner', label: 'Beginner' },
|
||||
{ key: 'advanced', label: 'Advanced' },
|
||||
{ key: 'expert', label: 'Expert' },
|
||||
]
|
||||
// Alt-Themen tragen noch einfach/mittel/schwer → auf die neuen Keys mappen.
|
||||
const ALT_STUFE = { einfach: 'anfaenger', mittel: 'fortgeschritten', schwer: 'experte' }
|
||||
// Legacy topics still carry einfach/mittel/schwer → map them to the new keys.
|
||||
const LEGACY_LEVEL = { einfach: 'beginner', mittel: 'advanced', schwer: 'expert' }
|
||||
|
||||
watch(() => props.topic, load, { immediate: true })
|
||||
|
||||
@@ -60,84 +60,84 @@ async function load() {
|
||||
error.value = null
|
||||
items.value = []
|
||||
try {
|
||||
items.value = await fetchBausteineUebersicht(props.topic)
|
||||
items.value = await fetchBlocksOverview(props.topic)
|
||||
} catch (e) {
|
||||
error.value = 'Übersicht nicht verfügbar — bitte erst Bausteine erstellen.'
|
||||
error.value = 'Overview not available — create blocks first.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Baustein relevant = hat ≥1 relevanten Subbaustein (gleiche Regel wie der Guide).
|
||||
// Ohne Relevanz-Daten (Alt-Themen) nicht dimmen.
|
||||
// Block relevant = has ≥1 relevant subblock (same rule as the guide).
|
||||
// Without relevance data (legacy topics) don't dim.
|
||||
function relevant(b) {
|
||||
const mitRelevanz = (b.subbausteine || []).filter((s) => s.relevanz)
|
||||
return !mitRelevanz.length || mitRelevanz.some((s) => s.relevanz === 'relevant')
|
||||
const withRelevance = (b.subblocks || []).filter((s) => s.relevance)
|
||||
return !withRelevance.length || withRelevance.some((s) => s.relevance === 'relevant')
|
||||
}
|
||||
|
||||
// Nur nicht-leere Stufen-Gruppen je Baustein (v-if + v-for nicht auf einem Element)
|
||||
function gruppen(b) {
|
||||
return STUFEN
|
||||
.map((st) => ({ ...st, subs: (b.subbausteine || []).filter((s) => (ALT_STUFE[s.stufe] || s.stufe) === st.key) }))
|
||||
// Only non-empty level groups per block (v-if + v-for not on one element)
|
||||
function groups(b) {
|
||||
return LEVELS
|
||||
.map((st) => ({ ...st, subs: (b.subblocks || []).filter((s) => (LEGACY_LEVEL[s.level] || s.level) === st.key) }))
|
||||
.filter((g) => g.subs.length)
|
||||
}
|
||||
|
||||
const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine?.length || 0), 0))
|
||||
const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.length || 0), 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bk-view">
|
||||
<header class="bk-head">
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="bk-sub">Bausteine-Übersicht</span>
|
||||
<span v-if="items.length" class="bk-count">{{ items.length }} Bausteine · {{ subTotal }} Subbausteine</span>
|
||||
<span class="bk-sub">Blocks overview</span>
|
||||
<span v-if="items.length" class="bk-count">{{ items.length }} Blocks · {{ subTotal }} Subblocks</span>
|
||||
<span class="bk-spacer"></span>
|
||||
<button class="bk-close" title="Schließen" @click="emit('close')">✕</button>
|
||||
<button class="bk-close" title="Close" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<section v-if="steps.length" class="bk-steps">
|
||||
<div class="bk-steps-top">
|
||||
<div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div>
|
||||
<div v-if="!generating" class="bk-global-actions">
|
||||
<button class="bk-act play" @click="emit('restartAll')">{{ partial ? 'Fortsetzen' : ready ? 'Neu generieren' : 'Generieren' }}</button>
|
||||
<button class="bk-act play" @click="emit('restartAll')">{{ partial ? 'Continue' : ready ? 'Regenerate' : 'Generate' }}</button>
|
||||
<button
|
||||
v-if="ready || partial"
|
||||
class="bk-act danger"
|
||||
:class="{ armed: confirm === 'remove' }"
|
||||
@click="arm('remove', () => emit('removeAll'))"
|
||||
>{{ confirm === 'remove' ? 'Sicher?' : 'Entfernen' }}</button>
|
||||
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
|
||||
</div>
|
||||
<div v-else class="bk-global-actions">
|
||||
<button class="bk-act danger" @click="emit('cancel')">Abbrechen</button>
|
||||
<button class="bk-act danger" @click="emit('cancel')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bk-phasen">
|
||||
<div v-for="g in phasenGruppen" :key="g.phase" class="bk-phase">
|
||||
<div v-for="g in phaseGroups" :key="g.phase" class="bk-phase">
|
||||
<span class="bk-phase-label">{{ g.phase }}</span>
|
||||
<div class="bk-schritte">
|
||||
<div class="bk-steps">
|
||||
<button
|
||||
v-for="s in g.schritte"
|
||||
v-for="s in g.steps"
|
||||
:key="s.idx"
|
||||
class="bk-step"
|
||||
:class="[s.state, { sel: gewaehlt === s.idx }]"
|
||||
:class="[s.state, { sel: selected === s.idx }]"
|
||||
:disabled="generating"
|
||||
:title="`Startpunkt «${s.label}» wählen`"
|
||||
@click="stepKlick(s.idx)"
|
||||
:title="`Choose start point «${s.label}»`"
|
||||
@click="stepClick(s.idx)"
|
||||
>{{ s.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="gewaehlt !== null && !generating" class="bk-step-actions">
|
||||
<span class="bk-step-actions-label">Ab «{{ gewaehltLabel }}»:</span>
|
||||
<button class="bk-act play" @click="neuAbHier">↻ neu generieren</button>
|
||||
<button class="bk-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', loeschAbHier)">{{ confirm === 'reset' ? 'Sicher?' : '✕ alles löschen' }}</button>
|
||||
<button class="bk-act ghost" @click="gewaehlt = null; confirm = null">Abbrechen</button>
|
||||
<div v-if="selected !== null && !generating" class="bk-step-actions">
|
||||
<span class="bk-step-actions-label">From «{{ selectedLabel }}»:</span>
|
||||
<button class="bk-act play" @click="regenerateFromHere">↻ regenerate</button>
|
||||
<button class="bk-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', deleteFromHere)">{{ confirm === 'reset' ? 'Sure?' : '✕ delete all' }}</button>
|
||||
<button class="bk-act ghost" @click="selected = null; confirm = null">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="bk-empty-state">Lade…</div>
|
||||
<div v-if="loading" class="bk-empty-state">Loading…</div>
|
||||
<div v-else-if="error" class="bk-empty-state">{{ error }}</div>
|
||||
<div v-else-if="!items.length" class="bk-empty-state">Noch keine Bausteine.</div>
|
||||
<div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div>
|
||||
|
||||
<div v-else class="bk-grid">
|
||||
<article
|
||||
@@ -145,21 +145,21 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
|
||||
:key="b.num"
|
||||
class="bk-card"
|
||||
:class="{ 'bk-irrelevant': !relevant(b) }"
|
||||
:title="relevant(b) ? null : 'Nicht relevant — kommt später in den „Rest“'"
|
||||
:title="relevant(b) ? null : 'Not relevant — comes later in the "Rest"'"
|
||||
>
|
||||
<h3 class="bk-title"><span class="bk-num">{{ b.num }}</span>{{ b.titel }}</h3>
|
||||
<p v-if="b.beschreibung" class="bk-desc">{{ b.beschreibung }}</p>
|
||||
<div v-if="b.subbausteine && b.subbausteine.length" class="bk-stufen">
|
||||
<div v-for="g in gruppen(b)" :key="g.key" class="bk-stufe" :class="'st-' + g.key">
|
||||
<span class="bk-stufe-label">{{ g.label }}</span>
|
||||
<h3 class="bk-title"><span class="bk-num">{{ b.num }}</span>{{ b.title }}</h3>
|
||||
<p v-if="b.description" class="bk-desc">{{ b.description }}</p>
|
||||
<div v-if="b.subblocks && b.subblocks.length" class="bk-levels">
|
||||
<div v-for="g in groups(b)" :key="g.key" class="bk-level" :class="'st-' + g.key">
|
||||
<span class="bk-level-label">{{ g.label }}</span>
|
||||
<ul>
|
||||
<li v-for="s in g.subs" :key="s.titel" :class="{ rand: s.relevanz === 'rand' }">
|
||||
{{ s.titel }}<span v-if="s.relevanz === 'rand'" class="rand-tag" title="Randthema — kommt später in den „Rest“">Rand</span>
|
||||
<li v-for="s in g.subs" :key="s.title" :class="{ rand: s.relevance === 'peripheral' }">
|
||||
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Peripheral topic — comes later in the 'Rest'">Edge</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="bk-no-subs">Keine Subbausteine.</p>
|
||||
<p v-else class="bk-no-subs">No subblocks.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,7 +199,7 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
|
||||
}
|
||||
.bk-close:hover { border-color: var(--accent); }
|
||||
|
||||
/* Schritt-Übersicht über den Bausteinen */
|
||||
/* Step overview above the blocks */
|
||||
.bk-steps {
|
||||
padding: 0.85rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -232,7 +232,7 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.bk-schritte { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.bk-steps { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.bk-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -253,12 +253,12 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
|
||||
.bk-step.pending { color: var(--text-faint); }
|
||||
.bk-step.sel { border-color: var(--accent); color: var(--on-accent); background: var(--accent); font-weight: 700; box-shadow: 0 0 0 2px var(--accent-soft); }
|
||||
|
||||
/* Kopf: Fortschritt links, globale Buttons rechts */
|
||||
/* Header: progress left, global buttons right */
|
||||
.bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.7rem; }
|
||||
.bk-steps-top .bk-progress { margin-bottom: 0; }
|
||||
.bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
||||
|
||||
/* Aktions-Leiste bei gewähltem Startpunkt */
|
||||
/* Action bar for the selected start point */
|
||||
.bk-step-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -311,7 +311,7 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
/* Nicht relevante Bausteine (kein relevanter Subbaustein) gedimmt */
|
||||
/* Non-relevant blocks (no relevant subblock) dimmed */
|
||||
.bk-card.bk-irrelevant { opacity: 0.5; }
|
||||
|
||||
.bk-title {
|
||||
@@ -342,35 +342,35 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.bk-stufen { display: flex; flex-direction: column; gap: 0.55rem; }
|
||||
.bk-stufe {
|
||||
.bk-levels { display: flex; flex-direction: column; gap: 0.55rem; }
|
||||
.bk-level {
|
||||
border-left: 3px solid var(--st);
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
.bk-stufe.st-anfaenger { --st: var(--stufe-anfaenger); }
|
||||
.bk-stufe.st-fortgeschritten { --st: var(--stufe-fortgeschritten); }
|
||||
.bk-stufe.st-experte { --st: var(--stufe-experte); }
|
||||
.bk-level.st-beginner { --st: var(--level-beginner); }
|
||||
.bk-level.st-advanced { --st: var(--level-advanced); }
|
||||
.bk-level.st-expert { --st: var(--level-expert); }
|
||||
|
||||
.bk-stufe-label {
|
||||
.bk-level-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--st);
|
||||
}
|
||||
.bk-stufe ul {
|
||||
.bk-level ul {
|
||||
list-style: disc;
|
||||
margin: 0.25rem 0 0;
|
||||
padding-left: 1.15rem;
|
||||
}
|
||||
.bk-stufe li {
|
||||
.bk-level li {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.bk-stufe li::marker { color: var(--text-faint); }
|
||||
.bk-stufe li.rand { color: var(--text-faint); }
|
||||
.bk-level li::marker { color: var(--text-faint); }
|
||||
.bk-level li.rand { color: var(--text-faint); }
|
||||
.rand-tag {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.6rem;
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { fetchElements } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { renderMarkdown, plainText } from '../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
version: { type: Number, default: 0 }, // Erhöhung = Elemente neu laden
|
||||
version: { type: Number, default: 0 }, // increment = reload elements
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open'])
|
||||
@@ -18,13 +18,10 @@ async function load() {
|
||||
try {
|
||||
elements.value = await fetchElements(props.topic)
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Elemente:', e)
|
||||
console.error('Failed to load elements:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function plain(text) {
|
||||
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -33,10 +30,10 @@ function plain(text) {
|
||||
<div class="overview-content">
|
||||
<header class="overview-head">
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="overview-format">Elemente</span>
|
||||
<span class="overview-format">Elements</span>
|
||||
</header>
|
||||
<p v-if="!elements.length" class="overview-empty">
|
||||
Noch keine Elemente. Rechts in der Sidebar Stichwort eingeben und + klicken.
|
||||
No elements yet. Enter a keyword in the sidebar on the right and click +.
|
||||
</p>
|
||||
<div class="element-grid">
|
||||
<article
|
||||
@@ -45,11 +42,11 @@ function plain(text) {
|
||||
class="element-card"
|
||||
@click="emit('open', el)"
|
||||
>
|
||||
<h3>{{ plain(el.title) }}</h3>
|
||||
<h3>{{ plainText(el.title) }}</h3>
|
||||
<div class="markdown" v-html="renderMarkdown(el.description)"></div>
|
||||
<div v-for="(ex, i) in el.examples" :key="i" class="markdown el-example" v-html="renderMarkdown(ex)"></div>
|
||||
<div v-if="el.hints.length" class="el-hints-block">
|
||||
<h4>Hinweise</h4>
|
||||
<h4>Hints</h4>
|
||||
<ul>
|
||||
<li v-for="(h, i) in el.hints" :key="i" class="markdown" v-html="renderMarkdown(h)"></li>
|
||||
</ul>
|
||||
@@ -161,7 +158,7 @@ function plain(text) {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
/* Markdown: Basis global (assets/markdown.css), hier nur Grundschrift der Karten */
|
||||
/* Markdown: base styles global (assets/markdown.css), here only the card base font */
|
||||
.markdown {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
|
||||
@@ -2,59 +2,59 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { renderMarkdownInline } from '../markdown.js'
|
||||
|
||||
const props = defineProps({ karten: { type: Array, default: () => [] } })
|
||||
const props = defineProps({ cards: { type: Array, default: () => [] } })
|
||||
|
||||
const offen = ref(false)
|
||||
const reihenfolge = ref([])
|
||||
const open = ref(false)
|
||||
const order = ref([])
|
||||
const pos = ref(0)
|
||||
const flipped = ref(false)
|
||||
|
||||
function reset() {
|
||||
reihenfolge.value = props.karten.map((_, i) => i)
|
||||
order.value = props.cards.map((_, i) => i)
|
||||
pos.value = 0
|
||||
flipped.value = false
|
||||
}
|
||||
watch(() => props.karten, reset, { immediate: true })
|
||||
watch(() => props.cards, reset, { immediate: true })
|
||||
|
||||
const aktuelle = computed(() => props.karten[reihenfolge.value[pos.value]] || null)
|
||||
const zaehler = computed(() => `${Math.min(pos.value + 1, reihenfolge.value.length)} / ${reihenfolge.value.length}`)
|
||||
const current = computed(() => props.cards[order.value[pos.value]] || null)
|
||||
const counter = computed(() => `${Math.min(pos.value + 1, order.value.length)} / ${order.value.length}`)
|
||||
|
||||
function weiter(gewusst) {
|
||||
if (gewusst) {
|
||||
function next(known) {
|
||||
if (known) {
|
||||
pos.value++
|
||||
} else {
|
||||
// „Nochmal" → Karte ans Ende der Runde schieben (leichtes Spacing).
|
||||
const [k] = reihenfolge.value.splice(pos.value, 1)
|
||||
reihenfolge.value.push(k)
|
||||
// "Again" → push the card to the end of the round (light spacing).
|
||||
const [k] = order.value.splice(pos.value, 1)
|
||||
order.value.push(k)
|
||||
}
|
||||
if (pos.value >= reihenfolge.value.length) pos.value = 0
|
||||
if (pos.value >= order.value.length) pos.value = 0
|
||||
flipped.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="karten.length" class="flashcards">
|
||||
<button class="art-head" @click="offen = !offen">
|
||||
<span class="art-icon">🃏</span> Karteikarten
|
||||
<span class="art-count">{{ karten.length }}</span>
|
||||
<span class="art-toggle">{{ offen ? '▾' : '▸' }}</span>
|
||||
<div v-if="cards.length" class="flashcards">
|
||||
<button class="art-head" @click="open = !open">
|
||||
<span class="art-icon">🃏</span> Flashcards
|
||||
<span class="art-count">{{ cards.length }}</span>
|
||||
<span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-if="offen && aktuelle" class="fc-body">
|
||||
<div v-if="open && current" class="fc-body">
|
||||
<div class="fc-card" :class="{ flipped }" @click="flipped = !flipped">
|
||||
<div class="fc-zaehler">{{ zaehler }}</div>
|
||||
<div class="fc-zaehler">{{ counter }}</div>
|
||||
<div v-if="!flipped" class="fc-seite">
|
||||
<span class="fc-label">Frage</span>
|
||||
<div class="fc-text" v-html="renderMarkdownInline(aktuelle.frage)"></div>
|
||||
<span class="fc-hint">Klick zum Umdrehen</span>
|
||||
<span class="fc-label">Question</span>
|
||||
<div class="fc-text" v-html="renderMarkdownInline(current.question)"></div>
|
||||
<span class="fc-hint">Click to flip</span>
|
||||
</div>
|
||||
<div v-else class="fc-seite">
|
||||
<span class="fc-label">Antwort</span>
|
||||
<div class="fc-text" v-html="renderMarkdownInline(aktuelle.antwort)"></div>
|
||||
<span class="fc-label">Answer</span>
|
||||
<div class="fc-text" v-html="renderMarkdownInline(current.answer)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="flipped" class="fc-aktionen">
|
||||
<button class="fc-btn nochmal" @click="weiter(false)">Nochmal</button>
|
||||
<button class="fc-btn gewusst" @click="weiter(true)">Gewusst</button>
|
||||
<button class="fc-btn nochmal" @click="next(false)">Again</button>
|
||||
<button class="fc-btn gewusst" @click="next(true)">Knew it</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup>
|
||||
// Themenweite Misch-Prüfung: wählt einen Baustein (priorisiert: noch nicht auf Max), darunter
|
||||
// zufällig, und nutzt dafür das normale BausteinPanel. Jeder Baustein hat seinen eigenen
|
||||
// Prüfungs-Slot (pruefungCache), also bleiben die Frage-Pools getrennt. Buchung läuft je
|
||||
// Baustein über die normale Prüfungs-Route. Gesamt-cap = Σ cap_final aller prüfbaren Bausteine.
|
||||
// Topic-wide mixed exam: picks a block (prioritized: not yet at max), then random
|
||||
// within it, and reuses the normal BlockPanel. Each block has its own exam slot
|
||||
// (pruefungCache), so the question pools stay separate. Booking runs per block via the
|
||||
// normal exam route. Total cap = Σ cap_final of all checkable blocks.
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuides, fetchGuideContent, fetchBausteinLernstand } from '../api.js'
|
||||
import BausteinPanel from './BausteinPanel.vue'
|
||||
import { fetchGuides, fetchGuideContent, fetchBlockLearnState } from '../api.js'
|
||||
import BlockPanel from './BlockPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
@@ -13,41 +13,41 @@ const props = defineProps({
|
||||
})
|
||||
const emit = defineEmits(['progressChanged', 'fokus-active'])
|
||||
|
||||
const sections = ref([]) // [{titel, md, kompakt, num}] — prüfbare Sections der Vollfassung
|
||||
const lernstand = ref({}) // titel -> {gute_antworten, streak, cap, cap_aktuell, freie_ebene}
|
||||
const aktiv = ref(null) // aktueller Baustein-Titel
|
||||
const ladefehler = ref(null)
|
||||
const sections = ref([]) // [{title, md, compact, num}] — checkable sections of the full version
|
||||
const learnstate = ref({}) // title -> {good_answers, streak, cap, cap_aktuell, freie_level}
|
||||
const active = ref(null) // current block title
|
||||
const loadError = ref(null)
|
||||
|
||||
const pruefbare = computed(() => sections.value.filter((s) => (lernstand.value[s.titel]?.cap || 0) > 0))
|
||||
const checkable = computed(() => sections.value.filter((s) => (learnstate.value[s.title]?.cap || 0) > 0))
|
||||
|
||||
// Gesamt-Fortschritt: Σ min(score, cap_final) / Σ cap_final über alle prüfbaren Bausteine.
|
||||
const gesamt = computed(() => {
|
||||
// Total progress: Σ min(score, cap_final) / Σ cap_final over all checkable blocks.
|
||||
const total = computed(() => {
|
||||
let score = 0, cap = 0
|
||||
for (const s of pruefbare.value) {
|
||||
const l = lernstand.value[s.titel] || {}
|
||||
score += Math.min(l.gute_antworten || 0, l.cap || 0)
|
||||
for (const s of checkable.value) {
|
||||
const l = learnstate.value[s.title] || {}
|
||||
score += Math.min(l.good_answers || 0, l.cap || 0)
|
||||
cap += l.cap || 0
|
||||
}
|
||||
return { score, cap }
|
||||
})
|
||||
|
||||
const aktivSection = computed(() => sections.value.find((s) => s.titel === aktiv.value) || null)
|
||||
const aktivStatus = computed(() => lernstand.value[aktiv.value] || null)
|
||||
const activeSection = computed(() => sections.value.find((s) => s.title === active.value) || null)
|
||||
const activeStatus = computed(() => learnstate.value[active.value] || null)
|
||||
|
||||
// Priorität: Bausteine, die noch nicht auf cap_final sind; darunter zufällig.
|
||||
function waehleBaustein() {
|
||||
const offen = pruefbare.value.filter((s) => {
|
||||
const l = lernstand.value[s.titel] || {}
|
||||
return (l.gute_antworten || 0) < (l.cap || 0)
|
||||
// Priority: blocks not yet at cap_final; among those random.
|
||||
function pickBlock() {
|
||||
const unfinished = checkable.value.filter((s) => {
|
||||
const l = learnstate.value[s.title] || {}
|
||||
return (l.good_answers || 0) < (l.cap || 0)
|
||||
})
|
||||
const pool = offen.length ? offen : pruefbare.value
|
||||
aktiv.value = pool.length ? pool[Math.floor(Math.random() * pool.length)].titel : null
|
||||
const pool = unfinished.length ? unfinished : checkable.value
|
||||
active.value = pool.length ? pool[Math.floor(Math.random() * pool.length)].title : null
|
||||
}
|
||||
|
||||
async function ladeLernstand() {
|
||||
async function loadLearnState() {
|
||||
try {
|
||||
lernstand.value = (await fetchBausteinLernstand(props.topic)).bausteine || {}
|
||||
} catch { /* offline → alter Stand */ }
|
||||
learnstate.value = (await fetchBlockLearnState(props.topic)).blocks || {}
|
||||
} catch { /* offline → keep old state */ }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -57,23 +57,23 @@ onMounted(async () => {
|
||||
const g = guides
|
||||
.filter((x) => x.topic === props.topic && x.format === 'Guide' && x.status === 'done')
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0]
|
||||
if (!g) { ladefehler.value = 'Kein fertiger Guide — erst einen Guide erstellen.'; return }
|
||||
const content = await fetchGuideContent(g.id, 4) // Vollfassung (alle Subs)
|
||||
if (!g) { loadError.value = 'No finished guide — create a guide first.'; return }
|
||||
const content = await fetchGuideContent(g.id, 4) // full version (all subs)
|
||||
sections.value = (content.chapters || [])
|
||||
.flatMap((ch) => ch.sections || [])
|
||||
.filter((s) => s.pruefbar !== false)
|
||||
.map((s) => ({ titel: s.title, md: s.md, kompakt: s.kompakt, num: s.num }))
|
||||
await ladeLernstand()
|
||||
waehleBaustein()
|
||||
.filter((s) => s.checkable !== false)
|
||||
.map((s) => ({ title: s.title, md: s.md, compact: s.compact, num: s.num }))
|
||||
await loadLearnState()
|
||||
pickBlock()
|
||||
} catch {
|
||||
ladefehler.value = 'Konnte die Prüfung nicht laden.'
|
||||
loadError.value = 'Could not load the exam.'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => emit('fokus-active', false))
|
||||
|
||||
async function onStatus() {
|
||||
await ladeLernstand() // cap_aktuell/freie_ebene können nach einer Antwort gewachsen sein
|
||||
await loadLearnState() // cap_aktuell/freie_level may have grown after an answer
|
||||
emit('progressChanged')
|
||||
}
|
||||
</script>
|
||||
@@ -81,27 +81,27 @@ async function onStatus() {
|
||||
<template>
|
||||
<div class="ge-panel">
|
||||
<div class="ge-head">
|
||||
<h2>Allgemeine Prüfung</h2>
|
||||
<span v-if="!ladefehler" class="ge-score">{{ gesamt.score }} / {{ gesamt.cap }}</span>
|
||||
<button v-if="aktiv" class="ge-next" title="Anderen Baustein prüfen" @click="waehleBaustein">⟳ Anderer Baustein</button>
|
||||
<h2>General Exam</h2>
|
||||
<span v-if="!loadError" class="ge-score">{{ total.score }} / {{ total.cap }}</span>
|
||||
<button v-if="active" class="ge-next" title="Check another block" @click="pickBlock">⟳ Another block</button>
|
||||
</div>
|
||||
|
||||
<p v-if="ladefehler" class="ge-msg">{{ ladefehler }}</p>
|
||||
<p v-else-if="!pruefbare.length" class="ge-msg">Noch keine prüfbaren Bausteine.</p>
|
||||
<p v-else-if="!aktiv" class="ge-msg">Alles auf Max — nichts mehr zu prüfen. 🎉</p>
|
||||
<p v-if="loadError" class="ge-msg">{{ loadError }}</p>
|
||||
<p v-else-if="!checkable.length" class="ge-msg">No checkable blocks yet.</p>
|
||||
<p v-else-if="!active" class="ge-msg">Everything maxed out — nothing left to check. 🎉</p>
|
||||
|
||||
<div v-else class="ge-body">
|
||||
<div class="ge-baustein">Baustein: <strong>{{ aktiv }}</strong></div>
|
||||
<BausteinPanel
|
||||
:key="aktiv"
|
||||
<div class="ge-block">Block: <strong>{{ active }}</strong></div>
|
||||
<BlockPanel
|
||||
:key="active"
|
||||
mode="full"
|
||||
:topic="topic"
|
||||
:baustein="aktiv"
|
||||
:section="aktivSection?.md || ''"
|
||||
:section-kompakt="aktivSection?.kompakt || ''"
|
||||
:block="active"
|
||||
:section="activeSection?.md || ''"
|
||||
:section-compact="activeSection?.compact || ''"
|
||||
:provider="provider"
|
||||
:status="aktivStatus"
|
||||
:cap="aktivStatus?.cap || 0"
|
||||
:status="activeStatus"
|
||||
:cap="activeStatus?.cap || 0"
|
||||
@statusChanged="onStatus"
|
||||
/>
|
||||
</div>
|
||||
@@ -116,5 +116,5 @@ async function onStatus() {
|
||||
.ge-next { margin-left: auto; padding: 5px 10px; border: 1px solid var(--border-strong); border-radius: 6px; background: var(--bg); cursor: pointer; }
|
||||
.ge-next:hover { color: var(--accent-hover); }
|
||||
.ge-msg { color: var(--text-muted); }
|
||||
.ge-baustein { margin-bottom: 8px; color: var(--text-muted); font-size: 0.9rem; }
|
||||
.ge-block { margin-bottom: 8px; color: var(--text-muted); font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuideContent, chatGuide, fetchBausteinLernstand, fetchArtefakte } from '../api.js'
|
||||
import { fetchGuideContent, chatGuide, fetchBlockLearnState, fetchArtefakte } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { stufeFuer, schwelle } from '../stufen.js'
|
||||
import { stufeFuer, schwelle } from '../levels.js'
|
||||
import { useChat } from '../composables/useChat.js'
|
||||
import BausteinPanel from './BausteinPanel.vue'
|
||||
import BausteinFokus from './BausteinFokus.vue'
|
||||
import BlockPanel from './BlockPanel.vue'
|
||||
import BlockFocus from './BlockFocus.vue'
|
||||
import FlashcardWidget from './FlashcardWidget.vue'
|
||||
import WorkedExampleBlock from './WorkedExampleBlock.vue'
|
||||
|
||||
// Titel-Normalisierung wie backend _norm_titel (casefold ≈ toLowerCase + ß→ss) — für das
|
||||
// Andocken der Artefakte (gekeyt nach baustein_norm) an die Section-Titel.
|
||||
function normTitel(s) {
|
||||
// Title normalization like backend _norm_title (casefold ≈ toLowerCase + ß→ss) — for
|
||||
// attaching the artifacts (keyed by block_norm) to the section titles.
|
||||
function normTitle(s) {
|
||||
return (s || '').normalize('NFKC')
|
||||
.replace(/[`'"<>„“”‚’«»*_]/g, '').replace(/[–—‐]/g, '-')
|
||||
.replace(/\s+/g, ' ').trim().replace(/^[.:;]+|[.:;]+$/g, '').trim()
|
||||
@@ -22,53 +22,53 @@ const props = defineProps({
|
||||
previewGuide: { type: Object, default: null },
|
||||
dark: { type: Boolean, default: false },
|
||||
provider: { type: String, default: 'claude' },
|
||||
elementsOpen: { type: Boolean, default: false }, // Element-Sidebar offen → Chat nach links
|
||||
doneByFormat: { type: Object, default: () => ({}) }, // Format → fertiger Guide (Themen-bezogen)
|
||||
elementsOpen: { type: Boolean, default: false }, // element sidebar open → chat to the left
|
||||
doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related)
|
||||
themaAbgeschlossen: { type: Boolean, default: false },
|
||||
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend
|
||||
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
|
||||
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
|
||||
})
|
||||
|
||||
const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive'])
|
||||
|
||||
// Rotierende Kapitel-Akzentfarben (ohne Rot)
|
||||
// Rotating chapter accent colors (no red)
|
||||
const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366f1']
|
||||
|
||||
// --- Inhalt laden ---
|
||||
// --- Load content ---
|
||||
const content = ref(null)
|
||||
const loadError = ref(null)
|
||||
const scrollEl = ref(null)
|
||||
const lernstand = ref({}) // Prüfungs-Stand pro Baustein-Titel — VOR dem immediate-Watch (loadContent nutzt es)
|
||||
const artefakte = ref({}) // baustein_norm → {karteikarte[], beispiel[], diagramm}
|
||||
const learnstate = ref({}) // exam state per block title — BEFORE the immediate watch (loadContent uses it)
|
||||
const artifacts = ref({}) // block_norm → {flashcard[], example[], diagramm}
|
||||
|
||||
function artefakteVon(title) {
|
||||
return artefakte.value[normTitel(title)] || null
|
||||
function artifactsFor(title) {
|
||||
return artifacts.value[normTitle(title)] || null
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
// --- Lazy render + markdown cache: parse only visible sections, each only once.
|
||||
// Fixes the freeze on open (160× marked/highlight.js) and re-parse on every update. ---
|
||||
const mdCache = new Map() // `${mode}:${num}` → html
|
||||
const visible = reactive({}) // num → true (stays true once ever visible)
|
||||
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)
|
||||
h = renderMarkdown(props.ansichtModus === 'compact' ? (s.compact || s.md) : s.md)
|
||||
mdCache.set(key, h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Sections rendern erst, wenn sie (fast) im Viewport sind — Observer auf den Scroll-Container.
|
||||
// Sections render only when (almost) in the viewport — observer on the 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
|
||||
visible[Number(e.target.dataset.num)] = true
|
||||
mdObserver.unobserve(e.target)
|
||||
}
|
||||
}, { root: scrollEl.value, rootMargin: '800px 0px' })
|
||||
@@ -79,115 +79,115 @@ watch(content, () => nextTick(setupLazy))
|
||||
onUnmounted(() => mdObserver?.disconnect())
|
||||
|
||||
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
|
||||
// Stufen-Ansicht (E/M/S/F) gewechselt → Guide-Inhalt neu mit passendem Ebenen-Filter laden.
|
||||
// Level view (E/M/S/F) changed → reload guide content with the matching depth filter.
|
||||
watch(() => props.stufeAnsicht, loadContent)
|
||||
|
||||
async function loadContent() {
|
||||
content.value = null
|
||||
loadError.value = null
|
||||
lernstand.value = {}
|
||||
learnstate.value = {}
|
||||
mdCache.clear()
|
||||
for (const k in sichtbar) delete sichtbar[k]
|
||||
for (const k in visible) delete visible[k]
|
||||
const g = props.previewGuide
|
||||
if (!g || g.status !== 'done') return
|
||||
try {
|
||||
content.value = await fetchGuideContent(g.id, props.stufeAnsicht)
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden des Guides:', e)
|
||||
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
|
||||
console.error('Error loading guide:', e)
|
||||
loadError.value = 'Content unavailable — the file is missing. Regenerate the guide (▶).'
|
||||
return
|
||||
}
|
||||
try {
|
||||
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
|
||||
} catch { /* offline → leer */ }
|
||||
learnstate.value = (await fetchBlockLearnState(g.topic)).blocks || {}
|
||||
} catch { /* offline → empty */ }
|
||||
try {
|
||||
artefakte.value = (await fetchArtefakte(g.topic)).artefakte || {}
|
||||
} catch { artefakte.value = {} }
|
||||
// Beim Öffnen zum ersten noch nicht gemeisterten prüfbaren Baustein scrollen.
|
||||
artifacts.value = (await fetchArtefakte(g.topic)).artefakte || {}
|
||||
} catch { artifacts.value = {} }
|
||||
// On open, scroll to the first not-yet-mastered checkable block.
|
||||
await nextTick()
|
||||
const ziel = bausteine.value.find((s) => istPruefbar(s) && stufeVon(s.title)?.key !== 'meister')
|
||||
if (ziel) scrollEl.value?.querySelector(`[data-num="${ziel.num}"]`)?.scrollIntoView({ block: 'start' })
|
||||
const target = blocks.value.find((s) => isCheckable(s) && levelOf(s.title)?.key !== 'master')
|
||||
if (target) scrollEl.value?.querySelector(`[data-num="${target.num}"]`)?.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
|
||||
// --- Baustein-Lernen: Prüfungs-Stand pro Baustein-Titel (lernstand oben deklariert) ---
|
||||
function stufeVon(title) {
|
||||
const l = lernstand.value[title]
|
||||
return l ? stufeFuer(l.gute_antworten || 0, l.cap || 10) : null
|
||||
// --- Block learning: exam state per block title (learnstate declared above) ---
|
||||
function levelOf(title) {
|
||||
const l = learnstate.value[title]
|
||||
return l ? stufeFuer(l.good_answers || 0, l.cap || 10) : null
|
||||
}
|
||||
function onBausteinStatus(baustein, status) {
|
||||
const alt = stufeVon(baustein)?.key || null
|
||||
lernstand.value = { ...lernstand.value, [baustein]: status }
|
||||
const neu = stufeFuer(status.gute_antworten || 0, status.cap || 10)?.key || null
|
||||
if (neu !== alt) emit('progressChanged') // Stufenwechsel → Locks/Stats neu laden
|
||||
function onBlockStatus(block, status) {
|
||||
const prevKey = levelOf(block)?.key || null
|
||||
learnstate.value = { ...learnstate.value, [block]: status }
|
||||
const newKey = stufeFuer(status.good_answers || 0, status.cap || 10)?.key || null
|
||||
if (newKey !== prevKey) emit('progressChanged') // level change → reload locks/stats
|
||||
}
|
||||
|
||||
// Section nach Prüfen/Beheben/Neu-Schreiben im Content ersetzen + Markdown-Cache leeren.
|
||||
function onSectionUpdated({ title, kompakt, md }) {
|
||||
// Replace the section in content after check/fix/rewrite + clear the markdown cache.
|
||||
function onSectionUpdated({ title, compact, md }) {
|
||||
if (!content.value) return
|
||||
for (const ch of content.value.chapters) {
|
||||
for (const s of ch.sections) {
|
||||
if (s.title !== title) continue
|
||||
s.md = md
|
||||
s.kompakt = kompakt
|
||||
s.compact = compact
|
||||
for (const k of [...mdCache.keys()]) if (k.endsWith(`:${s.num}`)) mdCache.delete(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Vollbild-Fokus: ein Baustein groß, Guide links + Prüfung rechts ---
|
||||
const fokusIndex = ref(null) // Index in der flachen Baustein-Liste; null = zu
|
||||
const fokusTab = ref('pruefung')
|
||||
const bausteine = computed(() =>
|
||||
// --- Fullscreen focus: one block large, guide left + exam right ---
|
||||
const focusIndex = ref(null) // index in the flat block list; null = closed
|
||||
const focusTab = ref('exam')
|
||||
const blocks = computed(() =>
|
||||
!content.value ? [] : content.value.chapters.flatMap((ch) => ch.sections),
|
||||
)
|
||||
const fokusBaustein = computed(() => (fokusIndex.value === null ? null : bausteine.value[fokusIndex.value]))
|
||||
const focusBlock = computed(() => (focusIndex.value === null ? null : blocks.value[focusIndex.value]))
|
||||
|
||||
// Fokus-Zustand an App melden (für die Sidebar über dem Overlay); Themenwechsel schließt den Fokus.
|
||||
watch(fokusIndex, (v) => emit('fokusActive', v !== null))
|
||||
watch(() => props.previewGuide?.id, () => { fokusIndex.value = null })
|
||||
// Report focus state to App (for the sidebar above the overlay); a topic change closes the focus.
|
||||
watch(focusIndex, (v) => emit('fokusActive', v !== null))
|
||||
watch(() => props.previewGuide?.id, () => { focusIndex.value = null })
|
||||
|
||||
function openFokus(baustein, tab) {
|
||||
if (!istPruefbar(baustein)) return // reine Lese-Sections (Rest/Rand) öffnen keinen Prüfungs-Fokus
|
||||
const i = bausteine.value.findIndex((s) => s.title === baustein.title)
|
||||
function openFocus(block, tab) {
|
||||
if (!isCheckable(block)) return // read-only sections (Rest/edge) don't open an exam focus
|
||||
const i = blocks.value.findIndex((s) => s.title === block.title)
|
||||
if (i === -1) return
|
||||
fokusIndex.value = i
|
||||
fokusTab.value = tab || 'pruefung'
|
||||
focusIndex.value = i
|
||||
focusTab.value = tab || 'exam'
|
||||
}
|
||||
// Nur prüfbare Sections im Fokus anspringen (FullGuide hat dazwischen reine Lese-Sections).
|
||||
function fokusPrev() {
|
||||
for (let i = fokusIndex.value - 1; i >= 0; i--) if (istPruefbar(bausteine.value[i])) { fokusIndex.value = i; return }
|
||||
// Only jump to checkable sections in focus (FullGuide has read-only sections in between).
|
||||
function focusPrev() {
|
||||
for (let i = focusIndex.value - 1; i >= 0; i--) if (isCheckable(blocks.value[i])) { focusIndex.value = i; return }
|
||||
}
|
||||
function fokusNext() {
|
||||
for (let i = fokusIndex.value + 1; i < bausteine.value.length; i++) if (istPruefbar(bausteine.value[i])) { fokusIndex.value = i; return }
|
||||
function focusNext() {
|
||||
for (let i = focusIndex.value + 1; i < blocks.value.length; i++) if (isCheckable(blocks.value[i])) { focusIndex.value = i; return }
|
||||
}
|
||||
|
||||
// Erfahrungsleiste: kumulativer Stufen-Stand über alle Bausteine (gold ⊆ lila ⊆ blau ⊆ grün).
|
||||
const fortschritt = computed(() => {
|
||||
const z = { total: bausteine.value.length, anfaenger: 0, fortgeschritten: 0, experte: 0, meister: 0 }
|
||||
for (const s of bausteine.value) {
|
||||
const l = lernstand.value[s.title]
|
||||
// Experience bar: cumulative level state over all blocks (gold ⊆ purple ⊆ blue ⊆ green).
|
||||
const progress = computed(() => {
|
||||
const z = { total: blocks.value.length, beginner: 0, advanced: 0, expert: 0, master: 0 }
|
||||
for (const s of blocks.value) {
|
||||
const l = learnstate.value[s.title]
|
||||
if (!l) continue
|
||||
const sc = l.gute_antworten || 0, cp = l.cap || 10
|
||||
if (sc >= schwelle(0.2, cp)) z.anfaenger++
|
||||
if (sc >= schwelle(0.4, cp)) z.fortgeschritten++
|
||||
if (sc >= schwelle(0.6, cp)) z.experte++
|
||||
if (sc >= schwelle(1.0, cp)) z.meister++
|
||||
const sc = l.good_answers || 0, cp = l.cap || 10
|
||||
if (sc >= schwelle(0.2, cp)) z.beginner++
|
||||
if (sc >= schwelle(0.4, cp)) z.advanced++
|
||||
if (sc >= schwelle(0.6, cp)) z.expert++
|
||||
if (sc >= schwelle(1.0, cp)) z.master++
|
||||
}
|
||||
return z
|
||||
})
|
||||
|
||||
// cap je Baustein = 4×relevante Subbausteine (vom Backend in lernstand[title].cap geliefert).
|
||||
function capVon(title) {
|
||||
return lernstand.value[title]?.cap || 10
|
||||
// cap per block = 4×relevant subblocks (provided by the backend in learnstate[title].cap).
|
||||
function capOf(title) {
|
||||
return learnstate.value[title]?.cap || 10
|
||||
}
|
||||
|
||||
// Section prüfbar? Rest nie. Sonst prüfbar, außer das Feld ist explizit false (FullGuide-Rand).
|
||||
// Fehlt das Feld (alte Guides ohne `pruefbar`) → prüfbar, damit Bestands-Guides weiter funktionieren.
|
||||
function istPruefbar(s) {
|
||||
return props.previewGuide?.format !== 'Rest' && s.pruefbar !== false
|
||||
// Section checkable? Rest never. Otherwise checkable, unless the field is explicitly false (FullGuide edge).
|
||||
// Missing field (old guides without `checkable`) → checkable, so existing guides keep working.
|
||||
function isCheckable(s) {
|
||||
return props.previewGuide?.format !== 'Rest' && s.checkable !== false
|
||||
}
|
||||
|
||||
// --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) ---
|
||||
// --- Chat (mechanics in useChat; context extraction stays here) ---
|
||||
const chat = useChat((msgs) => {
|
||||
const { section, outline } = extractContext()
|
||||
return chatGuide(props.previewGuide.id, {
|
||||
@@ -209,8 +209,8 @@ function closeChat() {
|
||||
chat.reset()
|
||||
}
|
||||
|
||||
// Mobil schließen sich Chat und Elemente-Sidebar gegenseitig aus —
|
||||
// nebeneinander ist kein Platz, die Sidebar würde den Chat überdecken.
|
||||
// On mobile, chat and element sidebar are mutually exclusive —
|
||||
// there is no room side by side, the sidebar would cover the chat.
|
||||
watch(() => props.elementsOpen, (open) => {
|
||||
if (open && chatOpen.value && window.matchMedia('(max-width: 768px)').matches) closeChat()
|
||||
})
|
||||
@@ -221,7 +221,7 @@ function onDocMouseDown(e) {
|
||||
closeChat()
|
||||
}
|
||||
|
||||
// Enter öffnet den Chat (wenn zu, nicht in Eingabefeld); ESC schließt ihn
|
||||
// Enter opens the chat (when closed, not in an input field); ESC closes it
|
||||
function onDocKeyDown(e) {
|
||||
if (e.key === 'Escape' && chatOpen.value) {
|
||||
e.preventDefault()
|
||||
@@ -251,7 +251,7 @@ function extractContext() {
|
||||
.join('\n')
|
||||
.slice(0, 7000)
|
||||
|
||||
// Aktuelle Section = letzte Karte, deren Oberkante oben im Viewport oder darüber liegt
|
||||
// Current section = last card whose top edge is at or above the top of the viewport
|
||||
let section = ''
|
||||
const cards = Array.from(scrollEl.value?.querySelectorAll('.section-card') || [])
|
||||
let current = null
|
||||
@@ -273,11 +273,11 @@ function extractContext() {
|
||||
<header class="guide-head">
|
||||
<h1>{{ previewGuide.topic }}</h1>
|
||||
<span class="guide-format">{{ previewGuide.format }}</span>
|
||||
<span v-if="themaAbgeschlossen" class="thema-done" title="Alle Bausteine auf Meister">✓ Thema abgeschlossen</span>
|
||||
<span v-if="themaAbgeschlossen" class="thema-done" title="All blocks mastered">✓ Topic completed</span>
|
||||
<span class="gh-spacer"></span>
|
||||
<div class="ansicht-toggle" title="Ausführlichkeit umschalten">
|
||||
<button :class="{ active: ansichtModus === 'kompakt' }" @click="$emit('setAnsicht', 'kompakt')">Kompakt</button>
|
||||
<button :class="{ active: ansichtModus === 'erklärend' }" @click="$emit('setAnsicht', 'erklärend')">Erklärend</button>
|
||||
<div class="ansicht-toggle" title="Toggle verbosity">
|
||||
<button :class="{ active: ansichtModus === 'compact' }" @click="$emit('setAnsicht', 'compact')">Compact</button>
|
||||
<button :class="{ active: ansichtModus === 'erklärend' }" @click="$emit('setAnsicht', 'erklärend')">Explanatory</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -294,28 +294,28 @@ function extractContext() {
|
||||
:key="s.num"
|
||||
:data-num="s.num"
|
||||
class="section-card"
|
||||
:style="stufeVon(s.title) ? { borderLeftColor: stufeVon(s.title).farbe } : {}"
|
||||
:style="levelOf(s.title) ? { borderLeftColor: levelOf(s.title).farbe } : {}"
|
||||
>
|
||||
<h3 :class="{ 'baustein-klick': istPruefbar(s) }" @click="istPruefbar(s) && openFokus(s, 'pruefung')">
|
||||
<h3 :class="{ 'block-klick': isCheckable(s) }" @click="isCheckable(s) && openFocus(s, 'exam')">
|
||||
{{ s.title }}
|
||||
<template v-if="istPruefbar(s) && stufeVon(s.title)">
|
||||
<span class="baustein-done" :style="{ color: stufeVon(s.title).farbe, borderColor: stufeVon(s.title).farbe }" :title="`${stufeVon(s.title).label} (${capVon(s.title)})`">{{ stufeVon(s.title).kurz }} {{ stufeVon(s.title).label }}</span>
|
||||
<template v-if="isCheckable(s) && levelOf(s.title)">
|
||||
<span class="block-done" :style="{ color: levelOf(s.title).farbe, borderColor: levelOf(s.title).farbe }" :title="`${levelOf(s.title).label} (${capOf(s.title)})`">{{ levelOf(s.title).kurz }} {{ levelOf(s.title).label }}</span>
|
||||
</template>
|
||||
</h3>
|
||||
<div v-if="sichtbar[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
|
||||
<div v-if="visible[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
|
||||
<div v-else class="section-body skeleton"></div>
|
||||
<template v-if="sichtbar[s.num] && artefakteVon(s.title)">
|
||||
<WorkedExampleBlock :beispiele="artefakteVon(s.title).beispiel || []" />
|
||||
<FlashcardWidget :karten="artefakteVon(s.title).karteikarte || []" />
|
||||
<template v-if="visible[s.num] && artifactsFor(s.title)">
|
||||
<WorkedExampleBlock :examples="artifactsFor(s.title).example || []" />
|
||||
<FlashcardWidget :cards="artifactsFor(s.title).flashcard || []" />
|
||||
</template>
|
||||
<BausteinPanel
|
||||
v-if="istPruefbar(s)"
|
||||
<BlockPanel
|
||||
v-if="isCheckable(s)"
|
||||
mode="trigger"
|
||||
:baustein="s.title"
|
||||
:status="lernstand[s.title]"
|
||||
:cap="capVon(s.title)"
|
||||
:block="s.title"
|
||||
:status="learnstate[s.title]"
|
||||
:cap="capOf(s.title)"
|
||||
:topic="previewGuide.topic"
|
||||
@open-fokus="(tab) => openFokus(s, tab)"
|
||||
@open-fokus="(tab) => openFocus(s, tab)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
@@ -324,64 +324,64 @@ function extractContext() {
|
||||
</div>
|
||||
|
||||
<div v-else-if="previewGuide" class="empty-preview">
|
||||
<p>{{ loadError || 'Lade Inhalt…' }}</p>
|
||||
<p>{{ loadError || 'Loading content…' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="empty-preview" v-else>
|
||||
<p>Guide-Format anklicken um zu generieren oder Vorschau zu öffnen.</p>
|
||||
<p>Click a guide format to generate or open a preview.</p>
|
||||
</div>
|
||||
|
||||
<BausteinFokus
|
||||
v-if="fokusBaustein"
|
||||
:baustein="fokusBaustein"
|
||||
:artefakte="artefakteVon(fokusBaustein.title)"
|
||||
<BlockFocus
|
||||
v-if="focusBlock"
|
||||
:block="focusBlock"
|
||||
:artefakte="artifactsFor(focusBlock.title)"
|
||||
:topic="previewGuide.topic"
|
||||
:guide-id="previewGuide.id"
|
||||
:provider="provider"
|
||||
:fortschritt="fortschritt"
|
||||
:status="lernstand[fokusBaustein.title]"
|
||||
:cap="capVon(fokusBaustein.title)"
|
||||
:tab="fokusTab"
|
||||
:fortschritt="progress"
|
||||
:status="learnstate[focusBlock.title]"
|
||||
:cap="capOf(focusBlock.title)"
|
||||
:tab="focusTab"
|
||||
:ansicht="ansichtModus"
|
||||
:has-prev="fokusIndex > 0"
|
||||
:has-next="fokusIndex < bausteine.length - 1"
|
||||
@prev="fokusPrev"
|
||||
@next="fokusNext"
|
||||
@close="fokusIndex = null"
|
||||
:has-prev="focusIndex > 0"
|
||||
:has-next="focusIndex < blocks.length - 1"
|
||||
@prev="focusPrev"
|
||||
@next="focusNext"
|
||||
@close="focusIndex = null"
|
||||
@set-ansicht="$emit('setAnsicht', $event)"
|
||||
@status-changed="(st) => onBausteinStatus(st.baustein, st)"
|
||||
@status-changed="(st) => onBlockStatus(st.block, st)"
|
||||
@open-sidebar="$emit('openSidebar')"
|
||||
@section-updated="onSectionUpdated"
|
||||
/>
|
||||
|
||||
<button v-if="previewGuide && !chatOpen && fokusIndex === null" class="chat-fab" :class="{ shifted: elementsOpen }" title="Fragen zum Guide" @click="openChat">💬</button>
|
||||
<button v-if="previewGuide && !chatOpen && focusIndex === null" class="chat-fab" :class="{ shifted: elementsOpen }" title="Questions about the guide" @click="openChat">💬</button>
|
||||
|
||||
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }">
|
||||
<header class="chat-header">
|
||||
<span>Fragen zum Guide</span>
|
||||
<button class="chat-close" title="Chat beenden" @click="closeChat">×</button>
|
||||
<span>Questions about the guide</span>
|
||||
<button class="chat-close" title="Close chat" @click="closeChat">×</button>
|
||||
</header>
|
||||
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
|
||||
<p v-if="!messages.length" class="chat-hint">Stell eine Frage zum aktuellen Abschnitt.</p>
|
||||
<p v-if="!messages.length" class="chat-hint">Ask a question about the current section.</p>
|
||||
<template v-for="(m, i) in messages" :key="i">
|
||||
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||
<div v-else class="chat-msg user">{{ m.content }}</div>
|
||||
</template>
|
||||
<div v-if="loading" class="chat-msg assistant chat-typing">Denkt…</div>
|
||||
<div v-if="loading" class="chat-msg assistant chat-typing">Thinking…</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="input"
|
||||
rows="3"
|
||||
placeholder="Frage stellen…"
|
||||
placeholder="Ask a question…"
|
||||
@input="autoGrow"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
></textarea>
|
||||
<button
|
||||
:disabled="!input.trim() && !loading"
|
||||
:class="{ cancel: loading }"
|
||||
:title="loading ? 'Abbrechen' : 'Senden'"
|
||||
:title="loading ? 'Cancel' : 'Send'"
|
||||
@click="send"
|
||||
>{{ loading ? '✕' : '➤' }}</button>
|
||||
</div>
|
||||
@@ -392,8 +392,8 @@ function extractContext() {
|
||||
<style scoped>
|
||||
.detail {
|
||||
flex: 1;
|
||||
/* Flex-Item darf schmaler werden als seine Code-Blöcke — sonst sprengt
|
||||
deren Mindestbreite auf Mobile das Layout */
|
||||
/* Flex item may shrink below its code blocks — otherwise their
|
||||
min width breaks the layout on mobile */
|
||||
min-width: 0;
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
@@ -402,7 +402,7 @@ function extractContext() {
|
||||
.guide-scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
/* Kein horizontales Pannen der ganzen Seite — Code-Blöcke scrollen intern */
|
||||
/* No horizontal panning of the whole page — code blocks scroll internally */
|
||||
overflow-x: hidden;
|
||||
background: var(--bg-preview);
|
||||
}
|
||||
@@ -411,7 +411,7 @@ function extractContext() {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 2.5rem 5rem;
|
||||
/* Lese-Zoom nur für den Inhalt — Sidebar/Chat bleiben unverändert */
|
||||
/* Reading zoom only for the content — sidebar/chat stay unchanged */
|
||||
zoom: 1;
|
||||
}
|
||||
|
||||
@@ -426,7 +426,7 @@ function extractContext() {
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
/* Sticky: der Ausführlichkeits-Toggle bleibt beim Scrollen erreichbar. */
|
||||
/* Sticky: the verbosity toggle stays reachable while scrolling. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
@@ -513,7 +513,7 @@ function extractContext() {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.baustein-done {
|
||||
.block-done {
|
||||
float: right;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.12rem 0.6rem;
|
||||
@@ -527,38 +527,38 @@ function extractContext() {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Absolvierte Bausteine: Karte kippt sichtbar auf Grün */
|
||||
.guide-content .section-card.absolviert {
|
||||
/* Completed blocks: card visibly flips to green */
|
||||
.guide-content .section-card.completed {
|
||||
border-color: var(--success-border);
|
||||
border-top: 3px solid var(--success);
|
||||
background: color-mix(in srgb, var(--success) 5%, var(--panel));
|
||||
}
|
||||
|
||||
/* Verstandene Bausteine (10/10): Lila */
|
||||
.baustein-done.verstanden {
|
||||
/* Understood blocks (10/10): purple */
|
||||
.block-done.understood {
|
||||
background: color-mix(in srgb, #8b5cf6 16%, var(--panel));
|
||||
border-color: #8b5cf6;
|
||||
color: #6d28d9;
|
||||
}
|
||||
.guide-content .section-card.verstanden {
|
||||
.guide-content .section-card.understood {
|
||||
border-color: #8b5cf6;
|
||||
border-top: 3px solid #8b5cf6;
|
||||
background: color-mix(in srgb, #8b5cf6 7%, var(--panel));
|
||||
}
|
||||
|
||||
/* Gemeisterte Bausteine (Meisterpfad 25/25): Gold */
|
||||
.baustein-done.gemeistert {
|
||||
/* Mastered blocks (master path 25/25): gold */
|
||||
.block-done.mastered {
|
||||
background: color-mix(in srgb, #d4af37 20%, var(--panel));
|
||||
border-color: #d4af37;
|
||||
color: #8a6d12;
|
||||
}
|
||||
.guide-content .section-card.gemeistert {
|
||||
.guide-content .section-card.mastered {
|
||||
border-color: #d4af37;
|
||||
border-top: 3px solid #d4af37;
|
||||
background: color-mix(in srgb, #d4af37 8%, var(--panel));
|
||||
}
|
||||
|
||||
/* Guides: Karten tragen die Kapitel-Akzentfarbe */
|
||||
/* Guides: cards carry the chapter accent color */
|
||||
.guide-content .section-card {
|
||||
border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--ch-accent, var(--accent)) 3%, var(--panel));
|
||||
@@ -572,12 +572,12 @@ function extractContext() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Titel-Klick öffnet die Vollansicht. */
|
||||
.section-card h3.baustein-klick { cursor: pointer; width: fit-content; }
|
||||
.section-card h3.baustein-klick:hover { color: var(--accent); }
|
||||
/* Title click opens the full view. */
|
||||
.section-card h3.block-klick { cursor: pointer; width: fit-content; }
|
||||
.section-card h3.block-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. */
|
||||
/* Placeholder for not-yet-rendered sections (lazy render). Stable height,
|
||||
so the IntersectionObserver detects the following cards in a staggered way. */
|
||||
.section-body.skeleton { min-height: 160px; }
|
||||
|
||||
.empty-preview {
|
||||
@@ -588,16 +588,16 @@ function extractContext() {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* --- Markdown: Basis global (assets/markdown.css), hier nur Lese-Ansicht-Overrides --- */
|
||||
/* --- Markdown: base global (assets/markdown.css), here only reading-view overrides --- */
|
||||
|
||||
/* Breite Lese-Ansicht: Code scrollt horizontal statt umzubrechen */
|
||||
/* Wide reading view: code scrolls horizontally instead of wrapping */
|
||||
.markdown :deep(pre) {
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* „Beispiel"-Überschriften in Karten als dezentes Uppercase-Label */
|
||||
/* "Example" headings in cards as a subtle uppercase label */
|
||||
.section-card .markdown :deep(h3) {
|
||||
font-size: 0.74em;
|
||||
text-transform: uppercase;
|
||||
@@ -606,8 +606,8 @@ function extractContext() {
|
||||
margin: 0.9em 0 0.35em;
|
||||
}
|
||||
|
||||
/* Lesbarkeit: ~17px Fließtext, Zeilenhöhe 1.6, Textspalte max. ~70 Zeichen —
|
||||
Code-Blöcke dürfen die volle Kartenbreite nutzen */
|
||||
/* Readability: ~17px body text, line height 1.6, text column max ~70 chars —
|
||||
code blocks may use the full card width */
|
||||
.section-body {
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.6;
|
||||
@@ -640,7 +640,7 @@ function extractContext() {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Element-Sidebar (320px) offen → Chat links daneben anzeigen */
|
||||
/* Element sidebar (320px) open → show chat to its left */
|
||||
.chat-fab.shifted {
|
||||
right: calc(1.5rem + 320px);
|
||||
}
|
||||
@@ -649,7 +649,7 @@ function extractContext() {
|
||||
right: calc(1.5rem + 320px);
|
||||
}
|
||||
|
||||
/* Mobil liegt die Elemente-Sidebar als Overlay über dem Chat — FAB/Panel ausblenden */
|
||||
/* On mobile the element sidebar overlays the chat — hide FAB/panel */
|
||||
@media (max-width: 768px) {
|
||||
.chat-fab.shifted,
|
||||
.chat-panel.shifted {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
import { fetchQuelle } from '../api.js'
|
||||
import { fetchSource } from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
topics: { type: Array, required: true },
|
||||
@@ -9,26 +9,26 @@ const props = defineProps({
|
||||
stats: { type: Object, default: null },
|
||||
fortschritt: { type: Object, default: () => ({}) },
|
||||
locks: { type: Object, default: () => ({}) },
|
||||
guideStepsDone: { type: Object, default: () => ({}) }, // höchster fertiger Schritt je Format (-1 = keiner)
|
||||
guideStepsDone: { type: Object, default: () => ({}) }, // highest finished step per format (-1 = none)
|
||||
uiError: { type: String, default: null },
|
||||
doneByFormat: { type: Object, default: () => ({}) },
|
||||
latestByFormat: { type: Object, default: () => ({}) },
|
||||
allGuides: { type: Array, default: () => [] },
|
||||
dismissedErrors: { type: Object, default: () => new Set() },
|
||||
bausteine: { type: Object, default: () => ({ ready: false, generating: false, progress: null, error: null }) },
|
||||
blocks: { type: Object, default: () => ({ ready: false, generating: false, progress: null, error: null }) },
|
||||
activeBausteine: { type: Array, default: () => [] },
|
||||
pinned: { type: Boolean, default: true },
|
||||
dark: { type: Boolean, default: false },
|
||||
provider: { type: String, default: 'claude' },
|
||||
providers: { type: Array, default: () => [] },
|
||||
folders: { type: Object, default: () => ({ projekt: [], uni: [] }) },
|
||||
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend
|
||||
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
|
||||
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'create', 'createThema', 'updateQuelle', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
|
||||
const emit = defineEmits(['select', 'createThema', 'updateSource', 'formatClick', 'bausteineClick', 'cancelBlocks', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
|
||||
|
||||
// Accordion: höchstens ein Panel offen. IDs: 'bausteine', 'fmt-<Format>', 'topic-<Name>'.
|
||||
// Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'.
|
||||
const openPanel = ref(null)
|
||||
const isOpen = (id) => openPanel.value === id
|
||||
function togglePanel(id) {
|
||||
@@ -40,16 +40,16 @@ function providerAvailable(id) {
|
||||
return p ? p.available : true
|
||||
}
|
||||
|
||||
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Lokal' }
|
||||
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Local' }
|
||||
|
||||
// Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert
|
||||
// Tracker at the top of the navigation: total topics, created/completed per format
|
||||
const trackerItems = computed(() => {
|
||||
if (!props.stats) return []
|
||||
const f = props.stats.formate || {}
|
||||
const fmt = (k) => `${f[k]?.absolviert ?? 0}/${f[k]?.erstellt ?? 0}`
|
||||
const f = props.stats.formats || {}
|
||||
const fmt = (k) => `${f[k]?.completed ?? 0}/${f[k]?.erstellt ?? 0}`
|
||||
return [
|
||||
{ label: 'Themen', value: String(props.stats.themen ?? 0), title: 'Themen inkl. Projekte' },
|
||||
{ label: 'Guides', value: fmt('Guide'), title: 'absolviert/erstellt' },
|
||||
{ label: 'Topics', value: String(props.stats.topics ?? 0), title: 'Topics incl. projects' },
|
||||
{ label: 'Guides', value: fmt('Guide'), title: 'completed/created' },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -57,58 +57,58 @@ const formats = [
|
||||
{ key: 'Guide', label: 'Guide' },
|
||||
]
|
||||
|
||||
// Stufen-Ansichten des EINEN Guides (gefiltert nach Subbaustein-Ebene).
|
||||
const STUFEN_ANSICHT = [
|
||||
{ k: 1, label: 'A', titel: 'Anfänger' },
|
||||
{ k: 2, label: 'F', titel: 'Anfänger + Fortgeschritten' },
|
||||
{ k: 3, label: 'E', titel: 'bis Experte' },
|
||||
{ k: 4, label: 'V', titel: 'Vollständig (inkl. Rand)' },
|
||||
// Level views of the SINGLE guide (filtered by subblock depth).
|
||||
const LEVEL_VIEWS = [
|
||||
{ k: 1, label: 'A', title: 'Beginner' },
|
||||
{ k: 2, label: 'F', title: 'Beginner + Advanced' },
|
||||
{ k: 3, label: 'E', title: 'up to Expert' },
|
||||
{ k: 4, label: 'V', title: 'Complete (incl. edge)' },
|
||||
]
|
||||
|
||||
const bausteineState = computed(() => {
|
||||
if (props.bausteine.generating) return 'generating'
|
||||
return props.bausteine.ready ? 'done' : 'none'
|
||||
const blocksState = computed(() => {
|
||||
if (props.blocks.generating) return 'generating'
|
||||
return props.blocks.ready ? 'done' : 'none'
|
||||
})
|
||||
|
||||
// Re-Run ab Teilschritt passiert jetzt in der Bausteine-Übersicht; die Sidebar hat keine Phasen-Pillen mehr.
|
||||
// Re-run from a substep now happens in the blocks overview; the sidebar no longer has phase pills.
|
||||
|
||||
// Nur FREMDE Themen — das gewählte Thema zeigt seinen Fortschritt inline an der Zeile
|
||||
// Only OTHER topics — the selected topic shows its progress inline on the row
|
||||
const activeGenerations = computed(() => {
|
||||
const bausteinLines = props.activeBausteine
|
||||
const blockLines = props.activeBausteine
|
||||
.filter((b) => b.topic !== props.selectedTopic)
|
||||
.map((b) => `${b.topic} – Bausteine: ${b.progress || 'Wartend…'}`)
|
||||
.map((b) => `${b.topic} – Blocks: ${b.progress || 'Waiting…'}`)
|
||||
const guideLines = props.allGuides
|
||||
.filter((g) => (g.status === 'generating' || g.status === 'queued') && g.topic !== props.selectedTopic)
|
||||
.map((g) => `${g.topic} – ${g.format}: ${g.progress || 'Wartend…'}`)
|
||||
return [...bausteinLines, ...guideLines]
|
||||
.map((g) => `${g.topic} – ${g.format}: ${g.progress || 'Waiting…'}`)
|
||||
return [...blockLines, ...guideLines]
|
||||
})
|
||||
|
||||
const { pending: pendingConfirm, armOrRun } = useConfirm()
|
||||
|
||||
function confirmCancelBausteine() {
|
||||
armOrRun('bausteine', () => emit('cancelBausteine'))
|
||||
function confirmCancelBlocks() {
|
||||
armOrRun('blocks', () => emit('cancelBlocks'))
|
||||
}
|
||||
|
||||
function confirmResetBausteine() {
|
||||
armOrRun('bausteine', () => emit('resetBausteine'))
|
||||
function confirmResetBlocks() {
|
||||
armOrRun('blocks', () => emit('resetBausteine'))
|
||||
}
|
||||
|
||||
function handleBausteinePlay() {
|
||||
if (bausteineState.value === 'generating') return
|
||||
// „Neu generieren" (ready) = ab Anfang; Erstbau/Fortsetzen ohne Löschen. Feiner Re-Run via Übersicht.
|
||||
const abPhase = props.bausteine.ready ? 1 : null
|
||||
function handleBlocksPlay() {
|
||||
if (blocksState.value === 'generating') return
|
||||
// "Regenerate" (ready) = from start; first build/resume without deleting. Fine re-run via overview.
|
||||
const abPhase = props.blocks.ready ? 1 : null
|
||||
emit('bausteineClick', { instructions: '', abPhase })
|
||||
}
|
||||
|
||||
// Name-Klick = Primäraktion: fertige Bausteine → Übersicht, sonst Panel auf/zu.
|
||||
function onBausteineName() {
|
||||
if (props.bausteine.ready && !props.bausteine.generating) emit('openBausteineView')
|
||||
else togglePanel('bausteine')
|
||||
// Name click = primary action: finished blocks → overview, otherwise toggle panel.
|
||||
function onBlocksName() {
|
||||
if (props.blocks.ready && !props.blocks.generating) emit('openBausteineView')
|
||||
else togglePanel('blocks')
|
||||
}
|
||||
|
||||
function guideStatus(format) {
|
||||
// Laufende Generierung hat Vorrang — sonst maskiert ein älterer fertiger
|
||||
// Guide den Lauf und ▶ würde Duplikate starten.
|
||||
// Running generation takes precedence — otherwise an older finished
|
||||
// guide masks the run and ▶ would start duplicates.
|
||||
const latest = props.latestByFormat[format]
|
||||
if (latest && (latest.status === 'generating' || latest.status === 'queued')) return latest.status
|
||||
if (props.doneByFormat[format]) return 'done'
|
||||
@@ -116,74 +116,74 @@ function guideStatus(format) {
|
||||
return latest.status
|
||||
}
|
||||
|
||||
// Schritt-Kugeln der Guide-Pipeline
|
||||
const GUIDE_STEPS = ['Gliederung', 'Inhalte', 'Inhalts-Check', 'Schreiben', 'Lese-Prüfung']
|
||||
// Step dots of the guide pipeline
|
||||
const GUIDE_STEPS = ['Outline', 'Content', 'Content check', 'Writing', 'Reading exam']
|
||||
|
||||
// Kugeln aus dem artefakt-basierten „fertig"-Marker (wie Bausteine, nicht aus dem DB-Zähler):
|
||||
// ≤ fertig = done. Läuft gerade → der nächste Schritt (fertig+1) ist aktiv.
|
||||
// Dots from the artifact-based "done" marker (like blocks, not the DB counter):
|
||||
// ≤ done = done. Running → the next step (done+1) is active.
|
||||
function guideSteps(format) {
|
||||
const labels = GUIDE_STEPS
|
||||
const fertig = props.guideStepsDone[format] ?? -1
|
||||
const done = props.guideStepsDone[format] ?? -1
|
||||
const st = guideStatus(format)
|
||||
const aktiv = st === 'generating' || st === 'queued' ? fertig + 1 : -1
|
||||
const active = st === 'generating' || st === 'queued' ? done + 1 : -1
|
||||
return labels.map((label, i) => ({
|
||||
label,
|
||||
state: i <= fertig ? 'done' : i === aktiv ? 'active' : 'pending',
|
||||
state: i <= done ? 'done' : i === active ? 'active' : 'pending',
|
||||
}))
|
||||
}
|
||||
|
||||
// Re-Run ab Guide-Schritt (1-basierte Kugel je Format). null = voll/Resume.
|
||||
const gewaehlterStep = reactive({})
|
||||
// Kugeln klickbar, sobald Artefakte existieren (Marker ≥ 0 oder fertig) und nicht generiert wird.
|
||||
function guideWaehlbar(format) {
|
||||
// Re-run from a guide step (1-based dot per format). null = full/resume.
|
||||
const selectedStep = reactive({})
|
||||
// Dots clickable once artifacts exist (marker ≥ 0 or done) and not generating.
|
||||
function guideSelectable(format) {
|
||||
const st = guideStatus(format)
|
||||
if (st === 'generating' || st === 'queued') return false
|
||||
return (props.guideStepsDone[format] ?? -1) >= 0 || st === 'done'
|
||||
}
|
||||
function guideStepKlick(format, n) {
|
||||
if (!guideWaehlbar(format)) return
|
||||
gewaehlterStep[format] = gewaehlterStep[format] === n ? null : n
|
||||
function guideStepClick(format, n) {
|
||||
if (!guideSelectable(format)) return
|
||||
selectedStep[format] = selectedStep[format] === n ? null : n
|
||||
}
|
||||
function gewaehltesStepLabel(format) {
|
||||
return GUIDE_STEPS[(gewaehlterStep[format] || 0) - 1] || ''
|
||||
function selectedStepLabel(format) {
|
||||
return GUIDE_STEPS[(selectedStep[format] || 0) - 1] || ''
|
||||
}
|
||||
|
||||
function errorMsg(format) {
|
||||
const latest = props.latestByFormat[format]
|
||||
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
|
||||
if (abgebrochen(format)) return '' // kein roter Fehler — das Pausiert-Badge zeigt den Zustand
|
||||
return latest.error_msg || 'Fehler bei der Generierung'
|
||||
if (aborted(format)) return '' // no red error — the Paused badge shows the state
|
||||
return latest.error_msg || 'Generation failed'
|
||||
}
|
||||
|
||||
// Abgebrochener Lauf = Teilfortschritt vorhanden: ▶ setzt fort, ✕ löscht den Fortschritt
|
||||
function abgebrochen(format) {
|
||||
// Aborted run = partial progress present: ▶ resumes, ✕ deletes the progress
|
||||
function aborted(format) {
|
||||
const latest = props.latestByFormat[format]
|
||||
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Abgebrochen')
|
||||
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Cancelled')
|
||||
}
|
||||
|
||||
// Name-Klick: fertiger Guide → Vorschau, sonst Aktions-Panel auf/zu.
|
||||
// Name click: finished guide → preview, otherwise toggle action panel.
|
||||
function handleFormatClick(format) {
|
||||
const guide = props.doneByFormat[format]
|
||||
if (guide) emit('preview', guide)
|
||||
else togglePanel('fmt-' + format)
|
||||
}
|
||||
|
||||
// Sperr-Gründe kommen vom Backend (GET /guides/locks) — die Regeln existieren
|
||||
// nur noch dort. Solange locks noch nicht geladen sind: Button frei, das
|
||||
// Backend weist ungültige Starts ohnehin ab (sichtbar über uiError).
|
||||
// Lock reasons come from the backend (GET /guides/locks) — the rules only
|
||||
// exist there now. While locks are not yet loaded: button enabled, the
|
||||
// backend rejects invalid starts anyway (visible via uiError).
|
||||
function playLock(format) {
|
||||
return props.locks?.[format] ?? null
|
||||
}
|
||||
|
||||
function handlePlay(format) {
|
||||
if (playLock(format)) return
|
||||
// Gewählte Kugel (1-basiert) → ab_step (0-basiert). Nur bei (teil-)gebautem Guide.
|
||||
const abStep = guideWaehlbar(format) && gewaehlterStep[format] ? gewaehlterStep[format] - 1 : null
|
||||
// Selected dot (1-based) → ab_step (0-based). Only for a (partially) built guide.
|
||||
const abStep = guideSelectable(format) && selectedStep[format] ? selectedStep[format] - 1 : null
|
||||
emit('formatClick', { format, instructions: '', abStep })
|
||||
gewaehlterStep[format] = null
|
||||
selectedStep[format] = null
|
||||
}
|
||||
|
||||
// Flash-Message-Verhalten: × blendet nur aus, nichts wird gelöscht
|
||||
// Flash-message behavior: × only hides, nothing is deleted
|
||||
function dismissError(format) {
|
||||
const latest = props.latestByFormat[format]
|
||||
if (latest?.status === 'error') emit('dismissError', latest.id)
|
||||
@@ -192,15 +192,15 @@ function dismissError(format) {
|
||||
function handleDelete(format) {
|
||||
if (!props.latestByFormat[format]) return
|
||||
armOrRun('fmt-' + format, () => {
|
||||
// Alle laufenden Generierungen des Formats abbrechen (deckt auch Duplikate ab)
|
||||
// Cancel all running generations of the format (also covers duplicates)
|
||||
const running = props.allGuides.filter(
|
||||
(g) => g.topic === props.selectedTopic && g.format === format
|
||||
&& (g.status === 'generating' || g.status === 'queued'),
|
||||
)
|
||||
if (running.length) {
|
||||
for (const g of running) emit('cancelGuide', g.id)
|
||||
} else if (abgebrochen(format)) {
|
||||
// Pausierter Lauf: Teilfortschritt samt Schritt-Dateien löschen (Reset)
|
||||
} else if (aborted(format)) {
|
||||
// Paused run: delete partial progress incl. step files (reset)
|
||||
emit('deleteGuide', props.latestByFormat[format].id, true)
|
||||
} else {
|
||||
emit('deleteGuide', props.latestByFormat[format].id)
|
||||
@@ -208,7 +208,7 @@ function handleDelete(format) {
|
||||
})
|
||||
}
|
||||
|
||||
// Erstellen-Bereich: inline aufklappbar (Name + weitere Infos + Quellen-Typ).
|
||||
// Create area: inline expandable (name + more info + source type).
|
||||
const dlg = ref(false)
|
||||
const form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' })
|
||||
const canCreate = computed(() => {
|
||||
@@ -217,12 +217,12 @@ const canCreate = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
function toggleErstellen() {
|
||||
function toggleCreate() {
|
||||
if (!dlg.value) form.value = { name: '', instructions: '', sourceType: 'thema', sourceOrt: '' }
|
||||
dlg.value = !dlg.value
|
||||
}
|
||||
|
||||
function createThema() {
|
||||
function createTopic() {
|
||||
if (!canCreate.value) return
|
||||
emit('createThema', {
|
||||
topic: form.value.name.trim(),
|
||||
@@ -237,7 +237,7 @@ function confirmDeleteTopic(topic) {
|
||||
armOrRun('topic-' + topic, () => emit('deleteTopic', topic))
|
||||
}
|
||||
|
||||
// --- Thema-Edit: Name-Klick wählt nur; das Chevron klappt die Quellen-Form auf/zu (Default zu) ---
|
||||
// --- Topic edit: name click only selects; the chevron toggles the sources form (default closed) ---
|
||||
const editTopic = ref(null)
|
||||
const editForm = ref({ type: 'thema', ort: '', spec: '' })
|
||||
const editLoading = ref(false)
|
||||
@@ -253,8 +253,8 @@ async function toggleTopicPanel(t) {
|
||||
editTopic.value = t
|
||||
editLoading.value = true
|
||||
try {
|
||||
const q = await fetchQuelle(t)
|
||||
editForm.value = { type: q.type || 'thema', ort: q.ort || '', spec: q.spec || '' }
|
||||
const q = await fetchSource(t)
|
||||
editForm.value = { type: q.type || 'thema', ort: q.location || '', spec: q.spec || '' }
|
||||
} catch {
|
||||
editForm.value = { type: 'thema', ort: '', spec: '' }
|
||||
} finally {
|
||||
@@ -267,9 +267,9 @@ function setEditType(t) {
|
||||
editForm.value.ort = ''
|
||||
}
|
||||
|
||||
function saveQuelle() {
|
||||
function saveSource() {
|
||||
if (!canSave.value || !editTopic.value) return
|
||||
emit('updateQuelle', {
|
||||
emit('updateSource', {
|
||||
topic: editTopic.value,
|
||||
type: editForm.value.type,
|
||||
ort: editForm.value.ort.trim(),
|
||||
@@ -290,22 +290,22 @@ function saveQuelle() {
|
||||
<div class="new-topic">
|
||||
<button
|
||||
class="pin-btn"
|
||||
:title="pinned ? 'Sidebar ausblenden' : 'Sidebar fixieren'"
|
||||
:title="pinned ? 'Hide sidebar' : 'Pin sidebar'"
|
||||
@click="emit('togglePin')"
|
||||
>{{ pinned ? '⇤' : '⇥' }}</button>
|
||||
<button
|
||||
class="theme-btn"
|
||||
:title="dark ? 'Hellmodus' : 'Dunkelmodus'"
|
||||
:title="dark ? 'Light mode' : 'Dark mode'"
|
||||
@click="emit('toggleDark')"
|
||||
>{{ dark ? '☀' : '🌙' }}</button>
|
||||
<div v-if="selectedTopic" class="ansicht-toggle">
|
||||
<button :class="{ active: ansichtModus === 'kompakt' }" title="Kurzer Text" @click="emit('setAnsicht', 'kompakt')">
|
||||
<button :class="{ active: ansichtModus === 'compact' }" title="Short text" @click="emit('setAnsicht', 'compact')">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<rect x="2" y="5" width="12" height="1.6" rx="0.8" />
|
||||
<rect x="2" y="9" width="7" height="1.6" rx="0.8" />
|
||||
</svg>
|
||||
</button>
|
||||
<button :class="{ active: ansichtModus === 'erklärend' }" title="Langer Text" @click="emit('setAnsicht', 'erklärend')">
|
||||
<button :class="{ active: ansichtModus === 'erklärend' }" title="Long text" @click="emit('setAnsicht', 'erklärend')">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<rect x="2" y="3" width="12" height="1.4" rx="0.7" />
|
||||
<rect x="2" y="6.3" width="12" height="1.4" rx="0.7" />
|
||||
@@ -314,39 +314,39 @@ function saveQuelle() {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="selectedTopic" class="stufe-toggle" title="Tiefe der Guide-Ansicht">
|
||||
<button v-for="s in STUFEN_ANSICHT" :key="s.k"
|
||||
:class="{ active: stufeAnsicht === s.k }" :title="s.titel"
|
||||
<div v-if="selectedTopic" class="level-toggle" title="Depth of guide view">
|
||||
<button v-for="s in LEVEL_VIEWS" :key="s.k"
|
||||
:class="{ active: stufeAnsicht === s.k }" :title="s.title"
|
||||
@click="emit('setStufe', s.k)">{{ s.label }}</button>
|
||||
</div>
|
||||
<button class="new-topic-toggle" :class="{ active: dlg }" title="Thema erstellen" @click="toggleErstellen">+</button>
|
||||
<button class="new-topic-toggle" :class="{ active: dlg }" title="Create topic" @click="toggleCreate">+</button>
|
||||
</div>
|
||||
|
||||
<!-- Erstellen: inline aufklappbar (kein Modal) -->
|
||||
<!-- Create: inline expandable (no modal) -->
|
||||
<div v-if="dlg" class="thema-panel">
|
||||
<input class="dlg-input" v-model="form.name" placeholder="Thema-Name…" @keyup.enter="createThema" autofocus />
|
||||
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="Weitere Infos (optional)…"></textarea>
|
||||
<input class="dlg-input" v-model="form.name" placeholder="Topic name…" @keyup.enter="createTopic" autofocus />
|
||||
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="More info (optional)…"></textarea>
|
||||
<div class="dlg-sources">
|
||||
<button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Thema</button>
|
||||
<button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Topic</button>
|
||||
<button :class="{ active: form.sourceType === 'link' }" @click="form.sourceType = 'link'; form.sourceOrt = ''">Link</button>
|
||||
<button :class="{ active: form.sourceType === 'projekt' }" @click="form.sourceType = 'projekt'; form.sourceOrt = ''">Projekt</button>
|
||||
<button :class="{ active: form.sourceType === 'projekt' }" @click="form.sourceType = 'projekt'; form.sourceOrt = ''">Project</button>
|
||||
<button :class="{ active: form.sourceType === 'uni' }" @click="form.sourceType = 'uni'; form.sourceOrt = ''">Uni</button>
|
||||
</div>
|
||||
<input
|
||||
v-if="form.sourceType === 'link'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
placeholder="https://…" @keyup.enter="createThema"
|
||||
placeholder="https://…" @keyup.enter="createTopic"
|
||||
/>
|
||||
<select
|
||||
v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
>
|
||||
<option value="" disabled>Ordner wählen…</option>
|
||||
<option v-for="fo in (folders[form.sourceType] || [])" :key="fo.ort" :value="fo.ort">{{ fo.name }}</option>
|
||||
<option value="" disabled>Choose folder…</option>
|
||||
<option v-for="fo in (folders[form.sourceType] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
<div class="dlg-actions">
|
||||
<button class="dlg-cancel" @click="dlg = false">Abbrechen</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createThema">Erstellen</button>
|
||||
<button class="dlg-cancel" @click="dlg = false">Cancel</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-toggle" v-if="providers.length">
|
||||
@@ -355,113 +355,113 @@ function saveQuelle() {
|
||||
:key="p.id"
|
||||
:class="{ active: p.id === provider }"
|
||||
:disabled="!p.available"
|
||||
:title="p.available ? '' : 'Nicht konfiguriert (CLI/Key fehlt)'"
|
||||
:title="p.available ? '' : 'Not configured (CLI/key missing)'"
|
||||
@click="emit('setProvider', p.id)"
|
||||
>{{ PROVIDER_LABELS[p.id] || p.id }}</button>
|
||||
</div>
|
||||
<div class="format-section" v-if="selectedTopic">
|
||||
<div class="format-error ui-error" v-if="uiError">
|
||||
<span class="format-error-text">{{ uiError }}</span>
|
||||
<button class="format-error-x" title="Ausblenden" @click="emit('dismissUiError')">×</button>
|
||||
<button class="format-error-x" title="Hide" @click="emit('dismissUiError')">×</button>
|
||||
</div>
|
||||
<div class="progress-info" v-if="activeGenerations.length">
|
||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||
</div>
|
||||
<div class="ord-bausteine">
|
||||
<div class="ord-blocks">
|
||||
<div
|
||||
class="format-row bausteine-row"
|
||||
:class="{ 'is-active': bausteineState === 'generating' || bausteine.partial, 'row-open': isOpen('bausteine') }"
|
||||
class="format-row blocks-row"
|
||||
:class="{ 'is-active': blocksState === 'generating' || blocks.partial, 'row-open': isOpen('blocks') }"
|
||||
>
|
||||
<button class="format-name bausteine-name" @click="onBausteineName">
|
||||
<span class="format-label">Bausteine</span>
|
||||
<button class="format-name blocks-name" @click="onBlocksName">
|
||||
<span class="format-label">Blocks</span>
|
||||
<span
|
||||
v-if="bausteine.partial && bausteineState !== 'generating'"
|
||||
v-if="blocks.partial && blocksState !== 'generating'"
|
||||
class="resume-badge"
|
||||
title="Abgebrochen — Fortsetzen möglich"
|
||||
>Pausiert</span>
|
||||
title="Aborted — can be resumed"
|
||||
>Paused</span>
|
||||
</button>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('bausteine') }" title="Aktionen" @click.stop="togglePanel('bausteine')">▾</button>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('blocks') }" title="Actions" @click.stop="togglePanel('blocks')">▾</button>
|
||||
</div>
|
||||
<div v-if="isOpen('bausteine')" class="action-panel">
|
||||
<template v-if="bausteineState === 'generating'">
|
||||
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'bausteine' }" @click="confirmCancelBausteine">{{ pendingConfirm === 'bausteine' ? 'Sicher?' : 'Abbrechen' }}</button>
|
||||
<div v-if="isOpen('blocks')" class="action-panel">
|
||||
<template v-if="blocksState === 'generating'">
|
||||
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'blocks' }" @click="confirmCancelBlocks">{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Cancel' }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="panel-btn play" @click="handleBausteinePlay">{{ bausteine.partial ? 'Fortsetzen' : bausteine.ready ? 'Neu generieren' : 'Generieren' }}</button>
|
||||
<button class="panel-btn play" @click="handleBlocksPlay">{{ blocks.partial ? 'Resume' : blocks.ready ? 'Regenerate' : 'Generate' }}</button>
|
||||
<button
|
||||
v-if="bausteine.ready || bausteine.partial"
|
||||
v-if="blocks.ready || blocks.partial"
|
||||
class="panel-btn danger"
|
||||
:class="{ armed: pendingConfirm === 'bausteine' }"
|
||||
@click="confirmResetBausteine"
|
||||
>{{ pendingConfirm === 'bausteine' ? 'Sicher?' : 'Entfernen' }}</button>
|
||||
:class="{ armed: pendingConfirm === 'blocks' }"
|
||||
@click="confirmResetBlocks"
|
||||
>{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Remove' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="bausteineState === 'generating'" class="format-progress">
|
||||
{{ bausteine.progress || 'Wartend…' }}
|
||||
<div v-if="blocksState === 'generating'" class="format-progress">
|
||||
{{ blocks.progress || 'Waiting…' }}
|
||||
</div>
|
||||
<div v-if="bausteine.error && !bausteine.error.startsWith('Abgebrochen')" class="format-error">
|
||||
<span class="format-error-text">{{ bausteine.error }}</span>
|
||||
<div v-if="blocks.error && !blocks.error.startsWith('Cancelled')" class="format-error">
|
||||
<span class="format-error-text">{{ blocks.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Formate stehen per CSS-order nach der Bausteine-Zeile (order 2) -->
|
||||
<!-- Formats come after the blocks row via CSS order (order 2) -->
|
||||
<div v-for="f in formats" :key="f.key" :style="{ order: 3 }">
|
||||
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': abgebrochen(f.key), 'row-open': isOpen('fmt-' + f.key) }]">
|
||||
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': aborted(f.key), 'row-open': isOpen('fmt-' + f.key) }]">
|
||||
<button class="format-name" @click="handleFormatClick(f.key)">
|
||||
<span class="format-label">{{ f.label }}</span>
|
||||
<span
|
||||
v-if="abgebrochen(f.key)"
|
||||
v-if="aborted(f.key)"
|
||||
class="resume-badge"
|
||||
title="Abgebrochen — Fortsetzen möglich"
|
||||
>Pausiert</span>
|
||||
title="Aborted — can be resumed"
|
||||
>Paused</span>
|
||||
<span class="step-dots" v-if="guideSteps(f.key).length">
|
||||
<span
|
||||
v-for="(s, i) in guideSteps(f.key)"
|
||||
:key="s.label"
|
||||
class="step-pill"
|
||||
:class="[s.state, { sel: gewaehlterStep[f.key] === i + 1, klickbar: guideWaehlbar(f.key) }]"
|
||||
:title="(s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label) + (guideWaehlbar(f.key) ? ' — Klick: ab hier neu' : '')"
|
||||
@click.stop="guideStepKlick(f.key, i + 1)"
|
||||
:class="[s.state, { sel: selectedStep[f.key] === i + 1, klickbar: guideSelectable(f.key) }]"
|
||||
:title="(s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label) + (guideSelectable(f.key) ? ' — Click: regenerate from here' : '')"
|
||||
@click.stop="guideStepClick(f.key, i + 1)"
|
||||
>{{ i + 1 }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('fmt-' + f.key) }" title="Aktionen" @click.stop="togglePanel('fmt-' + f.key)">▾</button>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('fmt-' + f.key) }" title="Actions" @click.stop="togglePanel('fmt-' + f.key)">▾</button>
|
||||
</div>
|
||||
<div v-if="isOpen('fmt-' + f.key)" class="action-panel">
|
||||
<template v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'">
|
||||
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'fmt-' + f.key }" @click="handleDelete(f.key)">{{ pendingConfirm === 'fmt-' + f.key ? 'Sicher?' : 'Abbrechen' }}</button>
|
||||
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'fmt-' + f.key }" @click="handleDelete(f.key)">{{ pendingConfirm === 'fmt-' + f.key ? 'Sure?' : 'Cancel' }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="panel-btn play"
|
||||
:title="playLock(f.key) || (abgebrochen(f.key) ? 'Fortsetzen' : 'Generieren')"
|
||||
:title="playLock(f.key) || (aborted(f.key) ? 'Resume' : 'Generate')"
|
||||
:disabled="!!playLock(f.key)"
|
||||
@click="handlePlay(f.key)"
|
||||
>{{ gewaehlterStep[f.key] ? `Ab «${gewaehltesStepLabel(f.key)}» neu` : abgebrochen(f.key) ? 'Fortsetzen' : guideStatus(f.key) === 'done' ? 'Neu generieren' : 'Generieren' }}</button>
|
||||
>{{ selectedStep[f.key] ? `Restart from «${selectedStepLabel(f.key)}»` : aborted(f.key) ? 'Resume' : guideStatus(f.key) === 'done' ? 'Regenerate' : 'Generate' }}</button>
|
||||
<button
|
||||
v-if="guideStatus(f.key) !== 'none' || abgebrochen(f.key)"
|
||||
v-if="guideStatus(f.key) !== 'none' || aborted(f.key)"
|
||||
class="panel-btn danger"
|
||||
:class="{ armed: pendingConfirm === 'fmt-' + f.key }"
|
||||
@click="handleDelete(f.key)"
|
||||
>{{ pendingConfirm === 'fmt-' + f.key ? 'Sicher?' : abgebrochen(f.key) ? 'Fortschritt löschen' : 'Entfernen' }}</button>
|
||||
>{{ pendingConfirm === 'fmt-' + f.key ? 'Sure?' : aborted(f.key) ? 'Delete progress' : 'Remove' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"
|
||||
class="format-progress"
|
||||
>{{ latestByFormat[f.key]?.progress || 'Wartend…' }}</div>
|
||||
>{{ latestByFormat[f.key]?.progress || 'Waiting…' }}</div>
|
||||
<div v-if="errorMsg(f.key)" class="format-error">
|
||||
<span class="format-error-text">{{ errorMsg(f.key) }}</span>
|
||||
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
|
||||
<button class="format-error-x" title="Hide" @click="dismissError(f.key)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="format-row ord-pruefung">
|
||||
<div class="format-row ord-exam">
|
||||
<button class="format-name elements-btn" @click="emit('generalExam')">
|
||||
<span class="format-label">Allgemeine Prüfung</span>
|
||||
<span class="format-label">General Exam</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="format-row ord-elemente">
|
||||
<button class="format-name elements-btn" @click="emit('openElements')">
|
||||
<span class="format-label">Elemente</span>
|
||||
<span class="format-label">Elements</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -474,16 +474,16 @@ function saveQuelle() {
|
||||
>
|
||||
<div class="topic-row">
|
||||
<span class="topic-name" @click="emit('select', t)">{{ t }}</span>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Optionen" @click.stop="toggleTopicPanel(t)">▾</button>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Options" @click.stop="toggleTopicPanel(t)">▾</button>
|
||||
</div>
|
||||
<div v-if="isOpen('topic-' + t)" class="thema-panel edit-panel" @click.stop>
|
||||
<p v-if="editLoading" class="dlg-hint">Lade…</p>
|
||||
<p v-if="editLoading" class="dlg-hint">Loading…</p>
|
||||
<template v-else>
|
||||
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="Weitere Infos (optional)…"></textarea>
|
||||
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="More info (optional)…"></textarea>
|
||||
<div class="dlg-sources">
|
||||
<button :class="{ active: editForm.type === 'thema' }" @click="setEditType('thema')">Thema</button>
|
||||
<button :class="{ active: editForm.type === 'thema' }" @click="setEditType('thema')">Topic</button>
|
||||
<button :class="{ active: editForm.type === 'link' }" @click="setEditType('link')">Link</button>
|
||||
<button :class="{ active: editForm.type === 'projekt' }" @click="setEditType('projekt')">Projekt</button>
|
||||
<button :class="{ active: editForm.type === 'projekt' }" @click="setEditType('projekt')">Project</button>
|
||||
<button :class="{ active: editForm.type === 'uni' }" @click="setEditType('uni')">Uni</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -495,16 +495,16 @@ function saveQuelle() {
|
||||
v-else-if="editForm.type === 'projekt' || editForm.type === 'uni'"
|
||||
class="dlg-input" v-model="editForm.ort"
|
||||
>
|
||||
<option value="" disabled>Ordner wählen…</option>
|
||||
<option v-for="fo in (folders[editForm.type] || [])" :key="fo.ort" :value="fo.ort">{{ fo.name }}</option>
|
||||
<option value="" disabled>Choose folder…</option>
|
||||
<option v-for="fo in (folders[editForm.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
<div class="dlg-actions">
|
||||
<button
|
||||
class="dlg-delete"
|
||||
:class="{ armed: pendingConfirm === 'topic-' + t }"
|
||||
@click="confirmDeleteTopic(t)"
|
||||
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : 'Löschen' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveQuelle">Aktualisieren</button>
|
||||
>{{ pendingConfirm === 'topic-' + t ? 'Sure?' : 'Delete' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Update</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -589,7 +589,7 @@ function saveQuelle() {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
/* Ansicht-Umschalter: zwei Icon-Buttons (kurzer / langer Text) */
|
||||
/* View switcher: two icon buttons (short / long text) */
|
||||
.ansicht-toggle { display: inline-flex; }
|
||||
.new-topic .ansicht-toggle button {
|
||||
padding: 4px 7px;
|
||||
@@ -604,8 +604,8 @@ function saveQuelle() {
|
||||
.new-topic .ansicht-toggle button:hover { color: var(--accent-hover); }
|
||||
.new-topic .ansicht-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.ansicht-toggle svg { fill: currentColor; display: block; }
|
||||
.stufe-toggle { display: inline-flex; margin-left: 4px; }
|
||||
.new-topic .stufe-toggle button {
|
||||
.level-toggle { display: inline-flex; margin-left: 4px; }
|
||||
.new-topic .level-toggle button {
|
||||
padding: 4px 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
@@ -614,11 +614,11 @@ function saveQuelle() {
|
||||
font-weight: 600;
|
||||
min-width: 20px;
|
||||
}
|
||||
.new-topic .stufe-toggle button:first-child { border-radius: 6px 0 0 6px; }
|
||||
.new-topic .stufe-toggle button:last-child { border-radius: 0 6px 6px 0; }
|
||||
.new-topic .stufe-toggle button:not(:first-child) { border-left: none; }
|
||||
.new-topic .stufe-toggle button:hover { color: var(--accent-hover); }
|
||||
.new-topic .stufe-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.new-topic .level-toggle button:first-child { border-radius: 6px 0 0 6px; }
|
||||
.new-topic .level-toggle button:last-child { border-radius: 0 6px 6px 0; }
|
||||
.new-topic .level-toggle button:not(:first-child) { border-left: none; }
|
||||
.new-topic .level-toggle button:hover { color: var(--accent-hover); }
|
||||
.new-topic .level-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
@@ -646,7 +646,7 @@ function saveQuelle() {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Mini-Titel sitzt auf der oberen Kante des Badges (Legenden-Look) */
|
||||
/* Mini title sits on the top edge of the badge (legend look) */
|
||||
.stat-label {
|
||||
position: absolute;
|
||||
top: -0.42rem;
|
||||
@@ -740,7 +740,7 @@ function saveQuelle() {
|
||||
}
|
||||
|
||||
/* Format section */
|
||||
.bausteine-name {
|
||||
.blocks-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -753,7 +753,7 @@ function saveQuelle() {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Grobe Phasen als nummerierte Pillen (1–5) — Anzeige + anklickbar für Re-Run ab hier. */
|
||||
/* Coarse phases as numbered pills (1–5) — display + clickable for re-run from here. */
|
||||
.step-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -803,16 +803,16 @@ function saveQuelle() {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
/* flex + order: Bausteine (order 2) vor den Formaten (order 3) */
|
||||
/* flex + order: blocks (order 2) before the formats (order 3) */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ord-bausteine {
|
||||
.ord-blocks {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.ord-pruefung {
|
||||
.ord-exam {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
@@ -838,14 +838,14 @@ function saveQuelle() {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Abgewiesene Aktion (409/400) — oberhalb aller Format-Zeilen */
|
||||
/* Rejected action (409/400) — above all format rows */
|
||||
.ui-error {
|
||||
order: 0;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
/* Fortschritts-Text direkt unter der laufenden Format-Zeile */
|
||||
/* Progress text directly below the running format row */
|
||||
.format-progress {
|
||||
padding: 0 0.75rem 5px calc(0.75rem + 8px);
|
||||
font-size: 0.72rem;
|
||||
@@ -895,7 +895,7 @@ function saveQuelle() {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Accordion: Pfeil-Toggle + aufklappendes Aktions-Panel */
|
||||
/* Accordion: arrow toggle + expanding action panel */
|
||||
.panel-toggle {
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
@@ -957,11 +957,11 @@ function saveQuelle() {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Laufend/pausiert: × immer zeigen — Hover gibt es auf Touch nicht */
|
||||
/* Running/paused: always show × — there is no hover on touch */
|
||||
.fmt-generating .format-x,
|
||||
.fmt-queued .format-x,
|
||||
.fmt-paused .format-x,
|
||||
.bausteine-row.is-active .format-x {
|
||||
.blocks-row.is-active .format-x {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@@ -1072,9 +1072,9 @@ function saveQuelle() {
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
/* Erstellen — inline aufklappbar (kein Modal) */
|
||||
/* Create — inline expandable (no modal) */
|
||||
.new-topic-toggle {
|
||||
margin-left: auto; /* rechtsbündig statt volle Breite */
|
||||
margin-left: auto; /* right-aligned instead of full width */
|
||||
padding: 6px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
@@ -1136,7 +1136,7 @@ function saveQuelle() {
|
||||
.dlg-create { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.dlg-create:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
/* Thema-Edit-Panel: dezent in die Liste eingefügt — keine Box, nur ein linker Akzent */
|
||||
/* Topic edit panel: subtly inserted into the list — no box, just a left accent */
|
||||
.edit-panel {
|
||||
margin: 0;
|
||||
padding: 0.5rem 1rem 0.7rem 1.25rem;
|
||||
|
||||
@@ -2,27 +2,27 @@
|
||||
import { ref } from 'vue'
|
||||
import { renderMarkdownInline } from '../markdown.js'
|
||||
|
||||
defineProps({ beispiele: { type: Array, default: () => [] } })
|
||||
const offen = ref(false)
|
||||
defineProps({ examples: { type: Array, default: () => [] } })
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="beispiele.length" class="worked">
|
||||
<button class="art-head" @click="offen = !offen">
|
||||
<span class="art-icon">📝</span> Beispiele
|
||||
<span class="art-count">{{ beispiele.length }}</span>
|
||||
<span class="art-toggle">{{ offen ? '▾' : '▸' }}</span>
|
||||
<div v-if="examples.length" class="worked">
|
||||
<button class="art-head" @click="open = !open">
|
||||
<span class="art-icon">📝</span> Examples
|
||||
<span class="art-count">{{ examples.length }}</span>
|
||||
<span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-if="offen" class="we-body">
|
||||
<div v-for="(b, i) in beispiele" :key="i" class="we-card">
|
||||
<div v-if="b.subbaustein" class="we-sub">{{ b.subbaustein }}</div>
|
||||
<div v-if="open" class="we-body">
|
||||
<div v-for="(b, i) in examples" :key="i" class="we-card">
|
||||
<div v-if="b.subblock" class="we-sub">{{ b.subblock }}</div>
|
||||
<div class="we-problem" v-html="renderMarkdownInline(b.problem)"></div>
|
||||
<ol class="we-schritte">
|
||||
<li v-for="(s, j) in b.schritte" :key="j" v-html="renderMarkdownInline(s)"></li>
|
||||
<ol class="we-steps">
|
||||
<li v-for="(s, j) in b.steps" :key="j" v-html="renderMarkdownInline(s)"></li>
|
||||
</ol>
|
||||
<div v-if="b.ergebnis" class="we-ergebnis">
|
||||
<span class="we-label">Ergebnis</span>
|
||||
<span v-html="renderMarkdownInline(b.ergebnis)"></span>
|
||||
<div v-if="b.result" class="we-result">
|
||||
<span class="we-label">Result</span>
|
||||
<span v-html="renderMarkdownInline(b.result)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,9 +54,9 @@ const offen = ref(false)
|
||||
color: var(--text-faint); font-weight: 700; margin-bottom: 0.3rem;
|
||||
}
|
||||
.we-problem { font-weight: 600; margin-bottom: 0.4rem; }
|
||||
.we-schritte { margin: 0 0 0.4rem 1.1rem; padding: 0; }
|
||||
.we-schritte li { margin: 0.2rem 0; }
|
||||
.we-ergebnis {
|
||||
.we-steps { margin: 0 0 0.4rem 1.1rem; padding: 0; }
|
||||
.we-steps li { margin: 0.2rem 0; }
|
||||
.we-result {
|
||||
display: flex; gap: 6px; align-items: baseline;
|
||||
padding-top: 0.35rem; border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const emit = defineEmits(['changes'])
|
||||
const chat = useChat((msgs) => chatElement(props.element.id, msgs, props.provider))
|
||||
const { messages, input, loading, messagesEl, inputEl, onScroll } = chat
|
||||
|
||||
// Anderes Element gewählt → Verlauf verwerfen
|
||||
// Different element selected → discard history
|
||||
watch(() => props.element.id, () => chat.reset())
|
||||
|
||||
async function send() {
|
||||
@@ -25,23 +25,23 @@ async function send() {
|
||||
<template>
|
||||
<div class="el-chat">
|
||||
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
|
||||
<p v-if="!messages.length" class="chat-hint">Schreib, was am Element geändert werden soll.</p>
|
||||
<p v-if="!messages.length" class="chat-hint">Write what should be changed on the element.</p>
|
||||
<template v-for="(m, i) in messages" :key="i">
|
||||
<div :class="['chat-msg', m.role]">{{ m.content }}</div>
|
||||
</template>
|
||||
<div v-if="loading" class="chat-msg assistant chat-typing">Passt an…</div>
|
||||
<div v-if="loading" class="chat-msg assistant chat-typing">Adjusting…</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="input"
|
||||
placeholder="Element anpassen…"
|
||||
placeholder="Adjust element…"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
></textarea>
|
||||
<button
|
||||
:disabled="!input.trim() && !loading"
|
||||
:class="{ cancel: loading }"
|
||||
:title="loading ? 'Abbrechen' : 'Senden'"
|
||||
:title="loading ? 'Cancel' : 'Send'"
|
||||
@click="send"
|
||||
>{{ loading ? '✕' : '➤' }}</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { updateElement, checkElement, styleElement, refineSuggestion } from '../../api.js'
|
||||
import { renderMarkdown } from '../../markdown.js'
|
||||
import { renderMarkdown, plainText } from '../../markdown.js'
|
||||
import ElementSuggestion from './ElementSuggestion.vue'
|
||||
import ElementChatTab from './ElementChatTab.vue'
|
||||
import ElementEditTab from './ElementEditTab.vue'
|
||||
@@ -16,18 +16,15 @@ const emit = defineEmits(['back', 'close', 'updated', 'changed'])
|
||||
const tab = ref('overview') // 'overview' | 'chat' | 'edit'
|
||||
const savingEdit = ref(false)
|
||||
|
||||
// Markdown-Zeichen aus dem Header-Titel entfernen
|
||||
function plain(text) {
|
||||
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
|
||||
}
|
||||
// Strip Markdown characters from the header title
|
||||
|
||||
// Anderes Element gewählt → Prüf-Zustand und Tab zurücksetzen
|
||||
// Different element selected → reset exam state and tab
|
||||
watch(() => props.element.id, () => {
|
||||
tab.value = 'overview'
|
||||
resetCheck()
|
||||
})
|
||||
|
||||
// --- KI-Prüfung auf fehlende Infos (Ergebnisse landen als Inline-Vorschläge) ---
|
||||
// --- AI exam for missing info (results land as inline suggestions) ---
|
||||
const checking = ref(false)
|
||||
const statusMsg = ref(null)
|
||||
|
||||
@@ -37,10 +34,10 @@ function resetCheck() {
|
||||
resetStyle()
|
||||
}
|
||||
|
||||
let checkRun = 0 // laufende Prüfung identifizieren; Abbruch ignoriert ihr Ergebnis
|
||||
let checkRun = 0 // identify the running exam; cancellation ignores its result
|
||||
|
||||
async function runCheck() {
|
||||
if (checking.value) { // zweiter Klick = abbrechen
|
||||
if (checking.value) { // second click = cancel
|
||||
checkRun++
|
||||
checking.value = false
|
||||
return
|
||||
@@ -50,23 +47,23 @@ async function runCheck() {
|
||||
statusMsg.value = null
|
||||
try {
|
||||
const res = await checkElement(props.element.id, props.provider)
|
||||
if (run !== checkRun) return // abgebrochen oder neue Prüfung gestartet
|
||||
if (run !== checkRun) return // cancelled or a new exam started
|
||||
const mapped = res.suggestions.map((s) => ({
|
||||
text: s.text, action: 'hinzufuegen', target: s.target, index: null, content: s.content,
|
||||
text: s.text, action: 'add', target: s.target, index: null, content: s.content,
|
||||
}))
|
||||
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
|
||||
else statusMsg.value = 'Keine wichtigen Lücken gefunden.'
|
||||
else statusMsg.value = 'No important gaps found.'
|
||||
} catch (e) {
|
||||
if (run !== checkRun) return
|
||||
console.error('Prüfung fehlgeschlagen:', e)
|
||||
statusMsg.value = 'Prüfung fehlgeschlagen — bitte erneut versuchen.'
|
||||
console.error('Exam failed:', e)
|
||||
statusMsg.value = 'Exam failed — please try again.'
|
||||
} finally {
|
||||
if (run === checkRun) checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Stil-Prüfung: KI schlägt Änderungen vor, Nutzer bestätigt ---
|
||||
const styleChanges = ref(null) // null = noch nicht geprüft
|
||||
// --- Style exam: AI proposes changes, user confirms ---
|
||||
const styleChanges = ref(null) // null = not yet examined
|
||||
const styling = ref(false)
|
||||
const applyingStyle = ref(false)
|
||||
const refiningIdx = ref(null)
|
||||
@@ -83,7 +80,7 @@ function suggBusy(i) {
|
||||
return applyingStyle.value || refiningIdx.value === i
|
||||
}
|
||||
|
||||
// Einzelnen Vorschlag per Anweisung überarbeiten (Stift-Icon)
|
||||
// Refine a single suggestion via instruction (pencil icon)
|
||||
async function refineChange(i, instruction) {
|
||||
if (refiningIdx.value !== null || applyingStyle.value) return
|
||||
refiningIdx.value = i
|
||||
@@ -93,15 +90,15 @@ async function refineChange(i, instruction) {
|
||||
next[i] = res.change
|
||||
styleChanges.value = next
|
||||
} catch (e) {
|
||||
console.error('Überarbeitung fehlgeschlagen:', e)
|
||||
statusMsg.value = 'Überarbeitung fehlgeschlagen — bitte erneut versuchen.'
|
||||
console.error('Refinement failed:', e)
|
||||
statusMsg.value = 'Refinement failed — please try again.'
|
||||
} finally {
|
||||
refiningIdx.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function runStyle() {
|
||||
if (styling.value) { // zweiter Klick = abbrechen
|
||||
if (styling.value) { // second click = cancel
|
||||
styleRun++
|
||||
styling.value = false
|
||||
return
|
||||
@@ -113,35 +110,35 @@ async function runStyle() {
|
||||
const res = await styleElement(props.element.id, props.provider)
|
||||
if (run !== styleRun) return
|
||||
if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes]
|
||||
else statusMsg.value = 'Stil passt bereits.'
|
||||
else statusMsg.value = 'Style already fits.'
|
||||
} catch (e) {
|
||||
if (run !== styleRun) return
|
||||
console.error('Stil-Prüfung fehlgeschlagen:', e)
|
||||
statusMsg.value = 'Stil-Prüfung fehlgeschlagen — bitte erneut versuchen.'
|
||||
console.error('Style exam failed:', e)
|
||||
statusMsg.value = 'Style exam failed — please try again.'
|
||||
} finally {
|
||||
if (run === styleRun) styling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Chat-Vorschläge landen ebenfalls als Inline-Vorschläge in der Übersicht
|
||||
// Chat suggestions also land as inline suggestions in the overview
|
||||
function onChatChanges(changes) {
|
||||
styleChanges.value = [...(styleChanges.value || []), ...changes]
|
||||
}
|
||||
|
||||
// Vorschläge am Ziel-Ort anzeigen: anpassen/entfernen beim betroffenen Eintrag …
|
||||
// Show suggestions at the target location: adjust/remove at the affected entry …
|
||||
function styleAt(target, index = null) {
|
||||
if (!styleChanges.value) return []
|
||||
return styleChanges.value
|
||||
.map((c, i) => [i, c])
|
||||
.filter(([, c]) => c.target === target && c.index === index && c.action !== 'hinzufuegen')
|
||||
.filter(([, c]) => c.target === target && c.index === index && c.action !== 'add')
|
||||
}
|
||||
|
||||
// … Ergänzungen am Ende der jeweiligen Sektion
|
||||
// … additions at the end of the respective section
|
||||
function styleAdds(target) {
|
||||
if (!styleChanges.value) return []
|
||||
return styleChanges.value
|
||||
.map((c, i) => [i, c])
|
||||
.filter(([, c]) => c.target === target && c.action === 'hinzufuegen')
|
||||
.filter(([, c]) => c.target === target && c.action === 'add')
|
||||
}
|
||||
|
||||
function dismissStyleChange(i) {
|
||||
@@ -160,8 +157,8 @@ async function applyStyleChange(i) {
|
||||
examples: [...props.element.examples],
|
||||
hints: [...props.element.hints],
|
||||
}
|
||||
if (c.action === 'entfernen') fields[c.target].splice(c.index, 1)
|
||||
else if (c.action === 'hinzufuegen') {
|
||||
if (c.action === 'remove') fields[c.target].splice(c.index, 1)
|
||||
else if (c.action === 'add') {
|
||||
if (c.target === 'title') fields.title = c.content
|
||||
else if (c.target === 'description')
|
||||
fields[c.target] = fields[c.target] ? fields[c.target] + '\n\n' + c.content : c.content
|
||||
@@ -172,22 +169,22 @@ async function applyStyleChange(i) {
|
||||
const updated = await updateElement(props.element.id, fields)
|
||||
emit('updated', updated)
|
||||
|
||||
// Rest-Vorschläge behalten; Indizes hinter einer Entfernung rücken auf
|
||||
// Keep remaining suggestions; indices after a removal shift up
|
||||
const rest = styleChanges.value.filter((_, j) => j !== i)
|
||||
if (c.action === 'entfernen') {
|
||||
if (c.action === 'remove') {
|
||||
for (const r of rest) {
|
||||
if (r.target === c.target && r.index !== null && r.index > c.index) r.index--
|
||||
}
|
||||
}
|
||||
styleChanges.value = rest
|
||||
} catch (e) {
|
||||
console.error('Übernehmen fehlgeschlagen:', e)
|
||||
console.error('Apply failed:', e)
|
||||
} finally {
|
||||
applyingStyle.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bearbeiten-Tab: Felder direkt speichern ---
|
||||
// --- Edit tab: save fields directly ---
|
||||
async function saveEdit(fields) {
|
||||
if (savingEdit.value) return
|
||||
savingEdit.value = true
|
||||
@@ -196,7 +193,7 @@ async function saveEdit(fields) {
|
||||
emit('updated', updated)
|
||||
tab.value = 'overview'
|
||||
} catch (e) {
|
||||
console.error('Speichern fehlgeschlagen:', e)
|
||||
console.error('Save failed:', e)
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
@@ -205,26 +202,26 @@ async function saveEdit(fields) {
|
||||
|
||||
<template>
|
||||
<header class="el-header">
|
||||
<button class="el-back" title="Zur Liste" @click="emit('back')">←</button>
|
||||
<span class="el-title">{{ plain(element.title) }}</span>
|
||||
<button class="el-back" title="Back to list" @click="emit('back')">←</button>
|
||||
<span class="el-title">{{ plainText(element.title) }}</span>
|
||||
<button
|
||||
class="el-tool" :class="{ busy: checking }"
|
||||
:title="checking ? 'Prüfung abbrechen' : 'Auf fehlende Infos prüfen'" @click="runCheck"
|
||||
:title="checking ? 'Cancel exam' : 'Check for missing info'" @click="runCheck"
|
||||
>🔍</button>
|
||||
<button
|
||||
class="el-tool" :class="{ busy: styling }"
|
||||
:title="styling ? 'Prüfung abbrechen' : 'Stil prüfen & anpassen'" @click="runStyle"
|
||||
:title="styling ? 'Cancel exam' : 'Check & adjust style'" @click="runStyle"
|
||||
>✨</button>
|
||||
<button class="el-close" title="Schließen" @click="emit('close')">×</button>
|
||||
<button class="el-close" title="Close" @click="emit('close')">×</button>
|
||||
</header>
|
||||
|
||||
<nav class="el-tabs">
|
||||
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Übersicht</button>
|
||||
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Overview</button>
|
||||
<button :class="{ active: tab === 'chat' }" @click="tab = 'chat'">Chat</button>
|
||||
<button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Bearbeiten</button>
|
||||
<button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Edit</button>
|
||||
</nav>
|
||||
|
||||
<!-- Übersicht: untrennbar mit styleChanges/Apply verzahnt → bleibt hier -->
|
||||
<!-- Overview: inseparably intertwined with styleChanges/apply → stays here -->
|
||||
<div v-show="tab === 'overview'" class="el-detail">
|
||||
<div v-if="element.description" class="el-desc markdown" v-html="renderMarkdown(element.description)"></div>
|
||||
<ElementSuggestion
|
||||
@@ -246,7 +243,7 @@ async function saveEdit(fields) {
|
||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||
/>
|
||||
<div v-if="element.hints.length || styleAdds('hints').length" class="el-hints-block">
|
||||
<h4>Hinweise</h4>
|
||||
<h4>Hints</h4>
|
||||
<ul class="el-hints">
|
||||
<li v-for="(h, i) in element.hints" :key="i">
|
||||
<span class="markdown" v-html="renderMarkdown(h)"></span>
|
||||
@@ -265,13 +262,13 @@ async function saveEdit(fields) {
|
||||
</div>
|
||||
|
||||
<div v-if="checking || styling || statusMsg" class="el-check">
|
||||
<p v-if="checking" class="check-empty busy-text">Prüft auf fehlende Infos…</p>
|
||||
<p v-if="styling" class="check-empty busy-text">Prüft den Stil…</p>
|
||||
<p v-if="checking" class="check-empty busy-text">Checking for missing info…</p>
|
||||
<p v-if="styling" class="check-empty busy-text">Checking the style…</p>
|
||||
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- v-show erhält den Chat-Verlauf beim Tab-Wechsel -->
|
||||
<!-- v-show preserves the chat history when switching tabs -->
|
||||
<ElementChatTab
|
||||
v-show="tab === 'chat'"
|
||||
:element="element"
|
||||
@@ -279,7 +276,7 @@ async function saveEdit(fields) {
|
||||
@changes="onChatChanges"
|
||||
/>
|
||||
|
||||
<!-- v-if lädt die Edit-Felder bei jedem Öffnen frisch -->
|
||||
<!-- v-if loads the edit fields fresh on every open -->
|
||||
<ElementEditTab
|
||||
v-if="tab === 'edit'"
|
||||
:element="element"
|
||||
@@ -427,7 +424,7 @@ async function saveEdit(fields) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Hinweis-Text inline neben dem Bullet halten (p ist sonst block) */
|
||||
/* Keep hint text inline next to the bullet (p is block otherwise) */
|
||||
.el-hints li > .markdown {
|
||||
display: inline;
|
||||
}
|
||||
@@ -437,12 +434,12 @@ async function saveEdit(fields) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Markdown: Basis global (assets/markdown.css); schmale Sidebar → kompaktere Code-Blöcke */
|
||||
/* Markdown: base is global (assets/markdown.css); narrow sidebar → more compact code blocks */
|
||||
.markdown :deep(pre) {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* --- KI-Prüfung --- */
|
||||
/* --- AI exam --- */
|
||||
.el-check {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.8rem;
|
||||
|
||||
@@ -35,28 +35,28 @@ function save() {
|
||||
<template>
|
||||
<div class="el-edit">
|
||||
<button class="edit-save" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Speichert…' : 'Speichern' }}
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
|
||||
<label>Titel</label>
|
||||
<input v-model="edit.title" placeholder="Titel" />
|
||||
<label>Title</label>
|
||||
<input v-model="edit.title" placeholder="Title" />
|
||||
|
||||
<label>Beschreibung</label>
|
||||
<textarea v-model="edit.description" placeholder="Beschreibung"></textarea>
|
||||
<label>Description</label>
|
||||
<textarea v-model="edit.description" placeholder="Description"></textarea>
|
||||
|
||||
<label>Beispiele</label>
|
||||
<label>Examples</label>
|
||||
<div v-for="(ex, i) in edit.examples" :key="'ex' + i" class="edit-row">
|
||||
<textarea v-model="edit.examples[i]" placeholder="Beispiel"></textarea>
|
||||
<button class="edit-del" title="Entfernen" @click="edit.examples.splice(i, 1)">×</button>
|
||||
<textarea v-model="edit.examples[i]" placeholder="Example"></textarea>
|
||||
<button class="edit-del" title="Remove" @click="edit.examples.splice(i, 1)">×</button>
|
||||
</div>
|
||||
<button class="edit-add" @click="edit.examples.push('')">+ Beispiel</button>
|
||||
<button class="edit-add" @click="edit.examples.push('')">+ Example</button>
|
||||
|
||||
<label>Hinweise</label>
|
||||
<label>Hints</label>
|
||||
<div v-for="(h, i) in edit.hints" :key="'hi' + i" class="edit-row">
|
||||
<textarea v-model="edit.hints[i]" placeholder="Hinweis"></textarea>
|
||||
<button class="edit-del" title="Entfernen" @click="edit.hints.splice(i, 1)">×</button>
|
||||
<textarea v-model="edit.hints[i]" placeholder="Hint"></textarea>
|
||||
<button class="edit-del" title="Remove" @click="edit.hints.splice(i, 1)">×</button>
|
||||
</div>
|
||||
<button class="edit-add" @click="edit.hints.push('')">+ Hinweis</button>
|
||||
<button class="edit-add" @click="edit.hints.push('')">+ Hint</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useConfirm } from '../../composables/useConfirm.js'
|
||||
import { plainText } from '../../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
elements: { type: Array, required: true },
|
||||
@@ -12,10 +13,7 @@ const emit = defineEmits(['select', 'create', 'remove'])
|
||||
const query = ref('')
|
||||
const { isArmed, armOrRun } = useConfirm()
|
||||
|
||||
// Markdown-Zeichen für Titel und Listen-Vorschau entfernen
|
||||
function plain(text) {
|
||||
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
|
||||
}
|
||||
// Strip Markdown characters for title and list preview
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
@@ -31,7 +29,7 @@ function add() {
|
||||
query.value = ''
|
||||
}
|
||||
|
||||
// Inline-Bestätigung: erster Klick „Sicher?", zweiter löscht
|
||||
// Inline confirmation: first click "Sure?", second deletes
|
||||
function confirmDelete(el) {
|
||||
armOrRun('el-' + el.id, () => emit('remove', el))
|
||||
}
|
||||
@@ -41,28 +39,28 @@ function confirmDelete(el) {
|
||||
<div class="el-new">
|
||||
<input
|
||||
v-model="query"
|
||||
placeholder="Suchen oder Stichwort…"
|
||||
placeholder="Search or keyword…"
|
||||
:disabled="creating"
|
||||
@keyup.enter="add"
|
||||
/>
|
||||
<button :disabled="creating" title="Element per KI erstellen" @click="add">+</button>
|
||||
<button :disabled="creating" title="Create element via AI" @click="add">+</button>
|
||||
</div>
|
||||
<div v-if="creating" class="el-creating">KI erstellt Element…</div>
|
||||
<div v-if="creating" class="el-creating">AI is creating element…</div>
|
||||
<ul class="el-list">
|
||||
<li v-for="el in filtered" :key="el.id" @click="emit('select', el)">
|
||||
<div class="el-item-main">
|
||||
<span class="el-item-title">{{ plain(el.title) }}</span>
|
||||
<span class="el-item-desc">{{ plain(el.description) }}</span>
|
||||
<span class="el-item-title">{{ plainText(el.title) }}</span>
|
||||
<span class="el-item-desc">{{ plainText(el.description) }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="el-delete"
|
||||
:class="{ armed: isArmed('el-' + el.id) }"
|
||||
title="Element löschen"
|
||||
title="Delete element"
|
||||
@click.stop="confirmDelete(el)"
|
||||
>{{ isArmed('el-' + el.id) ? 'Sicher?' : '×' }}</button>
|
||||
>{{ isArmed('el-' + el.id) ? 'Sure?' : '×' }}</button>
|
||||
</li>
|
||||
<li v-if="!filtered.length && !creating" class="el-empty">
|
||||
{{ elements.length ? 'Keine Treffer.' : 'Noch keine Elemente. Stichwort eingeben und + klicken.' }}
|
||||
{{ elements.length ? 'No matches.' : 'No elements yet. Enter a keyword and click +.' }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['apply', 'dismiss', 'refine'])
|
||||
|
||||
const ACTION_LABELS = { entfernen: 'Entfernen:', anpassen: 'Anpassen:', hinzufuegen: 'Hinzufügen:' }
|
||||
const ACTION_LABELS = { remove: 'Remove:', adjust: 'Adjust:', add: 'Add:' }
|
||||
|
||||
const editing = ref(false)
|
||||
const instruction = ref('')
|
||||
@@ -34,15 +34,15 @@ function submit() {
|
||||
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[change.action] }}</strong> {{ change.text }}</div>
|
||||
<div v-if="change.content" class="style-sugg-preview markdown" v-html="renderMarkdown(change.content)"></div>
|
||||
<div class="style-sugg-actions">
|
||||
<button class="sugg-ok" :disabled="busy" @click="emit('apply')">Bestätigen</button>
|
||||
<button class="sugg-no" :disabled="busy" @click="emit('dismiss')">Ablehnen</button>
|
||||
<button class="sugg-edit" :disabled="busy" title="Vorschlag per Anweisung anpassen" @click="toggleEdit">✏️</button>
|
||||
<button class="sugg-ok" :disabled="busy" @click="emit('apply')">Confirm</button>
|
||||
<button class="sugg-no" :disabled="busy" @click="emit('dismiss')">Reject</button>
|
||||
<button class="sugg-edit" :disabled="busy" title="Adjust suggestion via instruction" @click="toggleEdit">✏️</button>
|
||||
</div>
|
||||
<div v-if="editing" class="sugg-edit-row">
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="instruction"
|
||||
placeholder="Anweisung zum Vorschlag…"
|
||||
placeholder="Instruction for the suggestion…"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<button :disabled="!instruction.trim() || busy" @click="submit">➤</button>
|
||||
@@ -175,7 +175,7 @@ function submit() {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Markdown: Basis global (assets/markdown.css); kompakte Vorschau-Code-Blöcke */
|
||||
/* Markdown: base is global (assets/markdown.css); compact preview code blocks */
|
||||
.markdown :deep(pre) {
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -7,8 +7,8 @@ import ElementDetail from './ElementDetail.vue'
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
openId: { type: String, default: null }, // Element-ID, die geöffnet werden soll
|
||||
openTick: { type: Number, default: 0 }, // Erhöhung = openId (erneut) öffnen
|
||||
openId: { type: String, default: null }, // Element ID that should be opened
|
||||
openTick: { type: Number, default: 0 }, // increment = (re)open openId
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'changed'])
|
||||
@@ -24,12 +24,12 @@ async function load() {
|
||||
try {
|
||||
elements.value = await fetchElements(props.topic)
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Elemente:', e)
|
||||
console.error('Failed to load elements:', e)
|
||||
}
|
||||
openFromProp()
|
||||
}
|
||||
|
||||
// Aus der Übersicht im Hauptbereich angeklicktes Element öffnen
|
||||
// Open the element clicked in the overview of the main area
|
||||
watch(() => props.openTick, openFromProp)
|
||||
|
||||
function openFromProp() {
|
||||
@@ -46,7 +46,7 @@ async function create(hint) {
|
||||
elements.value.unshift(el)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Erstellen des Elements:', e)
|
||||
console.error('Failed to create element:', e)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
@@ -59,7 +59,7 @@ async function remove(el) {
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
// Bearbeitetes Element in Liste und Auswahl synchron halten
|
||||
// Keep the edited element in sync within the list and selection
|
||||
function onUpdated(el) {
|
||||
selected.value = el
|
||||
const idx = elements.value.findIndex((e) => e.id === el.id)
|
||||
@@ -81,8 +81,8 @@ function onUpdated(el) {
|
||||
/>
|
||||
<template v-else>
|
||||
<header class="el-header">
|
||||
<span class="el-title">Elemente</span>
|
||||
<button class="el-close" title="Schließen" @click="emit('close')">×</button>
|
||||
<span class="el-title">Elements</span>
|
||||
<button class="el-close" title="Close" @click="emit('close')">×</button>
|
||||
</header>
|
||||
<ElementList
|
||||
:elements="elements"
|
||||
@@ -104,13 +104,13 @@ function onUpdated(el) {
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--border);
|
||||
/* Über dem Guide-Chat (FAB/Panel: z-index 20) */
|
||||
/* Above the guide chat (FAB/panel: z-index 20) */
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Mobil/schmal: als Overlay über den Hauptinhalt legen, statt ihn
|
||||
im Flex-Fluss einzuquetschen. */
|
||||
/* Mobile/narrow: lay it as an overlay over the main content instead of
|
||||
squeezing it into the flex flow. */
|
||||
@media (max-width: 768px) {
|
||||
.elements-sidebar {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
// (Fast) am unteren Rand? Schwelle fängt Sub-Pixel und kleine Abstände ab.
|
||||
export function istUnten(el, schwelle = 60) {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < schwelle
|
||||
// (Almost) at the bottom edge? Threshold absorbs sub-pixels and small gaps.
|
||||
export function istUnten(el, threshold = 60) {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < threshold
|
||||
}
|
||||
|
||||
// Gemeinsame Chat-Mechanik: senden, abbrechen (Run-Counter), scrollen, Fokus.
|
||||
// performRequest(messages) → Promise<{ reply, … }>; send() gibt die Antwort
|
||||
// zurück, damit der Aufrufer Extras (z. B. changes) auswerten kann.
|
||||
// Shared chat mechanics: send, cancel (run counter), scroll, focus.
|
||||
// performRequest(messages) → Promise<{ reply, … }>; send() returns the response
|
||||
// so the caller can evaluate extras (e.g. changes).
|
||||
export function useChat(performRequest) {
|
||||
const messages = ref([])
|
||||
const input = ref('')
|
||||
const loading = ref(false)
|
||||
const messagesEl = ref(null) // Template-Ref: Nachrichten-Container
|
||||
const inputEl = ref(null) // Template-Ref: Textarea
|
||||
const stick = ref(true) // an den Boden „gepinnt" — nur dann auto-scrollen
|
||||
let run = 0 // laufende Anfrage identifizieren; Abbruch ignoriert ihr Ergebnis
|
||||
const messagesEl = ref(null) // template ref: messages container
|
||||
const inputEl = ref(null) // template ref: textarea
|
||||
const stick = ref(true) // "pinned" to the bottom — only then auto-scroll
|
||||
let run = 0 // identify the running request; cancel ignores its result
|
||||
|
||||
// @scroll-Handler: pinnt nur, wenn der Nutzer (fast) unten ist.
|
||||
// @scroll handler: pins only when the user is (almost) at the bottom.
|
||||
function onScroll() {
|
||||
if (messagesEl.value) stick.value = istUnten(messagesEl.value)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function useChat(performRequest) {
|
||||
function cancel() {
|
||||
run++
|
||||
loading.value = false
|
||||
messages.value.push({ role: 'assistant', content: 'Abgebrochen.' })
|
||||
messages.value.push({ role: 'assistant', content: 'Cancelled.' })
|
||||
}
|
||||
|
||||
function reset() {
|
||||
@@ -48,13 +48,13 @@ export function useChat(performRequest) {
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (loading.value) { // zweiter Klick = abbrechen
|
||||
if (loading.value) { // second click = cancel
|
||||
cancel()
|
||||
return null
|
||||
}
|
||||
const text = input.value.trim()
|
||||
if (!text) return null
|
||||
stick.value = true // eigenes Senden = ans Ende; Hochscrollen während des Wartens setzt es wieder false
|
||||
stick.value = true // own send = jump to end; scrolling up while waiting resets it to false
|
||||
const current = ++run
|
||||
messages.value.push({ role: 'user', content: text })
|
||||
input.value = ''
|
||||
@@ -64,17 +64,17 @@ export function useChat(performRequest) {
|
||||
try {
|
||||
const res = await performRequest(messages.value)
|
||||
if (current !== run) return null
|
||||
// Prüfung liefert `frage` (+ getrenntes `feedback`); andere Chats `reply`.
|
||||
// Exam returns `question` (+ separate `feedback`); other chats `reply`.
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: res.frage ?? res.reply ?? '…',
|
||||
content: res.question ?? res.reply ?? '…',
|
||||
feedback: res.feedback ?? null,
|
||||
bewertung: res.bewertung ?? null,
|
||||
rating: res.rating ?? null,
|
||||
})
|
||||
return res
|
||||
} catch {
|
||||
if (current !== run) return null
|
||||
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
|
||||
messages.value.push({ role: 'assistant', content: 'Request failed.' })
|
||||
return null
|
||||
} finally {
|
||||
if (current === run) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
// Inline-Bestätigung statt confirm(): erster Klick scharfschalten („Sicher?"),
|
||||
// zweiter Klick führt aus. Browser-Dialoge können unterdrückt sein (Firefox).
|
||||
// Inline confirmation instead of confirm(): first click arms ("Sure?"),
|
||||
// second click runs it. Browser dialogs can be suppressed (Firefox).
|
||||
export function useConfirm(timeoutMs = 3000) {
|
||||
const pending = ref(null) // aktuell scharfgeschalteter Key
|
||||
const pending = ref(null) // currently armed key
|
||||
let timer = null
|
||||
|
||||
function armOrRun(key, action) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
// Polling mit Visibility-Pause: Tab unsichtbar → stoppen; wieder sichtbar →
|
||||
// sofortiger Tick, dann weiter, falls isActive(). Stoppt selbst, sobald
|
||||
// isActive() nach einem Tick false liefert.
|
||||
// Polling with a visibility pause: tab hidden → stop; visible again →
|
||||
// immediate tick, then continue if isActive(). Stops itself as soon as
|
||||
// isActive() returns false after a tick.
|
||||
export function usePolling(tick, isActive, interval = 3000) {
|
||||
let timer = null
|
||||
|
||||
|
||||
35
frontend/src/levels.js
Normal file
35
frontend/src/levels.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// Learning levels per block — identical to the backend (learning.py LEVELS).
|
||||
// Floor in % of cap_final (cap_final = all subblocks × 25).
|
||||
// green=Beginner 20% · blue=Advanced 40% · purple=Expert 60% · gold=Master 100%.
|
||||
export const LEVELS = [
|
||||
{ key: 'beginner', label: 'Beginner', kurz: '✓', floor: 0.2, farbe: 'var(--level-beginner)' },
|
||||
{ key: 'advanced', label: 'Advanced', kurz: '✓✓', floor: 0.4, farbe: 'var(--level-advanced)' },
|
||||
{ key: 'expert', label: 'Expert', kurz: '✓✓✓', floor: 0.6, farbe: 'var(--level-expert)' },
|
||||
{ key: 'master', label: 'Master', kurz: '★', floor: 1.0, farbe: 'var(--level-master)' },
|
||||
]
|
||||
|
||||
export function schwelle(floor, cap) {
|
||||
return Math.round(floor * cap)
|
||||
}
|
||||
|
||||
// Highest reached level (object) or null (below Beginner, <20%).
|
||||
export function stufeFuer(score, cap) {
|
||||
let s = null
|
||||
for (const st of LEVELS) if (score >= schwelle(st.floor, cap)) s = st
|
||||
return s
|
||||
}
|
||||
|
||||
// Next not-yet-reached level (the goal) or null (= Master).
|
||||
export function naechste(score, cap) {
|
||||
for (const st of LEVELS) if (score < schwelle(st.floor, cap)) return st
|
||||
return null
|
||||
}
|
||||
|
||||
// Error-penalty display by progress (against cap_aktuell): ≤25%→−5 · ≤50%→−10 · ≤75%→−15 · >75%→−20.
|
||||
export function malusRegel(score, cap) {
|
||||
const pct = cap ? score / cap : 0
|
||||
if (pct <= 0.25) return '−5'
|
||||
if (pct <= 0.5) return '−10'
|
||||
if (pct <= 0.75) return '−15'
|
||||
return '−20'
|
||||
}
|
||||
@@ -17,9 +17,9 @@ marked.use(markedHighlight({
|
||||
}))
|
||||
marked.setOptions({ breaks: true, gfm: true })
|
||||
|
||||
// LaTeX-Mathe via KaTeX. Eigene marked-Extensions (statt marked-katex-extension,
|
||||
// die marked v18 hinterherhinkt). marked tokenisiert Code zuerst → $…$ in Code-
|
||||
// Blöcken wird NICHT als Mathe erkannt. throwOnError:false zeigt defektes TeX rot.
|
||||
// LaTeX math via KaTeX. Own marked extensions (instead of marked-katex-extension,
|
||||
// which lags behind marked v18). marked tokenizes code first → $…$ inside code
|
||||
// blocks is NOT treated as math. throwOnError:false renders broken TeX in red.
|
||||
function renderTex(tex, displayMode) {
|
||||
return katex.renderToString(tex, { displayMode, throwOnError: false, output: 'html' })
|
||||
}
|
||||
@@ -40,8 +40,8 @@ const inlineMath = {
|
||||
level: 'inline',
|
||||
start(src) { const i = src.indexOf('$'); return i < 0 ? undefined : i },
|
||||
tokenizer(src) {
|
||||
// $…$: kein $$, kein Leerzeichen direkt hinter dem öffnenden $ und vor dem
|
||||
// schließenden $ (pandoc-Stil) → mindert Kollisionen mit Fließtext-Dollarzeichen.
|
||||
// $…$: no $$, no space right after the opening $ or before the closing $
|
||||
// (pandoc style) → reduces collisions with dollar signs in prose.
|
||||
const m = /^\$(?![\s$])((?:\\\$|[^$])+?)\$/.exec(src)
|
||||
if (!m || /\s$/.test(m[1])) return
|
||||
return { type: 'inlineMath', raw: m[0], text: m[1].trim() }
|
||||
@@ -51,8 +51,8 @@ const inlineMath = {
|
||||
|
||||
marked.use({ extensions: [blockMath, inlineMath] })
|
||||
|
||||
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
|
||||
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
|
||||
// Raw HTML in markdown (e.g. <p>, <img> without backticks from agent output)
|
||||
// shown as text instead of rendered — otherwise the browser swallows the content.
|
||||
marked.use({
|
||||
renderer: {
|
||||
html(token) {
|
||||
@@ -66,15 +66,20 @@ export function renderMarkdown(text) {
|
||||
return DOMPurify.sanitize(marked.parse(text || ''))
|
||||
}
|
||||
|
||||
// Inline-Variante (kein <p>-Wrapping) für kurze Texte wie Quiz-Optionen oder
|
||||
// Lückentext-Satzteile — rendert $…$-Mathe und Markdown ohne Block-Umbruch.
|
||||
// Inline variant (no <p> wrapping) for short texts like quiz options or
|
||||
// gap-text sentence fragments — renders $…$ math and markdown without a block break.
|
||||
export function renderMarkdownInline(text) {
|
||||
return DOMPurify.sanitize(marked.parseInline(text || ''))
|
||||
}
|
||||
|
||||
// Markdown in Top-Level-Blöcke zerlegen: je Block { raw (exakte Quelle), html }.
|
||||
// raw ist verlustfrei (tokens.map(raw).join('') === Original) → Block-genaues Ersetzen.
|
||||
export function renderBloecke(text) {
|
||||
// Strip markdown to plain text (code fences + inline marks) — for previews/search.
|
||||
export function plainText(text) {
|
||||
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
|
||||
}
|
||||
|
||||
// Split markdown into top-level blocks: each block { raw (exact source), html }.
|
||||
// raw is lossless (tokens.map(raw).join('') === original) → block-precise replacement.
|
||||
export function renderBlocks(text) {
|
||||
const tokens = marked.lexer(text || '')
|
||||
return tokens
|
||||
.filter((t) => t.type !== 'space' && (t.raw || '').trim())
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Geteilter Prüfungs-State pro Baustein.
|
||||
// Die durablen Refs leben hier als Modul-Map: alle BausteinPanel-Instanzen desselben
|
||||
// Bausteins nutzen DIESELBEN Refs. So überlebt der Verlauf das Remounten (Baustein-
|
||||
// Wechsel, Fokus zu/auf) UND eine spät eintreffende Frage ist sofort reaktiv sichtbar.
|
||||
// Geht bei Page-Reload verloren (Modul re-init) — bewusst kein DB-Aufwand.
|
||||
// Shared exam state per block.
|
||||
// The durable refs live here as a module map: all BlockPanel instances of the same
|
||||
// block use the SAME refs. So the history survives remounting (block switch, focus
|
||||
// open/close) AND a late-arriving question is immediately reactive and visible.
|
||||
// Lost on page reload (module re-init) — deliberately no DB overhead.
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
const store = new Map() // key "topic::baustein" -> Refs (Verlauf + Fragen-Pool)
|
||||
const store = new Map() // key "topic::block" -> refs (history + questions pool)
|
||||
|
||||
export function usePruefSlot(key) {
|
||||
if (!store.has(key)) {
|
||||
@@ -15,14 +15,14 @@ export function usePruefSlot(key) {
|
||||
phase: ref('idle'),
|
||||
aktuelleFrage: ref(''),
|
||||
letztesFeedback: ref(''),
|
||||
pool: ref([]), // vorformulierte Frage-Objekte der AKTUELLEN Form ({form, ...})
|
||||
inflight: ref(0), // laufende Pool-Generierungen (instanzübergreifend koordiniert)
|
||||
poolForm: ref(''), // Form, für die der Pool gefüllt ist — bei Wechsel leeren
|
||||
musterQuelle: ref([]), // unveränderliche Voll-Liste der Frage-Muster (leer = Fallback)
|
||||
musterPool: ref([]), // Arbeitskopie: gezogene Saat ohne Zurücklegen; leer → Reset
|
||||
musterGeladen: ref(false), // Muster-Sidecar schon abgerufen?
|
||||
quizAktuell: ref(null), // laufende Quiz-Frage {frage, optionen, gewaehlt, fertig, ...}
|
||||
lueckAktuell: ref(null), // laufende Lückentext-Aufgabe {satz, loesung, ..., fertig}
|
||||
pool: ref([]), // pre-built question objects of the CURRENT form ({form, ...})
|
||||
inflight: ref(0), // running pool generations (coordinated across instances)
|
||||
poolForm: ref(''), // form the pool is filled for — clear on switch
|
||||
musterQuelle: ref([]), // immutable full list of question patterns (empty = fallback)
|
||||
musterPool: ref([]), // working copy: drawn seed without replacement; empty → reset
|
||||
musterGeladen: ref(false), // pattern sidecar already fetched?
|
||||
quizAktuell: ref(null), // running quiz question {question, options, gewaehlt, done, ...}
|
||||
lueckAktuell: ref(null), // running gap-text task {sentence, solution, ..., done}
|
||||
})
|
||||
}
|
||||
return store.get(key)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Lernstufen je Baustein — identisch zum Backend (lernen.py STUFEN).
|
||||
// Floor in % des cap_final (cap_final = alle Subbausteine × 25).
|
||||
// grün=Anfänger 20% · blau=Fortgeschritten 40% · lila=Experte 60% · gold=Meister 100%.
|
||||
export const STUFEN = [
|
||||
{ key: 'anfaenger', label: 'Anfänger', kurz: '✓', floor: 0.2, farbe: 'var(--stufe-anfaenger)' },
|
||||
{ key: 'fortgeschritten', label: 'Fortgeschritten', kurz: '✓✓', floor: 0.4, farbe: 'var(--stufe-fortgeschritten)' },
|
||||
{ key: 'experte', label: 'Experte', kurz: '✓✓✓', floor: 0.6, farbe: 'var(--stufe-experte)' },
|
||||
{ key: 'meister', label: 'Meister', kurz: '★', floor: 1.0, farbe: 'var(--stufe-meister)' },
|
||||
]
|
||||
|
||||
export function schwelle(floor, cap) {
|
||||
return Math.round(floor * cap)
|
||||
}
|
||||
|
||||
// Höchste erreichte Stufe (Objekt) oder null (unter Anfänger, <20%).
|
||||
export function stufeFuer(score, cap) {
|
||||
let s = null
|
||||
for (const st of STUFEN) if (score >= schwelle(st.floor, cap)) s = st
|
||||
return s
|
||||
}
|
||||
|
||||
// Nächste noch nicht erreichte Stufe (für das Ziel) oder null (= Meister).
|
||||
export function naechste(score, cap) {
|
||||
for (const st of STUFEN) if (score < schwelle(st.floor, cap)) return st
|
||||
return null
|
||||
}
|
||||
|
||||
// Fehler-Strafe-Anzeige nach Fortschritt (gegen cap_aktuell): ≤25%→−5 · ≤50%→−10 · ≤75%→−15 · >75%→−20.
|
||||
export function malusRegel(score, cap) {
|
||||
const pct = cap ? score / cap : 0
|
||||
if (pct <= 0.25) return '−5'
|
||||
if (pct <= 0.5) return '−10'
|
||||
if (pct <= 0.75) return '−15'
|
||||
return '−20'
|
||||
}
|
||||
Reference in New Issue
Block a user