update
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
265
frontend/src/components/GenerationView.vue
Normal file
265
frontend/src/components/GenerationView.vue
Normal 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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (1–5) — 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); }
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user