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

View File

@@ -1,17 +1,35 @@
const BASE = '/api' 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() { export async function fetchGuides() {
const res = await fetch(`${BASE}/guides`) const res = await fetch(`${BASE}/guides`)
return res.json() 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') { export async function createGuide(topic, format, instructions = '', provider = 'claude') {
const res = await fetch(`${BASE}/guides`, { const res = await fetch(`${BASE}/guides`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, instructions, provider }), body: JSON.stringify({ topic, format, instructions, provider }),
}) })
return res.json() return jsonOrThrow(res)
} }
export async function fetchActiveBausteine() { export async function fetchActiveBausteine() {
@@ -30,7 +48,7 @@ export async function createBausteine(topic, instructions = '', provider = 'clau
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, instructions, provider }), body: JSON.stringify({ topic, instructions, provider }),
}) })
return res.json() return jsonOrThrow(res)
} }
export async function cancelBausteine(topic) { export async function cancelBausteine(topic) {

View File

@@ -95,6 +95,12 @@ function closeChat() {
chat.reset() 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) { function onDocMouseDown(e) {
if (!chatOpen.value) return if (!chatOpen.value) return
if (panelEl.value && panelEl.value.contains(e.target)) return if (panelEl.value && panelEl.value.contains(e.target)) return
@@ -540,6 +546,14 @@ function extractContext() {
right: calc(1.5rem + 320px); 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 { .chat-panel {
position: fixed; position: fixed;
right: 1.5rem; right: 1.5rem;

View File

@@ -8,6 +8,8 @@ const props = defineProps({
selectedTopic: { type: String, default: null }, selectedTopic: { type: String, default: null },
stats: { type: Object, default: null }, stats: { type: Object, default: null },
fortschritt: { type: Object, default: () => ({}) }, fortschritt: { type: Object, default: () => ({}) },
locks: { type: Object, default: () => ({}) },
uiError: { type: String, default: null },
doneByFormat: { type: Object, default: () => ({}) }, doneByFormat: { type: Object, default: () => ({}) },
latestByFormat: { type: Object, default: () => ({}) }, latestByFormat: { type: Object, default: () => ({}) },
allGuides: { type: Array, default: () => [] }, allGuides: { type: Array, default: () => [] },
@@ -20,7 +22,7 @@ const props = defineProps({
providers: { type: Array, default: () => [] }, 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) { function providerAvailable(id) {
const p = props.providers.find((x) => x.id === id) const p = props.providers.find((x) => x.id === id)
@@ -54,12 +56,13 @@ const bausteineState = computed(() => {
return props.bausteine.ready ? 'done' : 'none' return props.bausteine.ready ? 'done' : 'none'
}) })
// Nur FREMDE Themen — das gewählte Thema zeigt seinen Fortschritt inline an der Zeile
const activeGenerations = computed(() => { const activeGenerations = computed(() => {
const bausteinLines = props.activeBausteine.map( const bausteinLines = props.activeBausteine
(b) => `${b.topic} Bausteine: ${b.progress || 'Wartend…'}`, .filter((b) => b.topic !== props.selectedTopic)
) .map((b) => `${b.topic} Bausteine: ${b.progress || 'Wartend…'}`)
const guideLines = props.allGuides 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…'}`) .map((g) => `${g.topic} ${g.format}: ${g.progress || 'Wartend…'}`)
return [...bausteinLines, ...guideLines] return [...bausteinLines, ...guideLines]
}) })
@@ -118,6 +121,7 @@ function guideSteps(format) {
function errorMsg(format) { function errorMsg(format) {
const latest = props.latestByFormat[format] const latest = props.latestByFormat[format]
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return '' 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' 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): // Sperr-Gründe kommen vom Backend (GET /guides/locks) — die Regeln existieren
// Progression pro Thema (MiniGuide → Guide → FullGuide) + max. 3 offene je Format. // nur noch dort. Solange locks noch nicht geladen sind: Button frei, das
const VORSTUFE = { Guide: 'MiniGuide', FullGuide: 'Guide' } // Backend weist ungültige Starts ohnehin ab (sichtbar über uiError).
function offeneGuides(format) {
const f = props.stats?.formate?.[format]
return (f?.erstellt ?? 0) - (f?.absolviert ?? 0)
}
function playLock(format) { function playLock(format) {
if (format === 'OnePager') return null return props.locks?.[format] ?? 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
} }
function handlePlay(format) { function handlePlay(format) {
@@ -240,12 +227,21 @@ function confirmDeleteProject(name) {
>{{ PROVIDER_LABELS[p.id] || p.id }}</button> >{{ PROVIDER_LABELS[p.id] || p.id }}</button>
</div> </div>
<div class="format-section" v-if="selectedTopic"> <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 class="progress-info" v-if="activeGenerations.length">
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div> <div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
</div> </div>
<div class="format-row bausteine-row ord-bausteine"> <div class="format-row bausteine-row ord-bausteine">
<div class="format-name bausteine-name"> <div class="format-name bausteine-name">
<span class="format-label">Bausteine</span> <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 class="step-dots">
<span <span
v-for="s in (bausteine.steps || [])" v-for="s in (bausteine.steps || [])"
@@ -280,7 +276,10 @@ function confirmDeleteProject(name) {
</template> </template>
</div> </div>
</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> <span class="format-error-text">{{ bausteine.error }}</span>
</div> </div>
<!-- OnePager (unabhängig von Bausteinen) steht per CSS-order vor der Bausteine-Zeile --> <!-- 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)]"> <div :class="['format-row', 'fmt-' + guideStatus(f.key)]">
<button class="format-name" @click="handleFormatClick(f.key)"> <button class="format-name" @click="handleFormatClick(f.key)">
<span class="format-label">{{ f.label }}</span> <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 class="step-dots" v-if="guideSteps(f.key).length">
<span <span
v-for="s in guideSteps(f.key)" v-for="s in guideSteps(f.key)"
@@ -316,6 +320,10 @@ function confirmDeleteProject(name) {
</template> </template>
</div> </div>
</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"> <div v-if="errorMsg(f.key)" class="format-error">
<span class="format-error-text">{{ errorMsg(f.key) }}</span> <span class="format-error-text">{{ errorMsg(f.key) }}</span>
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button> <button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
@@ -653,6 +661,35 @@ function confirmDeleteProject(name) {
animation: pulse 1.5s ease-in-out infinite; 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 { .format-row {
display: flex; display: flex;
align-items: center; align-items: center;