Frontend: locks vom Backend, uiError sichtbar, Pausiert-Badge, Inline-Progress, Mobile-Exklusivität, Fehler-Persistenz

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
team3
2026-06-12 08:18:24 +02:00
parent 2c426e6ac4
commit 700ba1e0e8
4 changed files with 133 additions and 39 deletions

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats, fetchTopicFortschritt } from './api.js'
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats, fetchTopicFortschritt, fetchGuideLocks } from './api.js'
import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue'
@@ -26,6 +26,8 @@ const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([])
const stats = ref(null)
const fortschritt = ref({})
const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle)
const uiError = ref(null) // abgewiesene Aktionen (409/400) sichtbar machen
const elementsOpen = ref(false) // rechte Sidebar
const elementsView = ref(false) // Übersicht im Hauptbereich
const elementsVersion = ref(0) // Erhöhung = Übersicht neu laden
@@ -126,21 +128,27 @@ async function loadTopics() {
}
}
// Fehlermeldungen verhalten sich wie Flash-Messages: × blendet aus,
// beim Reload sind Alt-Fehler von vornherein ausgeblendet.
const dismissedErrors = ref(new Set())
let errorsInitialized = false
// Weggeklickte Fehler bleiben weggeklickt — auch über Reloads (localStorage).
// Nicht weggeklickte Fehler bleiben sichtbar, bis der Nutzer sie schließt.
const dismissedErrors = ref(new Set(JSON.parse(localStorage.getItem('dismissedErrors') || '[]')))
function persistDismissed() {
localStorage.setItem('dismissedErrors', JSON.stringify([...dismissedErrors.value]))
}
function handleDismissError(guideId) {
dismissedErrors.value = new Set([...dismissedErrors.value, guideId])
persistDismissed()
}
async function loadGuides() {
try {
guides.value = await fetchGuides()
if (!errorsInitialized) {
errorsInitialized = true
dismissedErrors.value = new Set(guides.value.filter((g) => g.status === 'error').map((g) => g.id))
// IDs prunen, deren Guide nicht mehr als Fehler existiert
const errorIds = new Set(guides.value.filter((g) => g.status === 'error').map((g) => g.id))
if ([...dismissedErrors.value].some((id) => !errorIds.has(id))) {
dismissedErrors.value = new Set([...dismissedErrors.value].filter((id) => errorIds.has(id)))
persistDismissed()
}
loadStats()
} catch (e) {
@@ -154,9 +162,11 @@ async function loadBausteine() {
if (selectedTopic.value) {
bausteine.value = await fetchBausteineStatus(selectedTopic.value)
fortschritt.value = await fetchTopicFortschritt(selectedTopic.value)
locks.value = await fetchGuideLocks(selectedTopic.value)
} else {
bausteine.value = { ...EMPTY_BAUSTEINE }
fortschritt.value = {}
locks.value = {}
}
if (activeBausteine.value.length && !polling.running()) startPolling()
} catch (e) {
@@ -224,7 +234,13 @@ async function handleResetBausteine() {
async function handleBausteineClick({ instructions }) {
if (!selectedTopic.value) return
await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
uiError.value = null
try {
await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
} catch (e) {
uiError.value = e.message
return
}
await loadBausteine()
startPolling()
}
@@ -237,7 +253,13 @@ async function handleFormatClick({ format, instructions }) {
&& (g.status === 'generating' || g.status === 'queued'),
)
if (running) return
await apiCreate(selectedTopic.value, format, instructions, provider.value)
uiError.value = null
try {
await apiCreate(selectedTopic.value, format, instructions, provider.value)
} catch (e) {
uiError.value = e.message
return
}
await loadGuides()
startPolling()
}
@@ -327,6 +349,8 @@ onMounted(async () => {
:selectedTopic="selectedTopic"
:stats="stats"
:fortschritt="fortschritt"
:locks="locks"
:uiError="uiError"
:doneByFormat="doneByFormat"
:latestByFormat="latestByFormat"
:allGuides="guides"
@@ -350,6 +374,7 @@ onMounted(async () => {
@cancelGuide="handleCancel"
@deleteGuide="handleDeleteGuide"
@dismissError="handleDismissError"
@dismissUiError="uiError = null"
@preview="handlePreview"
@openElements="handleOpenElements"
@togglePin="toggleSidebarPin"

View File

@@ -1,17 +1,35 @@
const BASE = '/api'
// Backend-Fehler (400/409 mit detail) als Error werfen statt sie zu verschlucken
async function jsonOrThrow(res) {
if (!res.ok) {
let detail = `Fehler (HTTP ${res.status})`
try {
const data = await res.json()
if (data.detail) detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
} catch { /* kein JSON-Body */ }
throw new Error(detail)
}
return res.json()
}
export async function fetchGuides() {
const res = await fetch(`${BASE}/guides`)
return res.json()
}
export async function fetchGuideLocks(topic) {
const res = await fetch(`${BASE}/guides/locks?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function createGuide(topic, format, instructions = '', provider = 'claude') {
const res = await fetch(`${BASE}/guides`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, instructions, provider }),
})
return res.json()
return jsonOrThrow(res)
}
export async function fetchActiveBausteine() {
@@ -30,7 +48,7 @@ export async function createBausteine(topic, instructions = '', provider = 'clau
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, instructions, provider }),
})
return res.json()
return jsonOrThrow(res)
}
export async function cancelBausteine(topic) {

View File

@@ -95,6 +95,12 @@ function closeChat() {
chat.reset()
}
// Mobil schließen sich Chat und Elemente-Sidebar gegenseitig aus —
// nebeneinander ist kein Platz, die Sidebar würde den Chat überdecken.
watch(() => props.elementsOpen, (open) => {
if (open && chatOpen.value && window.matchMedia('(max-width: 768px)').matches) closeChat()
})
function onDocMouseDown(e) {
if (!chatOpen.value) return
if (panelEl.value && panelEl.value.contains(e.target)) return
@@ -540,6 +546,14 @@ function extractContext() {
right: calc(1.5rem + 320px);
}
/* Mobil liegt die Elemente-Sidebar als Overlay über dem Chat — FAB/Panel ausblenden */
@media (max-width: 768px) {
.chat-fab.shifted,
.chat-panel.shifted {
display: none;
}
}
.chat-panel {
position: fixed;
right: 1.5rem;

View File

@@ -8,6 +8,8 @@ const props = defineProps({
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: () => [] },
@@ -20,7 +22,7 @@ const props = defineProps({
providers: { type: Array, default: () => [] },
})
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'preview', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
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)
@@ -54,12 +56,13 @@ const bausteineState = computed(() => {
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.map(
(b) => `${b.topic} Bausteine: ${b.progress || 'Wartend…'}`,
)
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')
.filter((g) => (g.status === 'generating' || g.status === 'queued') && g.topic !== props.selectedTopic)
.map((g) => `${g.topic} ${g.format}: ${g.progress || 'Wartend…'}`)
return [...bausteinLines, ...guideLines]
})
@@ -118,6 +121,7 @@ function guideSteps(format) {
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'
}
@@ -134,28 +138,11 @@ function handleFormatClick(format) {
}
}
// Lernschulden-Regeln (nur Neu-Erstellungen; Resume + Neu-Generieren bestehender erlaubt):
// Progression pro Thema (MiniGuide → Guide → FullGuide) + max. 3 offene je Format.
const VORSTUFE = { Guide: 'MiniGuide', FullGuide: 'Guide' }
function offeneGuides(format) {
const f = props.stats?.formate?.[format]
return (f?.erstellt ?? 0) - (f?.absolviert ?? 0)
}
// 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) {
if (format === 'OnePager') return null
if (!props.bausteine.ready) return 'Erst Bausteine erstellen'
if (props.doneByFormat[format] || abgebrochen(format)) return null
const vorstufe = VORSTUFE[format]
if (vorstufe && !props.fortschritt?.[vorstufe]) {
return `Erst den ${vorstufe} dieses Themas absolvieren`
}
const offen = offeneGuides(format)
if (offen >= 3) {
return `Erst ${format}s absolvieren — ${offen} offen (max. 3)`
}
return null
return props.locks?.[format] ?? null
}
function handlePlay(format) {
@@ -240,12 +227,21 @@ function confirmDeleteProject(name) {
>{{ 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">
<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 || [])"
@@ -280,7 +276,10 @@ function confirmDeleteProject(name) {
</template>
</div>
</div>
<div v-if="bausteine.error" class="format-error ord-bausteine">
<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 -->
@@ -288,6 +287,11 @@ function confirmDeleteProject(name) {
<div :class="['format-row', 'fmt-' + guideStatus(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)"
@@ -316,6 +320,10 @@ function confirmDeleteProject(name) {
</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>
@@ -653,6 +661,35 @@ function confirmDeleteProject(name) {
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;