Files
creator/frontend/src/components/TopicSidebar.vue
2026-07-02 03:05:57 +02:00

1129 lines
34 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 { fetchSource } 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: () => ({}) }, // 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() },
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: 'compact' }, // compact | erklärend
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
})
const emit = defineEmits(['select', 'createThema', 'updateSource', 'formatClick', 'bausteineClick', 'cancelBlocks', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
// 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) {
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: 'Local' }
// Tracker at the top of the navigation: total topics, created/completed per format
const trackerItems = computed(() => {
if (!props.stats) return []
const f = props.stats.formats || {}
const fmt = (k) => `${f[k]?.completed ?? 0}/${f[k]?.erstellt ?? 0}`
return [
{ label: 'Topics', value: String(props.stats.topics ?? 0), title: 'Topics incl. projects' },
{ label: 'Guides', value: fmt('Guide'), title: 'completed/created' },
]
})
const formats = [
{ key: 'Guide', label: 'Guide' },
]
// 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 blocksState = computed(() => {
if (props.blocks.generating) return 'generating'
return props.blocks.ready ? 'done' : 'none'
})
// Re-run from a substep now happens in the blocks overview; the sidebar no longer has phase pills.
// Only OTHER topics — the selected topic shows its progress inline on the row
const activeGenerations = computed(() => {
const blockLines = props.activeBausteine
.filter((b) => b.topic !== props.selectedTopic)
.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 || 'Waiting…'}`)
return [...blockLines, ...guideLines]
})
const { pending: pendingConfirm, armOrRun } = useConfirm()
function confirmCancelBlocks() {
armOrRun('blocks', () => emit('cancelBlocks'))
}
function confirmResetBlocks() {
armOrRun('blocks', () => emit('resetBausteine'))
}
// Name click = open the block overview (generate/resume/remove all live there now).
function onBlocksName() {
emit('openBausteineView')
}
function guideStatus(format) {
// 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'
if (!latest || latest.status === 'error') return 'none'
return latest.status
}
// Stage dots of the guide board (display only — restart/reset lives on the board)
const GUIDE_STEPS = ['Lernziele', 'Zuweisung', 'Writer', 'Fakten', 'Coverage', 'Lesbarkeit']
// Dots from the card-based "done" marker: ≤ done = done. Running → done+1 active.
function guideSteps(format) {
const labels = GUIDE_STEPS
const done = props.guideStepsDone[format] ?? -1
const st = guideStatus(format)
const active = st === 'generating' || st === 'queued' ? done + 1 : -1
return labels.map((label, i) => ({
label,
state: i <= done ? 'done' : i === active ? 'active' : 'pending',
}))
}
// Dot click → open the live guide board (the board hosts restart/reset per column).
function guideStepClick(format) {
emit('openGuideBoard', format)
}
function errorMsg(format) {
const latest = props.latestByFormat[format]
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
if (aborted(format)) return '' // no red error — the Paused badge shows the state
return latest.error_msg || 'Generation failed'
}
// 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('Cancelled')
}
// 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)
}
// 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
emit('formatClick', { format, instructions: '', abStep: null }) // Restart-ab-Stage lebt auf dem Board
}
// Flash-message behavior: × only hides, nothing is deleted
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, () => {
// 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 (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)
}
})
}
// Create area: inline expandable (name + more info + source type).
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 toggleCreate() {
if (!dlg.value) form.value = { name: '', instructions: '', sourceType: 'thema', sourceOrt: '' }
dlg.value = !dlg.value
}
function createTopic() {
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))
}
// --- 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)
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 fetchSource(t)
editForm.value = { type: q.type || 'thema', ort: q.location || '', 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 saveSource() {
if (!canSave.value || !editTopic.value) return
emit('updateSource', {
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 ? 'Hide sidebar' : 'Pin sidebar'"
@click="emit('togglePin')"
>{{ pinned ? '⇤' : '⇥' }}</button>
<button
class="theme-btn"
:title="dark ? 'Light mode' : 'Dark mode'"
@click="emit('toggleDark')"
>{{ dark ? '☀' : '🌙' }}</button>
<div v-if="selectedTopic" class="ansicht-toggle">
<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="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" />
<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="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="Create topic" @click="toggleCreate">+</button>
</div>
<!-- Create: inline expandable (no modal) -->
<div v-if="dlg" class="thema-panel">
<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 = ''">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 = ''">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="createTopic"
/>
<select
v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'"
class="dlg-input" v-model="form.sourceOrt"
>
<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">Cancel</button>
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Create</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 ? '' : '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="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-blocks">
<div
class="format-row blocks-row"
:class="{ 'is-active': blocksState === 'generating' || blocks.partial, 'row-open': isOpen('blocks') }"
>
<button class="format-name blocks-name" @click="onBlocksName">
<span class="format-label">Blocks</span>
<span
v-if="blocks.partial && blocksState !== 'generating'"
class="resume-badge"
title="Aborted — can be resumed"
>Paused</span>
</button>
<button v-if="blocksState === 'generating' || blocks.ready || blocks.partial" class="panel-toggle" :class="{ open: isOpen('blocks') }" title="Actions" @click.stop="togglePanel('blocks')"></button>
</div>
<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
v-if="blocks.ready || blocks.partial"
class="panel-btn danger"
:class="{ armed: pendingConfirm === 'blocks' }"
@click="confirmResetBlocks"
>{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Remove' }}</button>
</template>
</div>
<div v-if="blocksState === 'generating'" class="format-progress">
{{ blocks.progress || 'Waiting' }}
</div>
<div v-if="blocks.error && !blocks.error.startsWith('Cancelled')" class="format-error">
<span class="format-error-text">{{ blocks.error }}</span>
</div>
</div>
<!-- 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': 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="aborted(f.key)"
class="resume-badge"
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 klickbar"
:class="[s.state]"
:title="(s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label) + ' — Klick: Live-Board öffnen'"
@click.stop="guideStepClick(f.key)"
>{{ i + 1 }}</span>
</span>
</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 ? 'Sure?' : 'Cancel' }}</button>
</template>
<template v-else>
<button
class="panel-btn play"
:title="playLock(f.key) || (aborted(f.key) ? 'Resume' : 'Generate')"
:disabled="!!playLock(f.key)"
@click="handlePlay(f.key)"
>{{ aborted(f.key) ? 'Resume' : guideStatus(f.key) === 'done' ? 'Regenerate' : 'Generate' }}</button>
<button
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 ? '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 || '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="Hide" @click="dismissError(f.key)">×</button>
</div>
</div>
<div class="format-row ord-exam">
<button class="format-name elements-btn" @click="emit('generalExam')">
<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">Elements</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="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">Loading</p>
<template v-else>
<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')">Topic</button>
<button :class="{ active: editForm.type === 'link' }" @click="setEditType('link')">Link</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
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>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 ? 'Sure?' : 'Delete' }}</button>
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Update</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);
}
/* View switcher: two icon buttons (short / long 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; }
.level-toggle { display: inline-flex; margin-left: 4px; }
.new-topic .level-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 .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;
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 title sits on the top edge of the badge (legend 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 */
.blocks-name {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.step-dots {
display: inline-flex;
gap: 5px;
flex: 1;
}
/* Coarse phases as numbered pills (15) — display + clickable for re-run from here. */
.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: blocks (order 2) before the formats (order 3) */
display: flex;
flex-direction: column;
}
.ord-blocks {
order: 2;
}
.ord-exam {
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;
}
/* Rejected action (409/400) — above all format rows */
.ui-error {
order: 0;
padding: 0.4rem 0.75rem;
background: var(--warning-soft);
}
/* Progress text directly below the running format row */
.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: arrow toggle + expanding action 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;
}
/* Running/paused: always show × — there is no hover on touch */
.fmt-generating .format-x,
.fmt-queued .format-x,
.fmt-paused .format-x,
.blocks-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; }
}
/* Create — inline expandable (no modal) */
.new-topic-toggle {
margin-left: auto; /* right-aligned instead of full width */
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; }
/* 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;
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>