857 lines
23 KiB
Vue
857 lines
23 KiB
Vue
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
import { useConfirm } from '../composables/useConfirm.js'
|
||
|
||
const props = defineProps({
|
||
topics: { type: Array, required: true },
|
||
projects: { type: Array, default: () => [] },
|
||
selectedTopic: { type: String, default: null },
|
||
stats: { type: Object, default: null },
|
||
fortschritt: { type: Object, default: () => ({}) },
|
||
locks: { type: Object, default: () => ({}) },
|
||
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: () => [] },
|
||
})
|
||
|
||
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
|
||
|
||
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: 'MiniGuides', value: fmt('MiniGuide'), title: 'absolviert/erstellt' },
|
||
{ label: 'Guides', value: fmt('Guide'), title: 'absolviert/erstellt' },
|
||
{ label: 'FullGuides', value: fmt('FullGuide'), title: 'absolviert/erstellt' },
|
||
]
|
||
})
|
||
|
||
const formats = [
|
||
{ key: 'OnePager', label: 'OnePager' },
|
||
{ key: 'MiniGuide', label: 'MiniGuide' },
|
||
{ key: 'Guide', label: 'Guide' },
|
||
{ key: 'FullGuide', label: 'FullGuide' },
|
||
]
|
||
|
||
const bausteineState = computed(() => {
|
||
if (props.bausteine.generating) return 'generating'
|
||
return props.bausteine.ready ? 'done' : 'none'
|
||
})
|
||
|
||
// 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
|
||
emit('bausteineClick', { instructions: '' })
|
||
}
|
||
|
||
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-Pipelines
|
||
const GUIDE_STEPS = ['Auswahl', 'Gliederung', 'Schreiben', 'Lese-Prüfung']
|
||
const ONEPAGER_STEPS = ['Recherche', 'Bauen', 'Prüfung']
|
||
|
||
// Kugeln werden wie bei den Bausteinen immer angezeigt:
|
||
// fertig = alle grün, laufend = live, abgebrochen = Teilfortschritt, sonst grau
|
||
function guideSteps(format) {
|
||
const labels = format === 'OnePager' ? ONEPAGER_STEPS : GUIDE_STEPS
|
||
const st = guideStatus(format)
|
||
if (st === 'generating' || st === 'queued') {
|
||
// Clamp: alte DB-Läufe können step-Werte oberhalb der neuen Listen haben
|
||
const step = Math.min(props.latestByFormat[format]?.step ?? -1, labels.length - 1)
|
||
return labels.map((label, i) => ({
|
||
label,
|
||
state: i < step ? 'done' : i === step ? 'active' : 'pending',
|
||
}))
|
||
}
|
||
if (props.doneByFormat[format]) {
|
||
return labels.map((label) => ({ label, state: 'done' }))
|
||
}
|
||
if (abgebrochen(format)) {
|
||
const step = Math.min(props.latestByFormat[format]?.step ?? 0, labels.length)
|
||
return labels.map((label, i) => ({ label, state: i < step ? 'done' : 'pending' }))
|
||
}
|
||
return labels.map((label) => ({ label, state: 'pending' }))
|
||
}
|
||
|
||
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')
|
||
}
|
||
|
||
function handleFormatClick(format) {
|
||
const guide = props.doneByFormat[format]
|
||
if (guide) {
|
||
emit('preview', guide)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
emit('formatClick', { format, instructions: '' })
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
})
|
||
}
|
||
|
||
const newTopic = ref('')
|
||
|
||
function submit() {
|
||
const t = newTopic.value.trim()
|
||
if (!t) return
|
||
emit('create', t)
|
||
newTopic.value = ''
|
||
}
|
||
|
||
function confirmDeleteTopic(topic) {
|
||
armOrRun('topic-' + topic, () => emit('deleteTopic', topic))
|
||
}
|
||
|
||
function confirmDeleteProject(name) {
|
||
armOrRun('project-' + name, () => emit('deleteProject', name))
|
||
}
|
||
</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>
|
||
<input
|
||
v-model="newTopic"
|
||
placeholder="Neues Thema…"
|
||
@keyup.enter="submit"
|
||
/>
|
||
<button @click="submit" :disabled="!newTopic.trim()">+</button>
|
||
</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="format-row bausteine-row ord-bausteine"
|
||
:class="{ 'is-active': bausteineState === 'generating' || bausteine.partial }"
|
||
>
|
||
<div class="format-name bausteine-name">
|
||
<span class="format-label">Bausteine</span>
|
||
<span
|
||
v-if="bausteine.partial && bausteineState !== 'generating'"
|
||
class="resume-badge"
|
||
title="Abgebrochen — ▶ setzt fort"
|
||
>Pausiert</span>
|
||
<span class="step-dots">
|
||
<span
|
||
v-for="s in (bausteine.steps || [])"
|
||
:key="s.label"
|
||
class="step-dot"
|
||
:class="s.state"
|
||
:title="s.state === 'active' ? (bausteine.progress || s.label) : s.label"
|
||
></span>
|
||
</span>
|
||
<span
|
||
v-if="bausteineState === 'generating'"
|
||
class="format-x"
|
||
:class="{ armed: pendingConfirm === 'bausteine' }"
|
||
title="Aktuellen Schritt abbrechen (Fortschritt bleibt)"
|
||
@click.stop="confirmCancelBausteine"
|
||
>{{ pendingConfirm === 'bausteine' ? 'Sicher?' : '×' }}</span>
|
||
<span
|
||
v-else-if="bausteine.partial"
|
||
class="format-x"
|
||
:class="{ armed: pendingConfirm === 'bausteine' }"
|
||
title="Fortschritt löschen"
|
||
@click.stop="confirmResetBausteine"
|
||
>{{ pendingConfirm === 'bausteine' ? 'Sicher?' : '×' }}</span>
|
||
</div>
|
||
<div class="format-actions">
|
||
<template v-if="bausteineState !== 'generating'">
|
||
<button
|
||
class="action-btn play"
|
||
:title="bausteine.partial ? 'Fortsetzen' : bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'"
|
||
@click="handleBausteinePlay"
|
||
>▶</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div v-if="bausteineState === 'generating'" class="format-progress ord-bausteine">
|
||
{{ bausteine.progress || 'Wartend…' }}
|
||
</div>
|
||
<div v-if="bausteine.error && !bausteine.error.startsWith('Abgebrochen')" class="format-error ord-bausteine">
|
||
<span class="format-error-text">{{ bausteine.error }}</span>
|
||
</div>
|
||
<!-- OnePager (unabhängig von Bausteinen) steht per CSS-order vor der Bausteine-Zeile -->
|
||
<div v-for="f in formats" :key="f.key" :style="{ order: f.key === 'OnePager' ? 1 : 3 }">
|
||
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': abgebrochen(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 — ▶ setzt fort"
|
||
>Pausiert</span>
|
||
<span class="step-dots" v-if="guideSteps(f.key).length">
|
||
<span
|
||
v-for="s in guideSteps(f.key)"
|
||
:key="s.label"
|
||
class="step-dot"
|
||
:class="s.state"
|
||
:title="s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label"
|
||
></span>
|
||
</span>
|
||
<span
|
||
v-if="guideStatus(f.key) !== 'none' || abgebrochen(f.key)"
|
||
class="format-x"
|
||
:class="{ armed: pendingConfirm === 'fmt-' + f.key }"
|
||
@click.stop="handleDelete(f.key)"
|
||
:title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen'
|
||
: abgebrochen(f.key) ? 'Fortschritt löschen' : 'Löschen'"
|
||
>{{ pendingConfirm === 'fmt-' + f.key ? 'Sicher?' : '×' }}</span>
|
||
</button>
|
||
<div class="format-actions">
|
||
<template v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
|
||
<button
|
||
class="action-btn play"
|
||
:title="playLock(f.key) || (abgebrochen(f.key) ? 'Fortsetzen' : 'Generieren')"
|
||
:disabled="!!playLock(f.key)"
|
||
@click="handlePlay(f.key)"
|
||
>▶</button>
|
||
</template>
|
||
</div>
|
||
</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-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 }"
|
||
@click="emit('select', t)"
|
||
>
|
||
<span>{{ t }}</span>
|
||
<button
|
||
class="delete-topic"
|
||
:class="{ armed: pendingConfirm === 'topic-' + t }"
|
||
@click.stop="confirmDeleteTopic(t)"
|
||
title="Thema und alle Guides löschen"
|
||
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : '×' }}</button>
|
||
</li>
|
||
<template v-if="projects.length">
|
||
<li class="projects-divider">Projekte</li>
|
||
<li
|
||
v-for="p in projects"
|
||
:key="'project-' + p"
|
||
:class="{ active: p === selectedTopic, 'project-item': true }"
|
||
@click="emit('select', p)"
|
||
>
|
||
<span>{{ p }}</span>
|
||
<button
|
||
class="delete-topic"
|
||
:class="{ armed: pendingConfirm === 'project-' + p }"
|
||
@click.stop="confirmDeleteProject(p)"
|
||
title="Projekt entfernen (löscht ./projects-Ordner)"
|
||
>{{ pendingConfirm === 'project-' + p ? 'Sicher?' : '×' }}</button>
|
||
</li>
|
||
</template>
|
||
</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);
|
||
}
|
||
|
||
.stats-bar {
|
||
display: flex;
|
||
padding: 0.5rem 0.75rem;
|
||
gap: 4px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.stat {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 1px;
|
||
padding: 4px 2px;
|
||
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);
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 0.58rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
color: var(--text-faint);
|
||
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 {
|
||
padding: 0.6rem 1rem;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
color: var(--text);
|
||
transition: background 0.15s;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.topic-list li:hover {
|
||
background: var(--accent-soft);
|
||
}
|
||
|
||
.topic-list li.active {
|
||
background: var(--accent-soft);
|
||
color: var(--accent-hover);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.delete-topic {
|
||
display: none;
|
||
background: none;
|
||
border: none;
|
||
color: var(--danger);
|
||
font-size: 1.1rem;
|
||
cursor: pointer;
|
||
padding: 0 2px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.topic-list li:hover .delete-topic {
|
||
display: block;
|
||
}
|
||
|
||
.projects-divider {
|
||
cursor: default;
|
||
font-size: 0.7rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--text-faint);
|
||
font-weight: 700;
|
||
padding: 0.6rem 1rem 0.3rem;
|
||
margin-top: 0.4rem;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.projects-divider:hover {
|
||
background: none;
|
||
}
|
||
|
||
.topic-list li.project-item span::before {
|
||
content: '📁 ';
|
||
}
|
||
|
||
/* Format section */
|
||
.bausteine-name {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: default;
|
||
}
|
||
|
||
.step-dots {
|
||
display: inline-flex;
|
||
gap: 5px;
|
||
flex: 1;
|
||
}
|
||
|
||
.step-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
background: var(--border-strong);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.step-dot.done {
|
||
background: var(--success-border);
|
||
}
|
||
|
||
.step-dot.active {
|
||
background: var(--warning-border);
|
||
animation: dot-pulse 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
@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: OnePager (order 1) vor Bausteine (order 2) vor den restlichen Formaten (order 3) */
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.ord-bausteine {
|
||
order: 2;
|
||
}
|
||
|
||
.ord-elemente {
|
||
order: 4;
|
||
}
|
||
|
||
.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;
|
||
background: none;
|
||
border: none;
|
||
text-align: left;
|
||
font-size: 0.85rem;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
cursor: default;
|
||
color: var(--text-faint);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
|
||
.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; }
|
||
}
|
||
|
||
</style>
|