Files
creator/frontend/src/App.vue
2026-07-05 15:26:22 +02:00

701 lines
21 KiB
Vue

<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue'
import BlocksOverview from './components/BlocksOverview.vue'
import GenerationView from './components/GenerationView.vue'
import GeneralExamPanel from './components/GeneralExamPanel.vue'
import PracticePanel from './components/PracticePanel.vue'
const guides = ref([])
const backendTopics = ref([])
const selectedTopic = ref(null)
const previewGuide = ref(null)
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
const sidebarSticky = ref(false)
const focusOpen = ref(false) // block fullscreen active → lift sidebar as overlay above the focus overlay
const darkMode = ref(
localStorage.getItem('darkMode') === null
? window.matchMedia('(prefers-color-scheme: dark)').matches
: localStorage.getItem('darkMode') === 'true',
)
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false }
const blocks = ref({ ...EMPTY_BLOCKS })
const activeBlocks = ref([])
const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([])
const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
const mainView = ref('blocks') // blocks | generation | general | practice | detail — exclusive main-area view
const guideBoardFormat = ref('Guide')
const viewMode = ref('compact') // compact | erklärend — per topic, default compact
const _storedLevel = localStorage.getItem('level')
const levelView = ref(_storedLevel === 'auto' || !_storedLevel ? 'auto' : Number(_storedLevel) || 'auto') // 'auto' | 1-4
const stats = ref(null)
const progress = ref({})
const uiError = ref(null) // surface rejected actions (409/400)
// Run a loader, log + swallow its error (a failed background load must not break the UI).
async function guard(label, fn) {
try { return await fn() } catch (e) { console.error(label, e) }
}
// Aktion ausführen und einen Backend-Fehler (409/400/500) in der Sidebar-Fehlerzeile
// zeigen statt als unhandled rejection zu verschlucken. → true bei Erfolg.
async function withUiError(fn) {
uiError.value = null
try { await fn(); return true } catch (e) { uiError.value = e.message; return false }
}
async function loadStats() {
await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
}
function setProvider(id) {
provider.value = id
localStorage.setItem('provider', id)
}
async function loadFolders() {
await guard('Failed to load folders:', async () => {
const [projekt, uni] = await Promise.all([fetchFolders('projekt'), fetchFolders('uni')])
folders.value = { projekt, uni }
})
}
async function loadProviders() {
await guard('Failed to load providers:', async () => {
providers.value = await fetchProviders()
const current = providers.value.find((p) => p.id === provider.value)
if (current && !current.available) {
const fallback = providers.value.find((p) => p.available)
if (fallback) setProvider(fallback.id)
}
})
}
function applyTheme() {
document.documentElement.classList.toggle('dark', darkMode.value)
}
function toggleDark() {
darkMode.value = !darkMode.value
localStorage.setItem('darkMode', darkMode.value ? 'true' : 'false')
applyTheme()
}
applyTheme()
function toggleSidebarPin() {
sidebarPinned.value = !sidebarPinned.value
localStorage.setItem('sidebarPinned', sidebarPinned.value ? 'true' : 'false')
if (sidebarPinned.value) sidebarSticky.value = false
}
function clickHoverZone() {
sidebarSticky.value = !sidebarSticky.value
}
function onSidebarLeave() {
if (!sidebarPinned.value) sidebarSticky.value = false
}
const topics = computed(() => backendTopics.value)
const doneByFormat = computed(() => {
const map = {}
for (const g of guides.value) {
if (g.topic !== selectedTopic.value) continue
if (g.status !== 'done') continue
if (!map[g.format] || g.created_at > map[g.format].created_at) {
map[g.format] = g
}
}
return map
})
const latestByFormat = computed(() => {
const map = {}
for (const g of guides.value) {
if (g.topic !== selectedTopic.value) continue
if (!map[g.format] || g.created_at > map[g.format].created_at) {
map[g.format] = g
}
}
return map
})
const hasActiveGuides = computed(() =>
guides.value.some((g) => g.status === 'queued' || g.status === 'generating'),
)
async function loadTopics() {
try {
backendTopics.value = await fetchTopics()
} catch (e) {
console.error('Failed to load topics:', e)
}
}
// Dismissed errors stay dismissed — even across reloads (localStorage).
// Errors that aren't dismissed stay visible until the user closes them.
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()
// Prune IDs whose guide no longer exists as an error
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) {
console.error('Failed to load:', e)
}
}
async function loadBlocks() {
try {
activeBlocks.value = await fetchActiveBlocks()
if (selectedTopic.value) {
blocks.value = await fetchBlocksStatus(selectedTopic.value)
progress.value = await fetchTopicProgress(selectedTopic.value)
} else {
blocks.value = { ...EMPTY_BLOCKS }
progress.value = {}
}
if (activeBlocks.value.length && !polling.running()) startPolling()
} catch (e) {
console.error('Failed to load blocks:', e)
}
}
function selectTopic(topic) {
selectedTopic.value = topic
previewGuide.value = null
sidebarSticky.value = false
mainView.value = 'generation' // topic click → generation board (guide only on pill click)
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
localStorage.setItem('lastTopic', topic)
loadBlocks()
}
// On reload, land where you were before (topic + format)
watch(previewGuide, (g) => {
if (g) localStorage.setItem('lastFormat', g.format)
})
async function handleCancelBlocks() {
if (!selectedTopic.value) return
if (await withUiError(() => apiCancelBausteine(selectedTopic.value))) await loadBlocks()
}
async function handleResetBlocks() {
if (!selectedTopic.value) return
if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks()
}
async function handleResetStage({ board, stage, restart = false }) {
if (!selectedTopic.value) return
uiError.value = null
try {
await apiResetBlocksStage(selectedTopic.value, board, stage)
if (restart) await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false) // Continue: Queue abarbeiten
} catch (e) {
uiError.value = e.message
return
}
await loadBlocks()
if (restart) startPolling()
}
async function handleAddResearch() {
if (!selectedTopic.value) return
uiError.value = null
try {
await apiAddResearch(selectedTopic.value, provider.value)
} catch (e) {
uiError.value = e.message
return
}
await loadBlocks()
startPolling()
}
async function handleRequeueDead() {
if (!selectedTopic.value) return
const ok = await withUiError(async () => {
await apiRequeueDead(selectedTopic.value)
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false)
})
if (!ok) return
await loadBlocks()
startPolling()
}
async function handleBlocksClick({ instructions = '', research = true, qaForce = false }) {
if (!selectedTopic.value) return
uiError.value = null
try {
// research=true = Start/mehr Research anhängen; false = Continue (Queue abarbeiten).
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce)
} catch (e) {
uiError.value = e.message
return
}
await loadBlocks()
startPolling()
}
async function handleCreateTopic({ topic, instructions, sourceType, sourceOrt }) {
uiError.value = null
try {
await apiCreateBausteine(topic, instructions, provider.value, sourceType, sourceOrt)
} catch (e) {
uiError.value = e.message
return
}
await loadTopics()
selectTopic(topic)
mainView.value = 'generation' // frisches Topic: die Boards sind das Einzige, was passiert
startPolling()
}
async function handleUpdateSource({ topic, type, ort, spec }) {
uiError.value = null
try {
await apiUpdateQuelle(topic, { type, ort, spec })
} catch (e) {
uiError.value = e.message
return
}
await loadBlocks() // the step display may follow the new source (e.g. link → "load source")
}
function setView(modus) {
viewMode.value = modus
if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus)
}
function setLevel(k) {
levelView.value = k
localStorage.setItem('level', String(k))
}
function handleOpenBlocksView() {
if (!selectedTopic.value) return
mainView.value = 'blocks'
previewGuide.value = null
}
async function handleFormatClick({ format, instructions = '', abStep = null }) {
if (!selectedTopic.value) return
// No duplicate start: if a generation is already running for topic+format, ignore
const running = guides.value.some(
(g) => g.topic === selectedTopic.value && g.format === format
&& (g.status === 'generating' || g.status === 'queued'),
)
if (running) { handleOpenGuideBoard(format); return }
uiError.value = null
try {
await apiCreate(selectedTopic.value, format, instructions, provider.value, abStep)
} catch (e) {
uiError.value = e.message
return
}
handleOpenGuideBoard(format) // Start → direkt aufs Live-Board
await loadGuides()
startPolling()
}
function handleOpenGuideBoard(format = 'Guide') {
if (!selectedTopic.value) return
guideBoardFormat.value = format
mainView.value = 'generation'
previewGuide.value = null
}
function handleOpenGeneration() {
if (!selectedTopic.value) return
mainView.value = 'generation'
previewGuide.value = null
}
async function handleRemoveGuideFormat(format) {
uiError.value = null
try {
await apiRemoveGuideFormat(selectedTopic.value, format)
} catch (e) {
uiError.value = e.message
return
}
await loadGuides()
}
async function handleRestartCard(cardId) {
uiError.value = null
try {
await apiRestartBlocksCard(selectedTopic.value, cardId)
await handleBlocksClick({ research: false }) // Continue: der Flow zieht die Karte
} catch (e) {
uiError.value = e.message
}
}
async function handleResetGuideCard({ format, blockNorm, abStage }) {
uiError.value = null
try {
await apiResetGuideCard(selectedTopic.value, format, blockNorm, abStage)
} catch (e) {
uiError.value = e.message
}
}
async function handleGuideBoardReset({ format, abStage }) {
uiError.value = null
try {
await apiResetGuideBoard(selectedTopic.value, format, abStage)
} catch (e) {
uiError.value = e.message
}
}
function handleGuideBoardPreview() {
const g = doneByFormat.value[guideBoardFormat.value]
if (g) handlePreview(g)
}
function handlePreview(guide) {
previewGuide.value = guide
mainView.value = 'detail'
}
function handleGeneralExam() {
if (!selectedTopic.value) return
mainView.value = 'general'
previewGuide.value = null
}
function handlePractice() {
if (!selectedTopic.value) return
mainView.value = 'practice'
previewGuide.value = null
}
async function handleDeleteGuide(guideId, slots = false) {
uiError.value = null
try {
await deleteGuide(guideId, slots)
} catch (e) {
uiError.value = e.message
return
}
if (previewGuide.value?.id === guideId) {
previewGuide.value = null
}
await loadGuides()
}
const polling = usePolling(
() => Promise.all([loadGuides(), loadBlocks(), loadTopics()]),
() => hasActiveGuides.value || activeBlocks.value.length > 0,
)
const startPolling = polling.start
async function handleCancel(guideId) {
if (await withUiError(() => apiCancel(guideId))) await loadGuides()
}
async function handleDeleteTopic(topic) {
// Das Backend löscht Guides/Board/Kanban selbst und wehrt laufende Generierungen ab (409).
if (!await withUiError(() => apiDeleteTopic(topic))) return
await loadTopics()
if (selectedTopic.value === topic) {
selectedTopic.value = null
previewGuide.value = null
}
await loadGuides()
}
onMounted(async () => {
await Promise.all([loadGuides(), loadTopics(), loadProviders(), loadFolders()])
const savedTopic = localStorage.getItem('lastTopic')
if (savedTopic && topics.value.includes(savedTopic)) {
selectTopic(savedTopic) // lands on the blocks overview
} else if (!selectedTopic.value && topics.value.length) {
selectTopic(topics.value[0])
}
})
</script>
<template>
<div class="layout" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': sidebarSticky, 'sidebar-over-fokus': focusOpen && sidebarSticky }">
<div v-if="!sidebarPinned" class="hover-zone" @click="clickHoverZone"></div>
<div v-if="(!sidebarPinned && sidebarSticky) || (focusOpen && sidebarSticky)" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
<TopicSidebar
:topics="topics"
:selectedTopic="selectedTopic"
:stats="stats"
:fortschritt="progress"
:uiError="uiError"
:doneByFormat="doneByFormat"
:latestByFormat="latestByFormat"
:allGuides="guides"
:dismissedErrors="dismissedErrors"
:blocks="blocks"
:activeBausteine="activeBlocks"
:pinned="sidebarPinned"
:dark="darkMode"
:provider="provider"
:providers="providers"
:folders="folders"
:ansichtModus="viewMode"
:stufeAnsicht="levelView"
@setProvider="setProvider"
@toggleDark="toggleDark"
@setAnsicht="setView"
@setStufe="setLevel"
@generalExam="handleGeneralExam"
@practice="handlePractice"
@select="selectTopic"
@createThema="handleCreateTopic"
@updateSource="handleUpdateSource"
@openBausteineView="handleOpenBlocksView"
@openGuideBoard="handleOpenGuideBoard"
@bausteineClick="handleBlocksClick"
@deleteTopic="handleDeleteTopic"
@dismissError="handleDismissError"
@dismissUiError="uiError = null"
@preview="handlePreview"
@openGeneration="handleOpenGeneration"
@togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave"
/>
<BlocksOverview
v-if="selectedTopic && mainView === 'blocks'"
:topic="selectedTopic"
:generating="blocks.generating"
:progress="blocks.progress"
:ready="blocks.ready"
:partial="blocks.partial"
@close="mainView = 'detail'"
@openGeneration="handleOpenGeneration"
/>
<GenerationView
v-else-if="selectedTopic && mainView === 'generation'"
:topic="selectedTopic"
:generating="blocks.generating"
:progress="blocks.progress"
:ready="blocks.ready"
:partial="blocks.partial"
:guideFormat="guideBoardFormat"
@close="mainView = 'blocks'"
@resetStage="handleResetStage"
@restartAll="() => handleBlocksClick({ research: true })"
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce) })"
@addResearch="handleAddResearch"
@requeueDead="handleRequeueDead"
@removeAll="handleResetBlocks"
@cancel="handleCancelBlocks"
@cancelGuide="handleCancel"
@startGuide="handleFormatClick"
@resetGuideStage="handleGuideBoardReset"
@preview="handleGuideBoardPreview"
@removeFormat="handleRemoveGuideFormat"
@restartCard="handleRestartCard"
@resetGuideCard="handleResetGuideCard"
/>
<GeneralExamPanel
v-else-if="selectedTopic && mainView === 'general'"
:topic="selectedTopic"
:provider="provider"
@progressChanged="loadStats(); loadBlocks()"
@fokus-active="focusOpen = $event"
/>
<PracticePanel
v-else-if="selectedTopic && mainView === 'practice'"
:key="selectedTopic"
:topic="selectedTopic"
/>
<TopicDetail
v-else-if="selectedTopic"
:previewGuide="previewGuide"
:provider="provider"
:themaAbgeschlossen="!!progress.completed"
:ansichtModus="viewMode"
:stufeAnsicht="levelView"
@progressChanged="loadStats(); loadBlocks()"
@setAnsicht="setView"
@open-sidebar="sidebarSticky = true"
@fokus-active="focusOpen = $event"
/>
<div v-else class="empty-main">
<p>Create or select a topic in the sidebar.</p>
</div>
</div>
</template>
<style>
:root {
--bg: #f8f9fb;
--bg-preview: #f0f1f4;
--panel: #ffffff;
--panel-soft: #f4f5f7;
--border: #e2e5e9;
--border-strong: #d8dde3;
--text: #1a1a1a;
--text-muted: #4b5563;
--text-faint: #9ca3af;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-soft: #ede9fe;
--accent-border: #a5b4fc;
--on-accent: #ffffff;
--success: #065f46;
--success-soft: #d1fae5;
--success-soft-hover: #a7f3d0;
--success-border: #34d399;
/* 4 learning levels: green/blue/purple/gold = Beginner/Advanced/Expert/Master */
--level-beginner: #22c55e;
--level-advanced: #3b82f6;
--level-expert: #8b5cf6;
--level-master: #d4af37;
--warning: #92400e;
--warning-soft: #fef3c7;
--warning-border: #fbbf24;
--danger: #991b1b;
--code-bg: #1e2a3a;
--code-fg: #e6e6e6;
--shadow: rgba(0, 0, 0, 0.12);
}
html.dark {
--bg: #15171c;
--bg-preview: #0e1014;
--panel: #1c1f26;
--panel-soft: #23262e;
--border: #2c3038;
--border-strong: #3a3f4a;
--text: #e6e8ee;
--text-muted: #9aa3b2;
--text-faint: #6b7280;
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-soft: #2a2350;
--accent-border: #4f46e5;
--on-accent: #ffffff;
--success: #34d399;
--success-soft: #0f3a2e;
--success-soft-hover: #155e45;
--success-border: #0f805e;
--warning: #fbbf24;
--warning-soft: #3a2c0a;
--warning-border: #a06a12;
--danger: #f87171;
--shadow: rgba(0, 0, 0, 0.5);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
}
input,
textarea {
background: var(--panel);
color: var(--text);
}
input::placeholder,
textarea::placeholder {
color: var(--text-faint);
}
.layout {
display: flex;
height: 100dvh;
overflow: hidden;
position: relative;
}
.hover-zone {
position: fixed;
left: 0;
top: 0;
width: 50px;
height: 100dvh;
z-index: 5;
cursor: pointer;
}
/* Invisible surface behind the open floating sidebar.
A tap/click next to it closes it — without it there's no way out on touch. */
.sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 9;
background: transparent;
}
.layout.sidebar-floating > .sidebar {
position: fixed;
left: 0;
top: 0;
height: 100dvh;
transform: translateX(-100%);
transition: transform 0.2s ease;
z-index: 10;
box-shadow: 0 0 16px var(--shadow);
}
.layout.sidebar-floating .hover-zone:hover ~ .sidebar,
.layout.sidebar-floating > .sidebar:hover,
.layout.sidebar-floating.sidebar-open > .sidebar {
transform: translateX(0);
}
/* Block fullscreen: lift the sidebar as an overlay ABOVE the focus overlay (z-index 40).
Applies for pinned AND floating. */
.layout.sidebar-over-fokus > .sidebar {
position: fixed;
left: 0;
top: 0;
height: 100dvh;
transform: translateX(0);
z-index: 50;
box-shadow: 0 0 16px var(--shadow);
}
.layout.sidebar-over-fokus .sidebar-backdrop {
z-index: 49;
}
.empty-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 1rem;
}
</style>