Files
creator/frontend/src/components/TopicSidebar.vue
2026-06-27 00:58:46 +02:00

1173 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, reactive, computed } from 'vue'
import { useConfirm } from '../composables/useConfirm.js'
import { fetchQuelle } from '../api.js'
const props = defineProps({
topics: { type: Array, required: true },
selectedTopic: { type: String, default: null },
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)
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 }) },
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
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'])
// Accordion: höchstens ein Panel offen. IDs: 'bausteine', 'fmt-<Format>', 'topic-<Name>'.
const openPanel = ref(null)
const isOpen = (id) => openPanel.value === id
function togglePanel(id) {
openPanel.value = openPanel.value === id ? null : id
}
function providerAvailable(id) {
const p = props.providers.find((x) => x.id === id)
return p ? p.available : true
}
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Lokal' }
// Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert
const trackerItems = computed(() => {
if (!props.stats) return []
const f = props.stats.formate || {}
const fmt = (k) => `${f[k]?.absolviert ?? 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' },
]
})
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)' },
]
const bausteineState = computed(() => {
if (props.bausteine.generating) return 'generating'
return props.bausteine.ready ? 'done' : 'none'
})
// Re-Run ab gewählter Phase (1-basiert). null = ab Anfang / Fortsetzen.
const gewaehltePhase = ref(null)
// Phasen anklickbar, sobald etwas (teil-)gebaut ist und gerade nicht generiert wird.
const phasenWaehlbar = computed(() => (props.bausteine.ready || props.bausteine.partial) && !props.bausteine.generating)
const gewaehltesLabel = computed(() => (props.bausteine.steps || [])[(gewaehltePhase.value || 0) - 1]?.label || '')
function phaseKlick(n) {
if (!phasenWaehlbar.value) return
gewaehltePhase.value = gewaehltePhase.value === n ? null : n
}
// Nur FREMDE Themen — das gewählte Thema zeigt seinen Fortschritt inline an der Zeile
const activeGenerations = computed(() => {
const bausteinLines = props.activeBausteine
.filter((b) => b.topic !== props.selectedTopic)
.map((b) => `${b.topic} Bausteine: ${b.progress || 'Wartend…'}`)
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]
})
const { pending: pendingConfirm, armOrRun } = useConfirm()
function confirmCancelBausteine() {
armOrRun('bausteine', () => emit('cancelBausteine'))
}
function confirmResetBausteine() {
armOrRun('bausteine', () => emit('resetBausteine'))
}
function handleBausteinePlay() {
if (bausteineState.value === 'generating') return
// Nur „Neu generieren" (ready) nutzt eine Phase; Fortsetzen/Erstbau resumen ohne Löschen.
const abPhase = props.bausteine.ready ? (gewaehltePhase.value ?? 1) : null
emit('bausteineClick', { instructions: '', abPhase })
gewaehltePhase.value = null
}
// 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')
}
function guideStatus(format) {
// Laufende Generierung hat Vorrang — sonst maskiert ein älterer fertiger
// Guide den Lauf und ▶ würde Duplikate starten.
const latest = props.latestByFormat[format]
if (latest && (latest.status === 'generating' || latest.status === 'queued')) return latest.status
if (props.doneByFormat[format]) return 'done'
if (!latest || latest.status === 'error') return 'none'
return latest.status
}
// Schritt-Kugeln der Guide-Pipeline
const GUIDE_STEPS = ['Gliederung', 'Inhalte', 'Inhalts-Check', 'Schreiben', 'Lese-Prüfung']
// 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.
function guideSteps(format) {
const labels = GUIDE_STEPS
const fertig = props.guideStepsDone[format] ?? -1
const st = guideStatus(format)
const aktiv = st === 'generating' || st === 'queued' ? fertig + 1 : -1
return labels.map((label, i) => ({
label,
state: i <= fertig ? 'done' : i === aktiv ? '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) {
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 gewaehltesStepLabel(format) {
return GUIDE_STEPS[(gewaehlterStep[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'
}
// Abgebrochener Lauf = Teilfortschritt vorhanden: ▶ setzt fort, ✕ löscht den Fortschritt
function abgebrochen(format) {
const latest = props.latestByFormat[format]
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Abgebrochen')
}
// Name-Klick: fertiger Guide → Vorschau, sonst Aktions-Panel auf/zu.
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).
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
emit('formatClick', { format, instructions: '', abStep })
gewaehlterStep[format] = null
}
// Flash-Message-Verhalten: × blendet nur aus, nichts wird gelöscht
function dismissError(format) {
const latest = props.latestByFormat[format]
if (latest?.status === 'error') emit('dismissError', latest.id)
}
function handleDelete(format) {
if (!props.latestByFormat[format]) return
armOrRun('fmt-' + format, () => {
// Alle laufenden Generierungen des Formats abbrechen (deckt auch Duplikate ab)
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)
emit('deleteGuide', props.latestByFormat[format].id, true)
} else {
emit('deleteGuide', props.latestByFormat[format].id)
}
})
}
// Erstellen-Bereich: inline aufklappbar (Name + weitere Infos + Quellen-Typ).
const dlg = ref(false)
const form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' })
const canCreate = computed(() => {
if (!form.value.name.trim()) return false
if (['link', 'projekt', 'uni'].includes(form.value.sourceType)) return !!form.value.sourceOrt.trim()
return true
})
function toggleErstellen() {
if (!dlg.value) form.value = { name: '', instructions: '', sourceType: 'thema', sourceOrt: '' }
dlg.value = !dlg.value
}
function createThema() {
if (!canCreate.value) return
emit('createThema', {
topic: form.value.name.trim(),
instructions: form.value.instructions.trim(),
sourceType: form.value.sourceType,
sourceOrt: form.value.sourceOrt.trim(),
})
dlg.value = false
}
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) ---
const editTopic = ref(null)
const editForm = ref({ type: 'thema', ort: '', spec: '' })
const editLoading = ref(false)
const canSave = computed(() => {
if (['link', 'projekt', 'uni'].includes(editForm.value.type)) return !!editForm.value.ort.trim()
return true
})
async function toggleTopicPanel(t) {
emit('select', t)
if (openPanel.value === 'topic-' + t) { openPanel.value = null; return }
openPanel.value = 'topic-' + 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 || '' }
} catch {
editForm.value = { type: 'thema', ort: '', spec: '' }
} finally {
editLoading.value = false
}
}
function setEditType(t) {
editForm.value.type = t
editForm.value.ort = ''
}
function saveQuelle() {
if (!canSave.value || !editTopic.value) return
emit('updateQuelle', {
topic: editTopic.value,
type: editForm.value.type,
ort: editForm.value.ort.trim(),
spec: editForm.value.spec.trim(),
})
openPanel.value = null
}
</script>
<template>
<aside class="sidebar" @mouseleave="emit('sidebarLeave')">
<div class="stats-bar" v-if="trackerItems.length">
<div class="stat" v-for="item in trackerItems" :key="item.label" :title="item.title">
<span class="stat-value">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
<div class="new-topic">
<button
class="pin-btn"
:title="pinned ? 'Sidebar ausblenden' : 'Sidebar fixieren'"
@click="emit('togglePin')"
>{{ pinned ? '⇤' : '⇥' }}</button>
<button
class="theme-btn"
:title="dark ? 'Hellmodus' : 'Dunkelmodus'"
@click="emit('toggleDark')"
>{{ dark ? '☀' : '🌙' }}</button>
<div v-if="selectedTopic" class="ansicht-toggle">
<button :class="{ active: ansichtModus === 'kompakt' }" title="Kurzer Text" @click="emit('setAnsicht', 'kompakt')">
<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')">
<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" />
<rect x="2" y="9.6" width="12" height="1.4" rx="0.7" />
<rect x="2" y="12.9" width="8" height="1.4" rx="0.7" />
</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"
@click="emit('setStufe', s.k)">{{ s.label }}</button>
</div>
<button class="new-topic-toggle" :class="{ active: dlg }" title="Thema erstellen" @click="toggleErstellen">+</button>
</div>
<!-- Erstellen: inline aufklappbar (kein 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>
<div class="dlg-sources">
<button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Thema</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 === '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"
/>
<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>
</select>
<div class="dlg-actions">
<button class="dlg-cancel" @click="dlg = false">Abbrechen</button>
<button class="dlg-create" :disabled="!canCreate" @click="createThema">Erstellen</button>
</div>
</div>
<div class="provider-toggle" v-if="providers.length">
<button
v-for="p in providers"
:key="p.id"
:class="{ active: p.id === provider }"
:disabled="!p.available"
:title="p.available ? '' : 'Nicht konfiguriert (CLI/Key fehlt)'"
@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>
</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="format-row bausteine-row"
:class="{ 'is-active': bausteineState === 'generating' || bausteine.partial, 'row-open': isOpen('bausteine') }"
>
<button class="format-name bausteine-name" @click="onBausteineName">
<span class="format-label">Bausteine</span>
<span
v-if="bausteine.partial && bausteineState !== 'generating'"
class="resume-badge"
title="Abgebrochen — Fortsetzen möglich"
>Pausiert</span>
<span class="step-dots">
<span
v-for="(s, i) in (bausteine.steps || [])"
:key="s.label"
class="step-pill"
:class="[s.state, { sel: gewaehltePhase === i + 1, klickbar: phasenWaehlbar }]"
:title="(s.state === 'active' ? (bausteine.progress || s.label) : s.label) + (phasenWaehlbar ? ' — Klick: ab hier neu' : '')"
@click.stop="phaseKlick(i + 1)"
>{{ i + 1 }}</span>
</span>
</button>
<button class="panel-toggle" :class="{ open: isOpen('bausteine') }" title="Aktionen" @click.stop="togglePanel('bausteine')"></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>
</template>
<template v-else>
<button class="panel-btn play" @click="handleBausteinePlay">{{ bausteine.partial ? 'Fortsetzen' : bausteine.ready ? (gewaehltePhase ? `Ab «${gewaehltesLabel}» neu` : 'Neu generieren') : 'Generieren' }}</button>
<button
v-if="bausteine.ready || bausteine.partial"
class="panel-btn danger"
:class="{ armed: pendingConfirm === 'bausteine' }"
@click="confirmResetBausteine"
>{{ pendingConfirm === 'bausteine' ? 'Sicher?' : 'Entfernen' }}</button>
</template>
</div>
<div v-if="bausteineState === 'generating'" class="format-progress">
{{ bausteine.progress || 'Wartend' }}
</div>
<div v-if="bausteine.error && !bausteine.error.startsWith('Abgebrochen')" class="format-error">
<span class="format-error-text">{{ bausteine.error }}</span>
</div>
</div>
<!-- Formate stehen per CSS-order nach der Bausteine-Zeile (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) }]">
<button class="format-name" @click="handleFormatClick(f.key)">
<span class="format-label">{{ f.label }}</span>
<span
v-if="abgebrochen(f.key)"
class="resume-badge"
title="Abgebrochen — Fortsetzen möglich"
>Pausiert</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)"
>{{ i + 1 }}</span>
</span>
</button>
<button class="panel-toggle" :class="{ open: isOpen('fmt-' + f.key) }" title="Aktionen" @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>
</template>
<template v-else>
<button
class="panel-btn play"
:title="playLock(f.key) || (abgebrochen(f.key) ? 'Fortsetzen' : 'Generieren')"
: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>
<button
v-if="guideStatus(f.key) !== 'none' || abgebrochen(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>
</template>
</div>
<div
v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"
class="format-progress"
>{{ latestByFormat[f.key]?.progress || 'Wartend…' }}</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>
</div>
</div>
<div class="format-row ord-pruefung">
<button class="format-name elements-btn" @click="emit('generalExam')">
<span class="format-label">Allgemeine Prüfung</span>
</button>
</div>
<div class="format-row ord-elemente">
<button class="format-name elements-btn" @click="emit('openElements')">
<span class="format-label">Elemente</span>
</button>
</div>
</div>
<ul class="topic-list">
<li
v-for="t in topics"
:key="t"
:class="{ active: t === selectedTopic, 'li-open': isOpen('topic-' + t) }"
>
<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>
</div>
<div v-if="isOpen('topic-' + t)" class="thema-panel edit-panel" @click.stop>
<p v-if="editLoading" class="dlg-hint">Lade</p>
<template v-else>
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="Weitere Infos (optional)…"></textarea>
<div class="dlg-sources">
<button :class="{ active: editForm.type === 'thema' }" @click="setEditType('thema')">Thema</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 === 'uni' }" @click="setEditType('uni')">Uni</button>
</div>
<input
v-if="editForm.type === 'link'"
class="dlg-input" v-model="editForm.ort"
placeholder="https://…"
/>
<select
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>
</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>
</div>
</template>
</div>
</li>
</ul>
</aside>
</template>
<style scoped>
.sidebar {
width: 300px;
min-width: 300px;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100dvh;
}
.new-topic {
display: flex;
gap: 4px;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
}
.new-topic input {
flex: 1;
min-width: 0;
padding: 6px 8px;
border: 1px solid var(--border-strong);
border-radius: 6px;
font-size: 0.85rem;
outline: none;
}
.new-topic input:focus {
border-color: var(--accent);
}
.new-topic button {
padding: 6px 10px;
border: none;
background: var(--accent);
color: var(--on-accent);
border-radius: 6px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
}
.new-topic button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.new-topic .pin-btn {
background: var(--bg);
color: var(--text-muted);
border: 1px solid var(--border-strong);
font-weight: 600;
padding: 6px 8px;
}
.new-topic .pin-btn:hover {
background: var(--accent-soft);
color: var(--accent-hover);
border-color: var(--accent-border);
}
.new-topic .theme-btn {
background: var(--bg);
color: var(--text-muted);
border: 1px solid var(--border-strong);
font-weight: 600;
padding: 6px 8px;
}
.new-topic .theme-btn:hover {
background: var(--accent-soft);
color: var(--accent-hover);
border-color: var(--accent-border);
}
/* Ansicht-Umschalter: zwei Icon-Buttons (kurzer / langer Text) */
.ansicht-toggle { display: inline-flex; }
.new-topic .ansicht-toggle button {
padding: 4px 7px;
background: var(--bg);
color: var(--text-muted);
border: 1px solid var(--border-strong);
display: inline-flex;
align-items: center;
}
.new-topic .ansicht-toggle button:first-child { border-radius: 6px 0 0 6px; }
.new-topic .ansicht-toggle button:last-child { border-radius: 0 6px 6px 0; border-left: none; }
.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 {
padding: 4px 6px;
background: var(--bg);
color: var(--text-muted);
border: 1px solid var(--border-strong);
font-size: 11px;
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); }
.stats-bar {
display: flex;
padding: 0.5rem 0.75rem;
gap: 4px;
border-bottom: 1px solid var(--border);
}
.stat {
position: relative;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 7px 2px 4px;
background: var(--panel-soft);
border: 1px solid var(--border);
border-radius: 6px;
cursor: default;
}
.stat-value {
font-size: 0.8rem;
font-weight: 700;
color: var(--text);
}
/* Mini-Titel sitzt auf der oberen Kante des Badges (Legenden-Look) */
.stat-label {
position: absolute;
top: -0.42rem;
left: 50%;
transform: translateX(-50%);
padding: 0 3px;
font-size: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-faint);
background: var(--panel);
white-space: nowrap;
}
.provider-toggle {
display: flex;
gap: 0;
padding: 0.5rem 0.75rem 0;
}
.provider-toggle button {
flex: 1;
padding: 5px 8px;
font-size: 0.78rem;
font-weight: 600;
border: 1px solid var(--border-strong);
background: var(--bg);
color: var(--text-muted);
cursor: pointer;
}
.provider-toggle button:first-child {
border-radius: 6px 0 0 6px;
}
.provider-toggle button:last-child {
border-radius: 0 6px 6px 0;
border-left: none;
}
.provider-toggle button.active {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
.provider-toggle button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.topic-list {
list-style: none;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.5rem 0;
border-top: 1px solid var(--border);
}
.topic-list li {
font-size: 0.9rem;
color: var(--text);
}
.topic-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 0.6rem 1rem;
cursor: pointer;
transition: background 0.15s;
}
.topic-row:hover {
background: var(--accent-soft);
}
.topic-list li.active .topic-row {
background: var(--accent-soft);
color: var(--accent-hover);
font-weight: 600;
}
.topic-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Format section */
.bausteine-name {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.step-dots {
display: inline-flex;
gap: 5px;
flex: 1;
}
/* Grobe Phasen als nummerierte Pillen (15) — Anzeige + anklickbar für Re-Run ab hier. */
.step-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
border-radius: 50%;
background: var(--border-strong);
color: var(--bg);
font-size: 0.62rem;
font-weight: 700;
line-height: 1;
flex-shrink: 0;
border: 1.5px solid transparent;
}
.step-pill.done {
background: var(--success-border);
}
.step-pill.active {
background: var(--warning-border);
animation: dot-pulse 1.2s ease-in-out infinite;
}
.step-pill.klickbar {
cursor: pointer;
}
.step-pill.sel {
border-color: var(--text);
box-shadow: 0 0 0 1px var(--text);
}
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.format-section {
flex-shrink: 0;
max-height: 60vh;
overflow-y: auto;
padding: 0.5rem 0;
/* flex + order: Bausteine (order 2) vor den Formaten (order 3) */
display: flex;
flex-direction: column;
}
.ord-bausteine {
order: 2;
}
.ord-pruefung {
order: 4;
}
.ord-elemente {
order: 5;
}
.elements-btn {
cursor: pointer;
color: var(--text);
}
.elements-btn:hover {
background: var(--panel-soft);
}
.progress-info {
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
color: var(--warning);
background: var(--warning-soft);
margin-bottom: 0.25rem;
animation: pulse 1.5s ease-in-out infinite;
}
/* Abgewiesene Aktion (409/400) — oberhalb aller Format-Zeilen */
.ui-error {
order: 0;
padding: 0.4rem 0.75rem;
background: var(--warning-soft);
}
/* Fortschritts-Text direkt unter der laufenden Format-Zeile */
.format-progress {
padding: 0 0.75rem 5px calc(0.75rem + 8px);
font-size: 0.72rem;
color: var(--warning);
line-height: 1.3;
animation: pulse 1.5s ease-in-out infinite;
}
.resume-badge {
flex: 0 0 auto;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--warning);
background: var(--warning-soft);
border: 1px solid var(--warning-border);
border-radius: 4px;
padding: 1px 5px;
}
.format-row {
display: flex;
align-items: center;
padding: 0.4rem 0.75rem;
transition: background 0.15s;
}
.format-row:hover {
background: var(--panel-soft);
}
.format-name {
flex: 1;
min-width: 0;
background: none;
border: none;
text-align: left;
font-size: 0.85rem;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
color: var(--text-faint);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Accordion: Pfeil-Toggle + aufklappendes Aktions-Panel */
.panel-toggle {
flex: 0 0 auto;
background: none;
border: none;
cursor: pointer;
color: var(--text-faint);
font-size: 0.8rem;
line-height: 1;
padding: 4px 8px;
transition: transform 0.15s, color 0.15s;
}
.panel-toggle.open { transform: rotate(180deg); color: var(--text); }
.panel-toggle:hover { color: var(--accent); }
.format-row.row-open,
.topic-list li.li-open .topic-row { background: var(--panel-soft); }
.action-panel {
display: flex;
gap: 0.5rem;
padding: 0.15rem 0.75rem 0.55rem calc(0.75rem + 8px);
}
.panel-btn {
flex: 1;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.panel-btn:hover { border-color: var(--accent); }
.panel-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.panel-btn:disabled:hover { border-color: var(--border-strong); }
.panel-btn.play {
color: var(--success);
background: var(--success-soft);
border-color: var(--success-border);
}
.panel-btn.play:hover { background: var(--success-soft-hover); }
.panel-btn.danger { color: var(--danger); }
.panel-btn.danger:hover { border-color: var(--danger); }
.panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); }
.format-x {
display: none;
color: var(--danger);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.format-name:hover .format-x {
display: inline;
}
/* Laufend/pausiert: × immer zeigen — Hover gibt es auf Touch nicht */
.fmt-generating .format-x,
.fmt-queued .format-x,
.fmt-paused .format-x,
.bausteine-row.is-active .format-x {
display: inline;
}
.format-x.armed,
.format-error-x.armed,
.delete-topic.armed {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
background: var(--danger);
color: #fff;
border-radius: 4px;
padding: 2px 6px;
}
.fmt-done .format-name {
color: var(--success);
font-weight: 600;
cursor: pointer;
background: var(--success-soft);
border: 1px solid var(--success-border);
}
.fmt-done .format-name:hover {
background: var(--success-soft-hover);
}
.fmt-generating .format-name,
.fmt-queued .format-name {
color: var(--warning);
background: var(--warning-soft);
border: 1px solid var(--warning-border);
animation: pulse 1.5s ease-in-out infinite;
}
.format-error {
display: flex;
align-items: flex-start;
gap: 4px;
padding: 2px 0.75rem 6px calc(0.75rem + 8px);
font-size: 0.72rem;
color: var(--danger);
line-height: 1.3;
}
.format-error-text {
flex: 1;
word-break: break-word;
}
.format-error-x {
flex: 0 0 auto;
background: none;
border: none;
color: var(--danger);
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
opacity: 0.6;
}
.format-error-x:hover {
opacity: 1;
}
.format-actions {
display: flex;
gap: 2px;
margin-left: 6px;
}
.action-btn {
background: none;
border: 1px solid transparent;
border-radius: 4px;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.15s;
}
.action-btn.play {
color: var(--success);
}
.action-btn.play:hover {
background: var(--success-soft);
border-color: var(--success-border);
}
.action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.action-btn:disabled:hover {
background: none;
border-color: transparent;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
/* Erstellen — inline aufklappbar (kein Modal) */
.new-topic-toggle {
margin-left: auto; /* rechtsbündig statt volle Breite */
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 6px;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
font: inherit;
font-size: 1.1rem;
line-height: 1;
text-align: center;
}
.new-topic-toggle:hover { background: var(--accent-hover); }
.new-topic-toggle.active { background: var(--accent-hover); }
.thema-panel {
margin: 0.4rem 0;
padding: 0.5rem;
background: var(--panel-soft);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
}
.dlg-input, .dlg-textarea {
width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.dlg-input:focus, .dlg-textarea:focus { outline: none; border-color: var(--accent); }
.dlg-textarea { resize: vertical; min-height: 2rem; }
.dlg-sources { display: flex; gap: 0.3rem; }
.dlg-sources button {
flex: 1;
padding: 0.35rem 0.2rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
}
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.dlg-hint { margin: 0; font-size: 0.75rem; color: var(--text-faint); }
.dlg-actions { display: flex; justify-content: flex-end; gap: 0.4rem; margin-top: 0.1rem; }
.dlg-actions button {
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
}
.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 */
.edit-panel {
margin: 0;
padding: 0.5rem 1rem 0.7rem 1.25rem;
background: var(--accent-soft);
border: none;
border-left: 3px solid var(--accent-border);
border-radius: 0;
}
.dlg-delete { margin-right: auto; color: var(--danger); }
.dlg-delete.armed { background: var(--danger); border-color: var(--danger); color: #fff; font-weight: 700; }
select.dlg-input { cursor: pointer; }
</style>