This commit is contained in:
team3
2026-07-02 22:48:57 +02:00
parent 41c9f29a37
commit 285317927d
38 changed files with 2548 additions and 2812 deletions

View File

@@ -1,13 +1,11 @@
<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, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchGuideSteps, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
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, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, 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 GuideBoard from './components/GuideBoard.vue'
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
import ElementsOverview from './components/ElementsOverview.vue'
import GenerationView from './components/GenerationView.vue'
import GeneralExamPanel from './components/GeneralExamPanel.vue'
const guides = ref([])
@@ -28,19 +26,14 @@ 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 | guideboard | elements | general | detail — exclusive main-area view
const mainView = ref('blocks') // blocks | generation | general | detail — exclusive main-area view
const guideBoardFormat = ref('Guide')
const viewMode = ref('compact') // compact | erklärend — per topic, default compact
const levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view)
const stats = ref(null)
const progress = ref({})
const locks = ref({}) // lock reasons per format (backend = single rule source)
const guideStepsDone = ref({}) // highest finished step index per format (artifact-based)
const uiError = ref(null) // surface rejected actions (409/400)
const elementsOpen = ref(false) // right sidebar
const elementsVersion = ref(0) // increment = reload overview
const elementOpenId = ref(null) // open element from overview in sidebar
const elementOpenTick = ref(0)
// Run a loader, log + swallow its error (a failed background load must not break the UI).
async function guard(label, fn) {
@@ -172,12 +165,10 @@ async function loadBlocks() {
blocks.value = await fetchBlocksStatus(selectedTopic.value)
progress.value = await fetchTopicProgress(selectedTopic.value)
locks.value = await fetchGuideLocks(selectedTopic.value)
guideStepsDone.value = await fetchGuideSteps(selectedTopic.value)
} else {
blocks.value = { ...EMPTY_BLOCKS }
progress.value = {}
locks.value = {}
guideStepsDone.value = {}
}
if (activeBlocks.value.length && !polling.running()) startPolling()
} catch (e) {
@@ -189,9 +180,7 @@ function selectTopic(topic) {
selectedTopic.value = topic
previewGuide.value = null
sidebarSticky.value = false
elementsOpen.value = false
mainView.value = 'blocks' // topic click → blocks overview (guide only on pill click)
elementOpenId.value = null
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
localStorage.setItem('lastTopic', topic)
loadBlocks()
@@ -273,6 +262,7 @@ async function handleCreateTopic({ topic, instructions, sourceType, sourceOrt })
}
await loadTopics()
selectTopic(topic)
mainView.value = 'generation' // frisches Topic: die Boards sind das Einzige, was passiert
startPolling()
}
@@ -326,10 +316,35 @@ async function handleFormatClick({ format, instructions = '', abStep = null }) {
function handleOpenGuideBoard(format = 'Guide') {
if (!selectedTopic.value) return
guideBoardFormat.value = format
mainView.value = 'guideboard'
mainView.value = 'generation'
previewGuide.value = null
}
function handleOpenGeneration() {
if (!selectedTopic.value) return
mainView.value = 'generation'
previewGuide.value = null
}
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 {
@@ -355,18 +370,6 @@ function handleGeneralExam() {
previewGuide.value = null
}
function handleOpenElements() {
if (!selectedTopic.value) return
mainView.value = 'elements'
// Right sidebar stays closed — it opens only when an element is clicked.
}
function handleOpenElementDetail(el) {
elementOpenId.value = el.id
elementOpenTick.value++
elementsOpen.value = true
}
async function handleDeleteGuide(guideId, slots = false) {
await deleteGuide(guideId, slots)
if (previewGuide.value?.id === guideId) {
@@ -422,7 +425,6 @@ onMounted(async () => {
:stats="stats"
:fortschritt="progress"
:locks="locks"
:guideStepsDone="guideStepsDone"
:uiError="uiError"
:doneByFormat="doneByFormat"
:latestByFormat="latestByFormat"
@@ -447,17 +449,12 @@ onMounted(async () => {
@updateSource="handleUpdateSource"
@openBausteineView="handleOpenBlocksView"
@openGuideBoard="handleOpenGuideBoard"
@formatClick="handleFormatClick"
@bausteineClick="handleBlocksClick"
@cancelBlocks="handleCancelBlocks"
@resetBausteine="handleResetBlocks"
@deleteTopic="handleDeleteTopic"
@cancelGuide="handleCancel"
@deleteGuide="handleDeleteGuide"
@dismissError="handleDismissError"
@dismissUiError="uiError = null"
@preview="handlePreview"
@openElements="handleOpenElements"
@openGeneration="handleOpenGeneration"
@togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave"
/>
@@ -469,6 +466,17 @@ onMounted(async () => {
: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="() => handleBlocksClick({ research: false })"
@@ -476,22 +484,13 @@ onMounted(async () => {
@requeueDead="handleRequeueDead"
@removeAll="handleResetBlocks"
@cancel="handleCancelBlocks"
/>
<GuideBoard
v-else-if="selectedTopic && mainView === 'guideboard'"
:topic="selectedTopic"
:format="guideBoardFormat"
@close="mainView = 'blocks'"
@cancelGuide="handleCancel"
@startGuide="handleFormatClick"
@resetStage="handleGuideBoardReset"
@resetGuideStage="handleGuideBoardReset"
@preview="handleGuideBoardPreview"
/>
<ElementsOverview
v-else-if="selectedTopic && mainView === 'elements'"
:topic="selectedTopic"
:version="elementsVersion"
@open="handleOpenElementDetail"
@deleteGuide="handleDeleteGuide"
@restartCard="handleRestartCard"
@resetGuideCard="handleResetGuideCard"
/>
<GeneralExamPanel
v-else-if="selectedTopic && mainView === 'general'"
@@ -505,7 +504,6 @@ onMounted(async () => {
:previewGuide="previewGuide"
:dark="darkMode"
:provider="provider"
:elementsOpen="elementsOpen"
:doneByFormat="doneByFormat"
:themaAbgeschlossen="!!progress.completed"
:ansichtModus="viewMode"
@@ -518,20 +516,6 @@ onMounted(async () => {
<div v-else class="empty-main">
<p>Create or select a topic in the sidebar.</p>
</div>
<div
v-if="elementsOpen && selectedTopic"
class="elements-backdrop"
@click="elementsOpen = false"
></div>
<ElementsSidebar
v-if="elementsOpen && selectedTopic"
:topic="selectedTopic"
:provider="provider"
:openId="elementOpenId"
:openTick="elementOpenTick"
@close="elementsOpen = false"
@changed="elementsVersion++"
/>
</div>
</template>
@@ -685,19 +669,4 @@ textarea::placeholder {
font-size: 1rem;
}
/* Only visible when the elements sidebar sits as an overlay on mobile.
A tap next to it closes it. */
.elements-backdrop {
display: none;
}
@media (max-width: 768px) {
.elements-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 29;
background: var(--shadow);
}
}
</style>

View File

@@ -78,6 +78,24 @@ export async function addBlocksResearch(topic, provider = 'claude') {
return jsonOrThrow(res)
}
export async function restartBlocksCard(topic, cardId) {
const res = await fetch(`${BASE}/blocks/card-restart`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, card_id: cardId }),
})
return jsonOrThrow(res)
}
export async function resetGuideCard(topic, format, blockNorm, abStage) {
const res = await fetch(`${BASE}/guides/board/card-reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, block_norm: blockNorm, ab_stage: abStage }),
})
return jsonOrThrow(res)
}
export async function requeueBlocksDead(topic) {
const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
return jsonOrThrow(res)
@@ -253,68 +271,3 @@ export async function chatGuide(id, { section, outline, messages, provider = 'cl
return res.json()
}
export async function fetchElements(topic) {
const res = await fetch(`${BASE}/elements?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function createElement(topic, hint = '', provider = 'claude') {
const res = await fetch(`${BASE}/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, hint, provider }),
})
return res.json()
}
export async function chatElement(id, messages, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, provider }),
})
return res.json()
}
export async function deleteElement(id) {
await fetch(`${BASE}/elements/${id}`, { method: 'DELETE' })
}
export async function updateElement(id, fields) {
const res = await fetch(`${BASE}/elements/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
})
return res.json()
}
export async function styleElement(id, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/style`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) throw new Error(`Stil-Exam fehlgeschlagen (${res.status})`)
return res.json()
}
export async function refineSuggestion(id, suggestion, instruction, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/refine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ suggestion, instruction, provider }),
})
if (!res.ok) throw new Error(`Überarbeitung fehlgeschlagen (${res.status})`)
return res.json()
}
export async function checkElement(id, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) throw new Error(`Exam fehlgeschlagen (${res.status})`)
return res.json()
}

View File

@@ -1,7 +1,6 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksOverview, fetchBlocksBoard } from '../api.js'
import KanbanBoard from './KanbanBoard.vue'
import { fetchBlocksOverview } from '../api.js'
const props = defineProps({
topic: { type: String, required: true },
@@ -10,71 +9,21 @@ const props = defineProps({
ready: { type: Boolean, default: false },
partial: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch', 'requeueDead', 'removeAll', 'cancel'])
const emit = defineEmits(['close', 'openGeneration'])
// ── Live-Kanban-Board (Poll 1.2s solange generiert) ────────────────────────────
// State ZUERST deklarieren: die immediate-Watches unten rufen load() synchron beim
// Setup — spätere const-Deklarationen wären dort noch TDZ (ReferenceError).
const board = ref(null)
const items = ref([])
const loading = ref(true)
const error = ref(null)
// Während einer Generierung wächst das Grid live nach (leichter Overview-Poll,
// das Kanban-Board selbst lebt in der Generierungs-View).
let timer = null
let lastDone = -1
async function pollBoard() {
try {
board.value = await fetchBlocksBoard(props.topic)
if (board.value.done !== lastDone) { // neue fertige Blöcke → Grid live nachladen
lastDone = board.value.done
load()
}
} catch { /* Board noch leer */ }
}
function startPoll() {
stopPoll()
timer = setInterval(pollBoard, 1200)
}
function stopPoll() {
if (timer) { clearInterval(timer); timer = null }
}
watch(() => props.topic, () => { board.value = null; lastDone = -1; pollBoard(); load() }, { immediate: true })
watch(() => props.generating, (g) => {
if (g) startPoll()
else { stopPoll(); pollBoard(); load() } // Endstand + fertige Blöcke nachladen
}, { immediate: true })
function startPoll() { stopPoll(); timer = setInterval(load, 5000) }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
watch(() => props.topic, () => { items.value = []; load() }, { immediate: true })
watch(() => props.generating, (g) => { if (g) startPoll(); else { stopPoll(); load() } }, { immediate: true })
onUnmounted(stopPoll)
const inventoryCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'inventory'))
const artefactCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'artefacts'))
const boardEmpty = computed(() => !(board.value?.columns || []).some((c) => c.total > 0))
const dead = computed(() => board.value?.dead || [])
// Spalten, auf die zurückgesetzt werden kann (Terminal-Spalten sind kein Reset-Ziel).
const RESETTABLE = new Set(['ingest', 'cluster', 'pair_check', 'consensus_gate', 'clarify', 'naming',
'naming_check', 'fragment_filter', 'grouping', 'gap_check', 'done',
'subblocks', 'facts', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
const sel = ref(null) // gewählte Spalte {board, key, label}
const confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen
function stageClick(c) {
if (props.generating || !RESETTABLE.has(c.key)) return
confirm.value = null
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
}
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
function resetHere(restart) {
const s = sel.value
sel.value = null
confirm.value = null
emit('resetStage', { board: s.board, stage: s.key, restart })
}
// ── Fertige Blöcke (Grid) ──────────────────────────────────────────────────────
const LEVELS = [
{ key: 'beginner', label: 'Beginner' },
@@ -120,59 +69,13 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
<button class="bk-close" title="Close" @click="emit('close')"></button>
</header>
<section class="bk-board">
<div class="bk-steps-top">
<div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div>
<div v-if="!generating" class="bk-global-actions">
<button class="bk-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Research' : 'Generate' }}</button>
<button v-if="partial" class="bk-act" @click="emit('continueAll')">Continue</button>
<button
v-if="dead.length"
class="bk-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="emit('requeueDead')"
> {{ dead.length }} dead</button>
<button
v-if="ready || partial"
class="bk-act danger"
:class="{ armed: confirm === 'remove' }"
@click="arm('remove', () => emit('removeAll'))"
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
</div>
<div v-else class="bk-global-actions">
<button class="bk-act" @click="emit('addResearch')">+ Research</button>
<button class="bk-act danger" @click="emit('cancel')">Cancel</button>
</div>
</div>
<div v-if="boardEmpty && !generating" class="bk-board-empty">No board yet generation streams live cards through the columns here.</div>
<template v-else>
<div class="bk-board-label">Inventar</div>
<KanbanBoard
:columns="inventoryCols"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
@stageClick="stageClick"
/>
<div class="bk-board-label">Artefakte</div>
<KanbanBoard
:columns="artefactCols"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
@stageClick="stageClick"
/>
</template>
<div v-if="sel && !generating" class="bk-step-actions">
<span class="bk-step-actions-label">Ab «{{ sel.label }}»:</span>
<button class="bk-act play" @click="resetHere(true)"> neu generieren</button>
<button class="bk-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button>
<button class="bk-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
</div>
</section>
<button v-if="generating" class="bk-banner" @click="emit('openGeneration')">
<span class="bk-progress-dot"></span>
Generierung läuft{{ progress ? ' · ' + progress : '' }} Board öffnen
</button>
<button v-else-if="!ready && !partial && !items.length && !loading" class="bk-banner idle" @click="emit('openGeneration')">
Noch keine Bausteine zur Generierung
</button>
<div v-if="loading" class="bk-empty-state">Loading</div>
<div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div>
@@ -242,29 +145,6 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
}
.bk-close:hover { border-color: var(--accent); }
/* Live board above the blocks */
.bk-board {
padding: 0.85rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel-soft);
}
.bk-board-label {
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.5rem 0 0.3rem;
}
.bk-board-empty { color: var(--text-faint); font-size: 0.82rem; padding: 0.4rem 0; }
.bk-progress {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
color: var(--accent);
font-weight: 600;
}
.bk-progress-dot {
width: 8px;
height: 8px;
@@ -274,35 +154,27 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
}
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; }
.bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; }
.bk-step-actions {
.bk-banner {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px dashed var(--border-strong);
}
.bk-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
.bk-act {
border: 1px solid var(--border-strong);
border-radius: 6px;
margin: 0.85rem 2rem 0;
padding: 0.55rem 0.9rem;
border: 1px solid var(--accent);
border-radius: 8px;
background: var(--panel);
color: var(--text);
font-size: 0.8rem;
padding: 0.3rem 0.7rem;
cursor: pointer;
color: var(--accent);
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.bk-act:hover { border-color: var(--accent); }
.bk-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.bk-act.play:hover { background: var(--accent-hover); }
.bk-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.bk-act.danger.armed { background: var(--danger); color: #fff; }
.bk-act.ghost { color: var(--text-muted); }
.bk-banner.idle { border-color: var(--border-strong); color: var(--text-muted); }
.bk-banner:hover { background: var(--panel-soft); }
.bk-empty-state {
flex: 1;

View File

@@ -1,167 +0,0 @@
<script setup>
import { ref, watch } from 'vue'
import { fetchElements } from '../api.js'
import { renderMarkdown, plainText } from '../markdown.js'
const props = defineProps({
topic: { type: String, required: true },
version: { type: Number, default: 0 }, // increment = reload elements
})
const emit = defineEmits(['open'])
const elements = ref([])
watch([() => props.topic, () => props.version], load, { immediate: true })
async function load() {
try {
elements.value = await fetchElements(props.topic)
} catch (e) {
console.error('Failed to load elements:', e)
}
}
</script>
<template>
<div class="elements-overview">
<div class="overview-scroll">
<div class="overview-content">
<header class="overview-head">
<h1>{{ topic }}</h1>
<span class="overview-format">Elements</span>
</header>
<p v-if="!elements.length" class="overview-empty">
No elements yet. Enter a keyword in the sidebar on the right and click +.
</p>
<div class="element-grid">
<article
v-for="el in elements"
:key="el.id"
class="element-card"
@click="emit('open', el)"
>
<h3>{{ plainText(el.title) }}</h3>
<div class="markdown" v-html="renderMarkdown(el.description)"></div>
<div v-for="(ex, i) in el.examples" :key="i" class="markdown el-example" v-html="renderMarkdown(ex)"></div>
<div v-if="el.hints.length" class="el-hints-block">
<h4>Hints</h4>
<ul>
<li v-for="(h, i) in el.hints" :key="i" class="markdown" v-html="renderMarkdown(h)"></li>
</ul>
</div>
</article>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.elements-overview {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-preview);
}
.overview-scroll {
flex: 1;
overflow-y: auto;
}
.overview-content {
max-width: 1000px;
margin: 0 auto;
padding: 2.5rem 2rem 4rem;
}
.overview-head {
display: flex;
align-items: baseline;
gap: 0.8rem;
margin-bottom: 1.5rem;
}
.overview-head h1 {
margin: 0;
font-size: 2.2rem;
color: var(--text);
}
.overview-format {
font-size: 1rem;
font-weight: 600;
color: var(--text-faint);
}
.overview-empty {
color: var(--text-muted);
}
.element-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1rem;
align-items: start;
}
.element-card {
background: var(--panel);
border: 1px solid var(--border);
border-top: 3px solid var(--accent);
border-radius: 10px;
padding: 1rem 1.1rem;
cursor: pointer;
transition: box-shadow 0.15s, transform 0.15s;
}
.element-card:hover {
box-shadow: 0 4px 16px var(--shadow);
transform: translateY(-1px);
}
.element-card h3 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: var(--text);
}
.el-example {
margin-top: 0.5rem;
}
.el-hints-block {
margin-top: 0.7rem;
}
.el-hints-block h4 {
margin: 0 0 0.3rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.el-hints-block ul {
margin: 0;
padding-left: 1.1rem;
}
.el-hints-block li {
font-size: 0.85rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.2rem;
}
/* Markdown: base styles global (assets/markdown.css), here only the card base font */
.markdown {
font-size: 0.9rem;
line-height: 1.55;
color: var(--text);
}
</style>

View File

@@ -0,0 +1,265 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksBoard } from '../api.js'
import KanbanBoard from './KanbanBoard.vue'
import GuideBoardSection from './GuideBoardSection.vue'
const props = defineProps({
topic: { type: String, required: true },
generating: { type: Boolean, default: false },
progress: { type: String, default: null },
ready: { type: Boolean, default: false },
partial: { type: Boolean, default: false },
guideFormat: { type: String, default: 'Guide' },
})
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'deleteGuide', 'restartCard', 'resetGuideCard'])
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ──────────────────────────────
const board = ref(null)
let timer = null
async function pollBoard() {
try {
board.value = await fetchBlocksBoard(props.topic)
} catch { /* Board noch leer */ }
}
function startPoll() { stopPoll(); timer = setInterval(pollBoard, 1200) }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
watch(() => props.topic, () => { board.value = null; pollBoard() }, { immediate: true })
watch(() => props.generating, (g) => {
if (g) startPoll()
else { stopPoll(); pollBoard() } // Endstand nachladen
}, { immediate: true })
onUnmounted(stopPoll)
const inventoryCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'inventory'))
const artefactCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'artefacts'))
const boardEmpty = computed(() => !(board.value?.columns || []).some((c) => c.total > 0))
const dead = computed(() => board.value?.dead || [])
// Spalten, auf die zurückgesetzt werden kann (Terminal-Spalten sind kein Reset-Ziel).
const RESETTABLE = new Set(['ingest', 'cluster', 'pair_check', 'consensus_gate', 'clarify', 'naming',
'naming_check', 'fragment_filter', 'grouping', 'gap_check', 'done',
'subblocks', 'facts', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
const sel = ref(null) // gewählte Spalte {board, key, label}
const selCard = ref(null) // gewählte Karte (Einzel-Restart, nur artefacts)
const confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen
function stageClick(c) {
if (props.generating || !RESETTABLE.has(c.key)) return
confirm.value = null
selCard.value = null
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
}
function cardClick(k) {
if (props.generating || k.kind !== 'ablock') return // Einzel-Restart nur für Artefakt-Karten
confirm.value = null
sel.value = null
selCard.value = selCard.value?.card_id === k.card_id ? null : k
}
function restartCard() {
const k = selCard.value
selCard.value = null
confirm.value = null
emit('restartCard', k.card_id)
}
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
function resetHere(restart) {
const s = sel.value
sel.value = null
confirm.value = null
emit('resetStage', { board: s.board, stage: s.key, restart })
}
</script>
<template>
<div class="gen-view">
<header class="gen-head">
<h1>{{ topic }}</h1>
<span class="gen-sub">Generierung</span>
<span class="gen-spacer"></span>
<button class="gen-close" title="Close" @click="emit('close')"></button>
</header>
<section class="gen-section">
<div class="gen-steps-top">
<span class="gen-title">Bausteine</span>
<div v-if="progress" class="gen-progress"><span class="gen-progress-dot"></span>{{ progress }}</div>
<div v-if="!generating" class="gen-actions">
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Research' : 'Generate' }}</button>
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Continue</button>
<button
v-if="ready || partial"
class="gen-act danger"
:class="{ armed: confirm === 'remove' }"
@click="arm('remove', () => emit('removeAll'))"
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
</div>
<div v-else class="gen-actions">
<button class="gen-act" @click="emit('addResearch')">+ Research</button>
<button class="gen-act danger" @click="emit('cancel')">Cancel</button>
</div>
<button
v-if="dead.length"
class="gen-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="emit('requeueDead')"
> {{ dead.length }} dead</button>
</div>
<div v-if="boardEmpty && !generating" class="gen-empty">No board yet generation streams live cards through the columns here.</div>
<template v-else>
<div class="gen-board-label">Inventar</div>
<KanbanBoard
:columns="inventoryCols"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
@stageClick="stageClick"
/>
<div class="gen-board-label">Artefakte</div>
<KanbanBoard
:columns="artefactCols"
:generating="generating"
:selectable="!generating"
:cardSelectable="!generating"
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
@stageClick="stageClick"
@cardClick="cardClick"
/>
</template>
<div v-if="selCard && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Karte «{{ selCard.title }}»:</span>
<button class="gen-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? restartCard() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : ' Karte neu generieren' }}</button>
<button class="gen-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
</div>
<div v-if="sel && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Ab «{{ sel.label }}»:</span>
<button class="gen-act play" @click="resetHere(true)"> neu generieren</button>
<button class="gen-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button>
<button class="gen-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
</div>
</section>
<section class="gen-section">
<GuideBoardSection
:topic="topic"
:format="guideFormat"
@cancelGuide="(id) => emit('cancelGuide', id)"
@startGuide="(p) => emit('startGuide', p)"
@resetStage="(p) => emit('resetGuideStage', p)"
@preview="emit('preview')"
@deleteGuide="(id) => emit('deleteGuide', id)"
@resetCard="(p) => emit('resetGuideCard', p)"
/>
</section>
</div>
</template>
<style scoped>
.gen-view {
flex: 1;
min-width: 0;
height: 100dvh;
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--bg-preview);
}
.gen-head {
position: sticky;
top: 0;
z-index: 3;
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 1.25rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.gen-head h1 { font-size: 1.5rem; }
.gen-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
.gen-spacer { flex: 1; }
.gen-close {
align-self: center;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
width: 2rem;
height: 2rem;
cursor: pointer;
}
.gen-close:hover { border-color: var(--accent); }
.gen-section {
padding: 0.85rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel-soft);
}
.gen-title { font-size: 0.9rem; font-weight: 700; }
.gen-board-label {
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.5rem 0 0.3rem;
}
.gen-empty { color: var(--text-faint); font-size: 0.82rem; padding: 0.4rem 0; }
.gen-progress {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
color: var(--accent);
font-weight: 600;
}
.gen-progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: gen-pulse 1.2s ease-in-out infinite;
}
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.gen-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; }
.gen-actions { margin-left: auto; display: flex; gap: 0.4rem; }
.gen-step-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px dashed var(--border-strong);
}
.gen-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
.gen-act {
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 0.8rem;
padding: 0.3rem 0.7rem;
cursor: pointer;
font-weight: 600;
}
.gen-act:hover { border-color: var(--accent); }
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gen-act.play:hover { background: var(--accent-hover); }
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gen-act.danger.armed { background: var(--danger); color: #fff; }
.gen-act.ghost { color: var(--text-muted); }
</style>

View File

@@ -7,7 +7,7 @@ const props = defineProps({
topic: { type: String, required: true },
format: { type: String, default: 'Guide' },
})
const emit = defineEmits(['close', 'cancelGuide', 'startGuide', 'resetStage', 'preview'])
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'deleteGuide', 'resetCard'])
const board = ref(null)
let timer = null
@@ -32,13 +32,31 @@ const done = computed(() => columns.value.find((c) => c.key === 'done')?.total |
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit']
const sel = ref(null)
const selCard = ref(null)
const confirm = ref(null)
function stageClick(c) {
if (generating.value || !STAGES.includes(c.key)) return
confirm.value = null
selCard.value = null
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) }
}
function cardClick(k) {
if (generating.value || !k.card_id) return
confirm.value = null
sel.value = null
const idx = Math.max(0, STAGES.indexOf(k.column))
selCard.value = selCard.value?.card_id === k.card_id ? null : { ...k, idx }
}
function resetCardHere() {
const k = selCard.value
selCard.value = null
confirm.value = null
emit('resetCard', { format: props.format, blockNorm: k.card_id, abStage: 0 })
setTimeout(poll, 400)
}
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
@@ -58,86 +76,56 @@ function resetHere() {
</script>
<template>
<div class="gb-view">
<header class="gb-head">
<h1>{{ topic }}</h1>
<span class="gb-sub">Guide-Board · {{ format }}</span>
<section class="gb-board">
<div class="gb-top">
<span class="gb-title">Guide · {{ format }}</span>
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
<span class="gb-spacer"></span>
<button class="gb-close" title="Close" @click="emit('close')"></button>
</header>
<section class="gb-board">
<div class="gb-top">
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
<div v-if="board?.error" class="gb-error">{{ board.error }}</div>
<div class="gb-actions">
<template v-if="generating">
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
</template>
<template v-else>
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
</template>
</div>
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
<div v-if="board?.error" class="gb-error">{{ board.error }}</div>
<div class="gb-actions">
<template v-if="generating">
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
</template>
<template v-else>
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
<button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => emit('deleteGuide', board?.guide_id))">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button>
</template>
</div>
</div>
<KanbanBoard
:columns="columns"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.key || null"
@stageClick="stageClick"
/>
<KanbanBoard
:columns="columns"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:cardSelectable="!generating"
:selectedKey="sel?.key || null"
@stageClick="stageClick"
@cardClick="cardClick"
/>
<div v-if="sel && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span>
<button class="gb-act play" @click="restartHere"> neu generieren</button>
<button class="gb-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', resetHere)">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button>
<button class="gb-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
</div>
<div v-if="selCard && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Karte «{{ selCard.title }}»:</span>
<button class="gb-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? resetCardHere() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : ' Karte neu (ab Lernziele)' }}</button>
<button class="gb-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
</div>
<div v-if="sel && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span>
<button class="gb-act play" @click="restartHere"> neu generieren</button>
<button class="gb-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', resetHere)">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button>
<button class="gb-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
</div>
<div v-if="!total && !generating" class="gb-empty">Noch kein Board «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div>
</section>
</div>
<div v-if="!total && !generating" class="gb-empty">Noch kein Board «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div>
</section>
</template>
<style scoped>
.gb-view {
flex: 1;
min-width: 0;
height: 100dvh;
display: flex;
flex-direction: column;
background: var(--bg-preview);
}
.gb-head {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 1.25rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.gb-head h1 { font-size: 1.5rem; }
.gb-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
.gb-spacer { flex: 1; }
.gb-close {
align-self: center;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
width: 2rem;
height: 2rem;
cursor: pointer;
}
.gb-close:hover { border-color: var(--accent); }
.gb-board { padding: 0.85rem 2rem; }
.gb-board { padding: 0.85rem 0 0; }
.gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; }
.gb-title { font-size: 0.9rem; font-weight: 700; }
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
.gb-progress {
display: flex;
align-items: center;

View File

@@ -6,10 +6,11 @@ const props = defineProps({
agents: { type: Array, default: () => [] }, // [{label, runtime}]
generating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte)
cardSelectable: { type: Boolean, default: false }, // Karten klickbar (Einzel-Restart)
selectedKey: { type: String, default: null },
hideEmpty: { type: Boolean, default: false }, // leere Spalten ausblenden (Terminal-Spalten)
})
const emit = defineEmits(['stageClick'])
const emit = defineEmits(['stageClick', 'cardClick'])
function visible(c) {
return !props.hideEmpty || c.total > 0
@@ -24,14 +25,14 @@ function fmtRuntime(s) {
<div class="kb">
<div v-if="agents.length" class="kb-agents">
<span class="kb-agents-label">{{ agents.length }} Agent(en):</span>
<span v-for="a in agents" :key="a.label" class="kb-agent">{{ a.label }} · {{ fmtRuntime(a.runtime) }}</span>
<span v-for="(a, i) in agents" :key="a.key || i" class="kb-agent" :title="a.key">{{ a.label }} · {{ fmtRuntime(a.runtime) }}</span>
</div>
<div class="kb-cols">
<div
v-for="c in columns.filter(visible)"
:key="(c.board || '') + c.key"
class="kb-col"
:class="{ active: c.total > 0, sel: selectedKey === c.key }"
:class="{ active: c.total > 0, sel: selectedKey === c.key, collapsed: !c.total }"
>
<button
class="kb-col-head"
@@ -43,7 +44,12 @@ function fmtRuntime(s) {
<span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span>
</button>
<ul v-if="c.cards && c.cards.length" class="kb-cards">
<li v-for="(k, i) in c.cards" :key="i" class="kb-card" :class="k.status" :title="k.info || k.title">
<li
v-for="(k, i) in c.cards" :key="i" class="kb-card"
:class="[k.status, { klickbar: cardSelectable && k.card_id }]"
:title="cardSelectable && k.card_id ? `${k.title} — Klick: Karte neu generieren` : (k.info || k.title)"
@click="cardSelectable && k.card_id && emit('cardClick', { ...k, column: c.key, colBoard: c.board })"
>
<div class="kb-card-row">
<span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span>
<span class="kb-card-title">{{ k.title }}</span>
@@ -51,6 +57,13 @@ function fmtRuntime(s) {
<span v-if="k.ziele" class="kb-badge ziele" title="Lernziele abgedeckt">{{ k.ziele }}</span>
</div>
<div v-if="k.info && k.status === 'active'" class="kb-card-info">{{ k.info }}</div>
<div v-if="k.step_n && k.status === 'active'" class="kb-steps" :title="k.steps ? k.steps.join(' → ') : ''">
<span
v-for="s in k.step_n" :key="s" class="kb-step"
:class="{ done: s < k.step_i, act: s === k.step_i }"
:title="k.steps ? k.steps[s - 1] : ''"
></span>
</div>
</li>
<li v-if="c.total > c.cards.length" class="kb-more">+{{ c.total - c.cards.length }} weitere</li>
</ul>
@@ -81,12 +94,15 @@ function fmtRuntime(s) {
.kb-cols {
display: flex;
gap: 0.45rem;
overflow-x: auto;
align-items: flex-start;
overflow-x: auto; /* Fallback (schmale Screens) — am Desktop passt alles dank Kollaps */
align-items: stretch;
padding-bottom: 0.3rem;
}
.kb-col {
flex: 0 0 150px;
flex: 1 1 150px;
min-width: 140px;
max-width: 230px;
align-self: flex-start;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
@@ -95,6 +111,36 @@ function fmtRuntime(s) {
.kb-col.active { opacity: 1; border-color: var(--border-strong); }
.kb-col.sel { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
/* Leere Spalten kollabieren zu schmalen Säulen (Jira-Muster) — der Kopf bleibt
klickbar (Reset ab Spalte), das Label läuft vertikal. */
.kb-col.collapsed {
flex: 0 0 auto;
min-width: 0;
width: 32px;
align-self: stretch;
opacity: 0.5;
}
.kb-col.collapsed:hover { opacity: 0.85; }
.kb-col.collapsed .kb-col-head {
flex-direction: column-reverse;
justify-content: flex-end;
align-items: center;
gap: 0.4rem;
height: 100%;
min-height: 130px;
border-bottom: none;
border-radius: 8px;
padding: 0.4rem 0;
}
.kb-col.collapsed .kb-col-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 0.6rem;
overflow: hidden;
text-overflow: ellipsis;
max-height: 150px;
}
.kb-col-head {
width: 100%;
display: flex;
@@ -151,6 +197,9 @@ function fmtRuntime(s) {
.kb-card.error { border-color: var(--danger); }
.kb-card.active { border-color: var(--accent-border); }
.kb-card-row { display: flex; align-items: center; gap: 0.35rem; }
.kb-card.klickbar { cursor: pointer; }
.kb-card.klickbar:hover { border-color: var(--accent); }
.kb-card-info {
margin: 0.15rem 0 0 1rem;
font-size: 0.66rem;
@@ -159,6 +208,29 @@ function fmtRuntime(s) {
text-overflow: ellipsis;
white-space: nowrap;
}
/* Phase stepper: one segment per fine step of the card's stage */
.kb-steps {
display: flex;
gap: 3px;
margin: 0.25rem 0 0.05rem 1rem;
}
.kb-step {
flex: 1;
max-width: 26px;
height: 3px;
border-radius: 2px;
background: var(--border, #444);
}
.kb-step.done { background: var(--accent, #7aa2f7); opacity: 0.55; }
.kb-step.act {
background: var(--accent, #7aa2f7);
animation: kbStepPulse 1.2s ease-in-out infinite;
}
@keyframes kbStepPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.kb-card-title {
flex: 1;
min-width: 0;

View File

@@ -22,7 +22,6 @@ const props = defineProps({
previewGuide: { type: Object, default: null },
dark: { type: Boolean, default: false },
provider: { type: String, default: 'claude' },
elementsOpen: { type: Boolean, default: false }, // element sidebar open → chat to the left
doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related)
themaAbgeschlossen: { type: Boolean, default: false },
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
@@ -209,12 +208,6 @@ function closeChat() {
chat.reset()
}
// On mobile, chat and element sidebar are mutually exclusive —
// there is no room side by side, the sidebar would cover the chat.
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
@@ -354,9 +347,9 @@ function extractContext() {
@section-updated="onSectionUpdated"
/>
<button v-if="previewGuide && !chatOpen && focusIndex === null" class="chat-fab" :class="{ shifted: elementsOpen }" title="Questions about the guide" @click="openChat">💬</button>
<button v-if="previewGuide && !chatOpen && focusIndex === null" class="chat-fab" title="Questions about the guide" @click="openChat">💬</button>
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }">
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel">
<header class="chat-header">
<span>Questions about the guide</span>
<button class="chat-close" title="Close chat" @click="closeChat">×</button>
@@ -640,23 +633,6 @@ function extractContext() {
background: var(--accent-hover);
}
/* Element sidebar (320px) open → show chat to its left */
.chat-fab.shifted {
right: calc(1.5rem + 320px);
}
.chat-panel.shifted {
right: calc(1.5rem + 320px);
}
/* On mobile the element sidebar overlays the chat — hide FAB/panel */
@media (max-width: 768px) {
.chat-fab.shifted,
.chat-panel.shifted {
display: none;
}
}
.chat-panel {
position: fixed;
right: 1.5rem;

View File

@@ -9,7 +9,6 @@ const props = defineProps({
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: () => ({}) },
@@ -26,7 +25,7 @@ const props = defineProps({
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'])
const emit = defineEmits(['select', 'createThema', 'updateSource', 'bausteineClick', 'deleteTopic', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openGeneration', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
// Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'.
const openPanel = ref(null)
@@ -85,14 +84,6 @@ const activeGenerations = computed(() => {
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')
@@ -108,26 +99,6 @@ function guideStatus(format) {
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 ''
@@ -141,23 +112,11 @@ function aborted(format) {
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Cancelled')
}
// Name click: finished guide → preview, otherwise toggle action panel.
// Name click: finished guide → preview, otherwise the generation view (start/cancel live there).
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
else emit('openGuideBoard', format)
}
// Flash-message behavior: × only hides, nothing is deleted
@@ -166,25 +125,6 @@ function dismissError(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: '' })
@@ -347,7 +287,7 @@ function saveSource() {
<div class="ord-blocks">
<div
class="format-row blocks-row"
:class="{ 'is-active': blocksState === 'generating' || blocks.partial, 'row-open': isOpen('blocks') }"
:class="{ 'is-active': blocksState === 'generating' || blocks.partial }"
>
<button class="format-name blocks-name" @click="onBlocksName">
<span class="format-label">Blocks</span>
@@ -357,20 +297,6 @@ function saveSource() {
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' }}
@@ -378,10 +304,16 @@ function saveSource() {
<div v-if="blocks.error && !blocks.error.startsWith('Cancelled')" class="format-error">
<span class="format-error-text">{{ blocks.error }}</span>
</div>
<div class="format-row">
<button class="format-name" @click="emit('openGeneration')">
<span class="format-label gen-label">Generierung</span>
<span v-if="blocksState === 'generating'" class="gen-live-dot" title="Läuft"></span>
</button>
</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) }]">
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': aborted(f.key) }]">
<button class="format-name" @click="handleFormatClick(f.key)">
<span class="format-label">{{ f.label }}</span>
<span
@@ -389,37 +321,7 @@ function saveSource() {
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'"
@@ -435,11 +337,6 @@ function saveSource() {
<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">
@@ -723,46 +620,12 @@ function saveSource() {
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; }
@@ -784,6 +647,16 @@ function saveSource() {
flex-direction: column;
}
.gen-label { color: var(--text-muted); font-size: 0.86em; }
.gen-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
animation: gen-side-pulse 1.2s ease-in-out infinite;
}
@keyframes gen-side-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.ord-blocks {
order: 2;
}
@@ -792,10 +665,6 @@ function saveSource() {
order: 4;
}
.ord-elemente {
order: 5;
}
.elements-btn {
cursor: pointer;
color: var(--text);
@@ -889,34 +758,7 @@ function saveSource() {
.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); }

View File

@@ -1,147 +0,0 @@
<script setup>
import { watch } from 'vue'
import { chatElement } from '../../api.js'
import { useChat } from '../../composables/useChat.js'
const props = defineProps({
element: { type: Object, required: true },
provider: { type: String, default: 'claude' },
})
const emit = defineEmits(['changes'])
const chat = useChat((msgs) => chatElement(props.element.id, msgs, props.provider))
const { messages, input, loading, messagesEl, inputEl, onScroll } = chat
// Different element selected → discard history
watch(() => props.element.id, () => chat.reset())
async function send() {
const res = await chat.send()
if (res?.changes?.length) emit('changes', res.changes)
}
</script>
<template>
<div class="el-chat">
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
<p v-if="!messages.length" class="chat-hint">Write what should be changed on the element.</p>
<template v-for="(m, i) in messages" :key="i">
<div :class="['chat-msg', m.role]">{{ m.content }}</div>
</template>
<div v-if="loading" class="chat-msg assistant chat-typing">Adjusting</div>
</div>
<div class="chat-input">
<textarea
ref="inputEl"
v-model="input"
placeholder="Adjust element…"
@keydown.enter.exact.prevent="send"
></textarea>
<button
:disabled="!input.trim() && !loading"
:class="{ cancel: loading }"
:title="loading ? 'Cancel' : 'Send'"
@click="send"
>{{ loading ? '✕' : '➤' }}</button>
</div>
</div>
</template>
<style scoped>
.el-chat {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-hint {
color: var(--text-faint);
font-size: 0.78rem;
text-align: center;
margin-top: 0.5rem;
}
.chat-msg {
max-width: 85%;
padding: 6px 10px;
border-radius: 12px;
font-size: 0.82rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent);
color: var(--on-accent);
border-bottom-right-radius: 3px;
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--panel-soft);
color: var(--text);
border-bottom-left-radius: 3px;
}
.chat-typing {
color: var(--text-faint);
font-style: italic;
}
.chat-input {
display: flex;
gap: 6px;
padding: 0.6rem;
border-top: 1px solid var(--border);
}
.chat-input textarea {
flex: 1;
resize: none;
height: 72px;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
background: var(--panel);
color: var(--text);
outline: none;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input button {
width: 38px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1rem;
cursor: pointer;
}
.chat-input button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.chat-input button.cancel {
background: var(--danger);
}
</style>

View File

@@ -1,455 +0,0 @@
<script setup>
import { ref, watch } from 'vue'
import { updateElement, checkElement, styleElement, refineSuggestion } from '../../api.js'
import { renderMarkdown, plainText } from '../../markdown.js'
import ElementSuggestion from './ElementSuggestion.vue'
import ElementChatTab from './ElementChatTab.vue'
import ElementEditTab from './ElementEditTab.vue'
const props = defineProps({
element: { type: Object, required: true },
provider: { type: String, default: 'claude' },
})
const emit = defineEmits(['back', 'close', 'updated', 'changed'])
const tab = ref('overview') // 'overview' | 'chat' | 'edit'
const savingEdit = ref(false)
// Strip Markdown characters from the header title
// Different element selected → reset exam state and tab
watch(() => props.element.id, () => {
tab.value = 'overview'
resetCheck()
})
// --- AI exam for missing info (results land as inline suggestions) ---
const checking = ref(false)
const statusMsg = ref(null)
function resetCheck() {
checking.value = false
statusMsg.value = null
resetStyle()
}
let checkRun = 0 // identify the running exam; cancellation ignores its result
async function runCheck() {
if (checking.value) { // second click = cancel
checkRun++
checking.value = false
return
}
const run = ++checkRun
checking.value = true
statusMsg.value = null
try {
const res = await checkElement(props.element.id, props.provider)
if (run !== checkRun) return // cancelled or a new exam started
const mapped = res.suggestions.map((s) => ({
text: s.text, action: 'add', target: s.target, index: null, content: s.content,
}))
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
else statusMsg.value = 'No important gaps found.'
} catch (e) {
if (run !== checkRun) return
console.error('Exam failed:', e)
statusMsg.value = 'Exam failed — please try again.'
} finally {
if (run === checkRun) checking.value = false
}
}
// --- Style exam: AI proposes changes, user confirms ---
const styleChanges = ref(null) // null = not yet examined
const styling = ref(false)
const applyingStyle = ref(false)
const refiningIdx = ref(null)
let styleRun = 0
function resetStyle() {
styleChanges.value = null
styling.value = false
applyingStyle.value = false
refiningIdx.value = null
}
function suggBusy(i) {
return applyingStyle.value || refiningIdx.value === i
}
// Refine a single suggestion via instruction (pencil icon)
async function refineChange(i, instruction) {
if (refiningIdx.value !== null || applyingStyle.value) return
refiningIdx.value = i
try {
const res = await refineSuggestion(props.element.id, styleChanges.value[i], instruction, props.provider)
const next = [...styleChanges.value]
next[i] = res.change
styleChanges.value = next
} catch (e) {
console.error('Refinement failed:', e)
statusMsg.value = 'Refinement failed — please try again.'
} finally {
refiningIdx.value = null
}
}
async function runStyle() {
if (styling.value) { // second click = cancel
styleRun++
styling.value = false
return
}
const run = ++styleRun
styling.value = true
statusMsg.value = null
try {
const res = await styleElement(props.element.id, props.provider)
if (run !== styleRun) return
if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes]
else statusMsg.value = 'Style already fits.'
} catch (e) {
if (run !== styleRun) return
console.error('Style exam failed:', e)
statusMsg.value = 'Style exam failed — please try again.'
} finally {
if (run === styleRun) styling.value = false
}
}
// Chat suggestions also land as inline suggestions in the overview
function onChatChanges(changes) {
styleChanges.value = [...(styleChanges.value || []), ...changes]
}
// Show suggestions at the target location: adjust/remove at the affected entry …
function styleAt(target, index = null) {
if (!styleChanges.value) return []
return styleChanges.value
.map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.index === index && c.action !== 'add')
}
// … additions at the end of the respective section
function styleAdds(target) {
if (!styleChanges.value) return []
return styleChanges.value
.map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.action === 'add')
}
function dismissStyleChange(i) {
styleChanges.value = styleChanges.value.filter((_, j) => j !== i)
}
async function applyStyleChange(i) {
if (applyingStyle.value) return
const c = styleChanges.value[i]
applyingStyle.value = true
try {
const STRING_TARGETS = ['title', 'description']
const fields = {
title: props.element.title,
description: props.element.description,
examples: [...props.element.examples],
hints: [...props.element.hints],
}
if (c.action === 'remove') fields[c.target].splice(c.index, 1)
else if (c.action === 'add') {
if (c.target === 'title') fields.title = c.content
else if (c.target === 'description')
fields[c.target] = fields[c.target] ? fields[c.target] + '\n\n' + c.content : c.content
else fields[c.target].push(c.content)
} else if (STRING_TARGETS.includes(c.target)) fields[c.target] = c.content
else fields[c.target][c.index] = c.content
const updated = await updateElement(props.element.id, fields)
emit('updated', updated)
// Keep remaining suggestions; indices after a removal shift up
const rest = styleChanges.value.filter((_, j) => j !== i)
if (c.action === 'remove') {
for (const r of rest) {
if (r.target === c.target && r.index !== null && r.index > c.index) r.index--
}
}
styleChanges.value = rest
} catch (e) {
console.error('Apply failed:', e)
} finally {
applyingStyle.value = false
}
}
// --- Edit tab: save fields directly ---
async function saveEdit(fields) {
if (savingEdit.value) return
savingEdit.value = true
try {
const updated = await updateElement(props.element.id, fields)
emit('updated', updated)
tab.value = 'overview'
} catch (e) {
console.error('Save failed:', e)
} finally {
savingEdit.value = false
}
}
</script>
<template>
<header class="el-header">
<button class="el-back" title="Back to list" @click="emit('back')"></button>
<span class="el-title">{{ plainText(element.title) }}</span>
<button
class="el-tool" :class="{ busy: checking }"
:title="checking ? 'Cancel exam' : 'Check for missing info'" @click="runCheck"
>🔍</button>
<button
class="el-tool" :class="{ busy: styling }"
:title="styling ? 'Cancel exam' : 'Check & adjust style'" @click="runStyle"
></button>
<button class="el-close" title="Close" @click="emit('close')">×</button>
</header>
<nav class="el-tabs">
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Overview</button>
<button :class="{ active: tab === 'chat' }" @click="tab = 'chat'">Chat</button>
<button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Edit</button>
</nav>
<!-- Overview: inseparably intertwined with styleChanges/apply stays here -->
<div v-show="tab === 'overview'" class="el-detail">
<div v-if="element.description" class="el-desc markdown" v-html="renderMarkdown(element.description)"></div>
<ElementSuggestion
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
:key="'sgd' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
<template v-for="(ex, i) in element.examples" :key="i">
<div class="el-entry markdown" v-html="renderMarkdown(ex)"></div>
<ElementSuggestion
v-for="[ci, c] in styleAt('examples', i)"
:key="'sge' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
</template>
<ElementSuggestion
v-for="[ci, c] in styleAdds('examples')"
:key="'sgea' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
<div v-if="element.hints.length || styleAdds('hints').length" class="el-hints-block">
<h4>Hints</h4>
<ul class="el-hints">
<li v-for="(h, i) in element.hints" :key="i">
<span class="markdown" v-html="renderMarkdown(h)"></span>
<ElementSuggestion
v-for="[ci, c] in styleAt('hints', i)"
:key="'sgh' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
</li>
</ul>
<ElementSuggestion
v-for="[ci, c] in styleAdds('hints')"
:key="'sgha' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
</div>
<div v-if="checking || styling || statusMsg" class="el-check">
<p v-if="checking" class="check-empty busy-text">Checking for missing info</p>
<p v-if="styling" class="check-empty busy-text">Checking the style</p>
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
</div>
</div>
<!-- v-show preserves the chat history when switching tabs -->
<ElementChatTab
v-show="tab === 'chat'"
:element="element"
:provider="provider"
@changes="onChatChanges"
/>
<!-- v-if loads the edit fields fresh on every open -->
<ElementEditTab
v-if="tab === 'edit'"
:element="element"
:saving="savingEdit"
@save="saveEdit"
/>
</template>
<style scoped>
.el-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--border);
}
.el-title {
flex: 1;
font-weight: 600;
font-size: 0.9rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-back,
.el-close {
border: none;
background: none;
color: var(--text-faint);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.el-back:hover,
.el-close:hover {
color: var(--text);
}
.el-tool {
border: none;
background: none;
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
padding: 2px 3px;
border-radius: 6px;
filter: grayscale(0.4);
}
.el-tool:hover {
background: var(--panel-soft);
filter: none;
}
.el-tool.busy {
filter: none;
animation: pulse 1.5s ease-in-out infinite;
}
.busy-text {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.35; }
}
.el-tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.el-tabs button {
flex: 1;
padding: 0.5rem 0.25rem;
border: none;
background: none;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.el-tabs button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.el-tabs button:hover:not(.active) {
color: var(--text);
}
.el-detail {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
}
.el-desc {
margin: 0 0 0.9rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.el-hints-block {
margin-top: 0.9rem;
}
.el-hints-block h4 {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.el-entry {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.4rem;
}
.el-entry:last-child {
margin-bottom: 0;
}
.el-hints {
margin: 0;
padding-left: 1.1rem;
}
.el-hints li {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.25rem;
}
/* Keep hint text inline next to the bullet (p is block otherwise) */
.el-hints li > .markdown {
display: inline;
}
.el-hints li > .markdown :deep(p) {
display: inline;
margin: 0;
}
/* Markdown: base is global (assets/markdown.css); narrow sidebar → more compact code blocks */
.markdown :deep(pre) {
padding: 8px 10px;
}
/* --- AI exam --- */
.el-check {
margin-top: 1rem;
padding-top: 0.8rem;
border-top: 1px dashed var(--border-strong);
}
.check-empty {
margin: 0.6rem 0 0;
font-size: 0.78rem;
color: var(--text-faint);
text-align: center;
}
</style>

View File

@@ -1,164 +0,0 @@
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
element: { type: Object, required: true },
saving: { type: Boolean, default: false },
})
const emit = defineEmits(['save'])
const edit = ref({ title: '', description: '', examples: [], hints: [] })
watch(() => props.element, load, { immediate: true })
function load() {
edit.value = {
title: props.element.title,
description: props.element.description,
examples: [...props.element.examples],
hints: [...props.element.hints],
}
}
function save() {
if (props.saving) return
emit('save', {
title: edit.value.title,
description: edit.value.description,
examples: edit.value.examples.filter((s) => s.trim()),
hints: edit.value.hints.filter((s) => s.trim()),
})
}
</script>
<template>
<div class="el-edit">
<button class="edit-save" :disabled="saving" @click="save">
{{ saving ? 'Saving' : 'Save' }}
</button>
<label>Title</label>
<input v-model="edit.title" placeholder="Title" />
<label>Description</label>
<textarea v-model="edit.description" placeholder="Description"></textarea>
<label>Examples</label>
<div v-for="(ex, i) in edit.examples" :key="'ex' + i" class="edit-row">
<textarea v-model="edit.examples[i]" placeholder="Example"></textarea>
<button class="edit-del" title="Remove" @click="edit.examples.splice(i, 1)">×</button>
</div>
<button class="edit-add" @click="edit.examples.push('')">+ Example</button>
<label>Hints</label>
<div v-for="(h, i) in edit.hints" :key="'hi' + i" class="edit-row">
<textarea v-model="edit.hints[i]" placeholder="Hint"></textarea>
<button class="edit-del" title="Remove" @click="edit.hints.splice(i, 1)">×</button>
</div>
<button class="edit-add" @click="edit.hints.push('')">+ Hint</button>
</div>
</template>
<style scoped>
.el-edit {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.el-edit label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin-top: 0.5rem;
}
.el-edit input,
.el-edit textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
background: var(--panel);
color: var(--text);
outline: none;
}
.el-edit textarea {
resize: vertical;
min-height: 120px;
overflow: auto;
line-height: 1.4;
}
.el-edit input:focus,
.el-edit textarea:focus {
border-color: var(--accent);
}
.edit-row {
display: flex;
gap: 6px;
align-items: flex-start;
}
.edit-row textarea {
flex: 1;
}
.edit-del {
flex-shrink: 0;
width: 30px;
align-self: stretch;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: none;
color: var(--danger);
font-size: 1rem;
cursor: pointer;
}
.edit-add {
align-self: flex-start;
padding: 5px 10px;
border: 1px dashed var(--border-strong);
border-radius: 8px;
background: none;
color: var(--text-muted);
font-size: 0.78rem;
cursor: pointer;
}
.edit-add:hover {
border-color: var(--accent);
color: var(--accent);
}
.edit-save {
position: sticky;
top: 0;
z-index: 1;
margin-bottom: 0.3rem;
padding: 9px 10px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.edit-save:disabled {
opacity: 0.5;
cursor: wait;
}
</style>

View File

@@ -1,196 +0,0 @@
<script setup>
import { ref, computed } from 'vue'
import { useConfirm } from '../../composables/useConfirm.js'
import { plainText } from '../../markdown.js'
const props = defineProps({
elements: { type: Array, required: true },
creating: { type: Boolean, default: false },
})
const emit = defineEmits(['select', 'create', 'remove'])
const query = ref('')
const { isArmed, armOrRun } = useConfirm()
// Strip Markdown characters for title and list preview
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return props.elements
return props.elements.filter(
(el) => el.title.toLowerCase().includes(q) || el.description.toLowerCase().includes(q),
)
})
function add() {
if (props.creating) return
emit('create', query.value.trim())
query.value = ''
}
// Inline confirmation: first click "Sure?", second deletes
function confirmDelete(el) {
armOrRun('el-' + el.id, () => emit('remove', el))
}
</script>
<template>
<div class="el-new">
<input
v-model="query"
placeholder="Search or keyword…"
:disabled="creating"
@keyup.enter="add"
/>
<button :disabled="creating" title="Create element via AI" @click="add">+</button>
</div>
<div v-if="creating" class="el-creating">AI is creating element</div>
<ul class="el-list">
<li v-for="el in filtered" :key="el.id" @click="emit('select', el)">
<div class="el-item-main">
<span class="el-item-title">{{ plainText(el.title) }}</span>
<span class="el-item-desc">{{ plainText(el.description) }}</span>
</div>
<button
class="el-delete"
:class="{ armed: isArmed('el-' + el.id) }"
title="Delete element"
@click.stop="confirmDelete(el)"
>{{ isArmed('el-' + el.id) ? 'Sure?' : '×' }}</button>
</li>
<li v-if="!filtered.length && !creating" class="el-empty">
{{ elements.length ? 'No matches.' : 'No elements yet. Enter a keyword and click +.' }}
</li>
</ul>
</template>
<style scoped>
.el-new {
display: flex;
gap: 6px;
padding: 0.6rem 0.75rem;
}
.el-new input {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
background: var(--panel);
color: var(--text);
outline: none;
}
.el-new input:focus {
border-color: var(--accent);
}
.el-new button {
width: 38px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1.1rem;
cursor: pointer;
}
.el-new button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.el-creating {
padding: 0.4rem 0.75rem;
font-size: 0.78rem;
color: var(--warning);
background: var(--warning-soft);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.35; }
}
.el-list {
flex: 1;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 0.25rem 0;
}
.el-list li {
display: flex;
align-items: center;
gap: 6px;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background 0.15s;
}
.el-list li:hover {
background: var(--panel-soft);
}
.el-item-main {
flex: 1;
min-width: 0;
}
.el-item-title {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-item-desc {
display: block;
font-size: 0.75rem;
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-delete {
border: none;
background: none;
color: var(--danger);
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
visibility: hidden;
}
.el-list li:hover .el-delete {
visibility: visible;
}
.el-delete.armed {
visibility: visible;
font-size: 0.7rem;
font-weight: 700;
background: var(--danger);
color: #fff;
border-radius: 4px;
padding: 2px 6px;
}
.el-empty {
cursor: default !important;
color: var(--text-faint);
font-size: 0.8rem;
}
.el-empty:hover {
background: none !important;
}
</style>

View File

@@ -1,184 +0,0 @@
<script setup>
import { ref, nextTick } from 'vue'
import { renderMarkdown } from '../../markdown.js'
const props = defineProps({
change: { type: Object, required: true },
busy: { type: Boolean, default: false },
})
const emit = defineEmits(['apply', 'dismiss', 'refine'])
const ACTION_LABELS = { remove: 'Remove:', adjust: 'Adjust:', add: 'Add:' }
const editing = ref(false)
const instruction = ref('')
const inputEl = ref(null)
function toggleEdit() {
editing.value = !editing.value
if (editing.value) nextTick(() => inputEl.value?.focus())
}
function submit() {
const text = instruction.value.trim()
if (!text || props.busy) return
emit('refine', text)
instruction.value = ''
editing.value = false
}
</script>
<template>
<div class="style-sugg" :class="{ busy }">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[change.action] }}</strong> {{ change.text }}</div>
<div v-if="change.content" class="style-sugg-preview markdown" v-html="renderMarkdown(change.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="busy" @click="emit('apply')">Confirm</button>
<button class="sugg-no" :disabled="busy" @click="emit('dismiss')">Reject</button>
<button class="sugg-edit" :disabled="busy" title="Adjust suggestion via instruction" @click="toggleEdit"></button>
</div>
<div v-if="editing" class="sugg-edit-row">
<input
ref="inputEl"
v-model="instruction"
placeholder="Instruction for the suggestion…"
@keyup.enter="submit"
/>
<button :disabled="!instruction.trim() || busy" @click="submit"></button>
</div>
</div>
</template>
<style scoped>
.style-sugg {
margin: 0.3rem 0 0.6rem;
padding: 0.5rem 0.6rem;
border: 1px dashed var(--accent);
border-radius: 8px;
background: var(--panel-soft);
}
.style-sugg.busy {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.45; }
}
.style-sugg-text {
font-size: 0.76rem;
line-height: 1.4;
color: var(--text);
}
.style-sugg-text strong {
color: var(--accent);
}
.style-sugg-preview {
margin-top: 0.35rem;
font-size: 0.76rem;
line-height: 1.45;
color: var(--text-muted);
}
.style-sugg-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 0.45rem;
}
.sugg-ok,
.sugg-no {
padding: 4px 10px;
border-radius: 6px;
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
}
.sugg-ok {
border: none;
background: var(--accent);
color: var(--on-accent);
}
.sugg-no {
border: 1px solid var(--border-strong);
background: none;
color: var(--text-muted);
}
.sugg-no:hover {
border-color: var(--danger);
color: var(--danger);
}
.sugg-edit {
border: none;
background: none;
font-size: 0.8rem;
cursor: pointer;
padding: 2px 4px;
border-radius: 6px;
filter: grayscale(0.4);
}
.sugg-edit:hover {
background: var(--border);
filter: none;
}
.sugg-ok:disabled,
.sugg-no:disabled,
.sugg-edit:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.sugg-edit-row {
display: flex;
gap: 6px;
margin-top: 0.45rem;
}
.sugg-edit-row input {
flex: 1;
padding: 5px 8px;
border: 1px solid var(--border-strong);
border-radius: 6px;
font-size: 0.76rem;
background: var(--panel);
color: var(--text);
outline: none;
}
.sugg-edit-row input:focus {
border-color: var(--accent);
}
.sugg-edit-row button {
width: 30px;
border: none;
border-radius: 6px;
background: var(--accent);
color: var(--on-accent);
font-size: 0.8rem;
cursor: pointer;
}
.sugg-edit-row button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Markdown: base is global (assets/markdown.css); compact preview code blocks */
.markdown :deep(pre) {
padding: 6px 8px;
border-radius: 6px;
margin: 0.3em 0;
}
</style>

View File

@@ -1,156 +0,0 @@
<script setup>
import { ref, watch } from 'vue'
import { fetchElements, createElement, deleteElement } from '../../api.js'
import ElementList from './ElementList.vue'
import ElementDetail from './ElementDetail.vue'
const props = defineProps({
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
openId: { type: String, default: null }, // Element ID that should be opened
openTick: { type: Number, default: 0 }, // increment = (re)open openId
})
const emit = defineEmits(['close', 'changed'])
const elements = ref([])
const creating = ref(false)
const selected = ref(null)
watch(() => props.topic, load, { immediate: true })
async function load() {
selected.value = null
try {
elements.value = await fetchElements(props.topic)
} catch (e) {
console.error('Failed to load elements:', e)
}
openFromProp()
}
// Open the element clicked in the overview of the main area
watch(() => props.openTick, openFromProp)
function openFromProp() {
if (!props.openId) return
const el = elements.value.find((e) => e.id === props.openId)
if (el) selected.value = el
}
async function create(hint) {
if (creating.value) return
creating.value = true
try {
const el = await createElement(props.topic, hint, props.provider)
elements.value.unshift(el)
emit('changed')
} catch (e) {
console.error('Failed to create element:', e)
} finally {
creating.value = false
}
}
async function remove(el) {
await deleteElement(el.id)
elements.value = elements.value.filter((e) => e.id !== el.id)
if (selected.value?.id === el.id) selected.value = null
emit('changed')
}
// Keep the edited element in sync within the list and selection
function onUpdated(el) {
selected.value = el
const idx = elements.value.findIndex((e) => e.id === el.id)
if (idx !== -1) elements.value.splice(idx, 1, el)
emit('changed')
}
</script>
<template>
<aside class="elements-sidebar">
<ElementDetail
v-if="selected"
:element="selected"
:provider="provider"
@back="selected = null"
@close="emit('close')"
@updated="onUpdated"
@changed="emit('changed')"
/>
<template v-else>
<header class="el-header">
<span class="el-title">Elements</span>
<button class="el-close" title="Close" @click="emit('close')">×</button>
</header>
<ElementList
:elements="elements"
:creating="creating"
@select="(el) => (selected = el)"
@create="create"
@remove="remove"
/>
</template>
</aside>
</template>
<style scoped>
.elements-sidebar {
width: 320px;
min-width: 320px;
height: 100dvh;
display: flex;
flex-direction: column;
background: var(--panel);
border-left: 1px solid var(--border);
/* Above the guide chat (FAB/panel: z-index 20) */
position: relative;
z-index: 30;
}
/* Mobile/narrow: lay it as an overlay over the main content instead of
squeezing it into the flex flow. */
@media (max-width: 768px) {
.elements-sidebar {
position: fixed;
top: 0;
right: 0;
width: min(100vw, 380px);
min-width: 0;
box-shadow: -4px 0 16px var(--shadow);
}
}
.el-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--border);
}
.el-title {
flex: 1;
font-weight: 600;
font-size: 0.9rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-close {
border: none;
background: none;
color: var(--text-faint);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.el-close:hover {
color: var(--text);
}
</style>