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

View File

@@ -1,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 (15) — Anzeige + anklickbar für Re-Run ab hier. */
/* Coarse phases as numbered pills (15) — 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;