Frontend: ElementsSidebar (1160 Z.) in 5 Komponenten gesplittet

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
team3
2026-06-12 08:15:31 +02:00
parent 5c35939eab
commit 2c426e6ac4
8 changed files with 1125 additions and 1126 deletions

View File

@@ -0,0 +1,458 @@
<script setup>
import { ref, watch } from 'vue'
import { updateElement, checkElement, styleElement, refineSuggestion } from '../../api.js'
import { renderMarkdown } 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)
// Markdown-Zeichen aus dem Header-Titel entfernen
function plain(text) {
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
}
// Anderes Element gewählt → Prüf-Zustand und Tab zurücksetzen
watch(() => props.element.id, () => {
tab.value = 'overview'
resetCheck()
})
// --- KI-Prüfung auf fehlende Infos (Ergebnisse landen als Inline-Vorschläge) ---
const checking = ref(false)
const statusMsg = ref(null)
function resetCheck() {
checking.value = false
statusMsg.value = null
resetStyle()
}
let checkRun = 0 // laufende Prüfung identifizieren; Abbruch ignoriert ihr Ergebnis
async function runCheck() {
if (checking.value) { // zweiter Klick = abbrechen
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 // abgebrochen oder neue Prüfung gestartet
const mapped = res.suggestions.map((s) => ({
text: s.text, action: 'hinzufuegen', target: s.target, index: null, content: s.content,
}))
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
else statusMsg.value = 'Keine wichtigen Lücken gefunden.'
} catch (e) {
if (run !== checkRun) return
console.error('Prüfung fehlgeschlagen:', e)
statusMsg.value = 'Prüfung fehlgeschlagen — bitte erneut versuchen.'
} finally {
if (run === checkRun) checking.value = false
}
}
// --- Stil-Prüfung: KI schlägt Änderungen vor, Nutzer bestätigt ---
const styleChanges = ref(null) // null = noch nicht geprüft
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
}
// Einzelnen Vorschlag per Anweisung überarbeiten (Stift-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('Überarbeitung fehlgeschlagen:', e)
statusMsg.value = 'Überarbeitung fehlgeschlagen — bitte erneut versuchen.'
} finally {
refiningIdx.value = null
}
}
async function runStyle() {
if (styling.value) { // zweiter Klick = abbrechen
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 = 'Stil passt bereits.'
} catch (e) {
if (run !== styleRun) return
console.error('Stil-Prüfung fehlgeschlagen:', e)
statusMsg.value = 'Stil-Prüfung fehlgeschlagen — bitte erneut versuchen.'
} finally {
if (run === styleRun) styling.value = false
}
}
// Chat-Vorschläge landen ebenfalls als Inline-Vorschläge in der Übersicht
function onChatChanges(changes) {
styleChanges.value = [...(styleChanges.value || []), ...changes]
}
// Vorschläge am Ziel-Ort anzeigen: anpassen/entfernen beim betroffenen Eintrag …
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 !== 'hinzufuegen')
}
// … Ergänzungen am Ende der jeweiligen Sektion
function styleAdds(target) {
if (!styleChanges.value) return []
return styleChanges.value
.map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.action === 'hinzufuegen')
}
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 === 'entfernen') fields[c.target].splice(c.index, 1)
else if (c.action === 'hinzufuegen') {
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)
// Rest-Vorschläge behalten; Indizes hinter einer Entfernung rücken auf
const rest = styleChanges.value.filter((_, j) => j !== i)
if (c.action === 'entfernen') {
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('Übernehmen fehlgeschlagen:', e)
} finally {
applyingStyle.value = false
}
}
// --- Bearbeiten-Tab: Felder direkt speichern ---
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('Speichern fehlgeschlagen:', e)
} finally {
savingEdit.value = false
}
}
</script>
<template>
<header class="el-header">
<button class="el-back" title="Zur Liste" @click="emit('back')"></button>
<span class="el-title">{{ plain(element.title) }}</span>
<button
class="el-tool" :class="{ busy: checking }"
:title="checking ? 'Prüfung abbrechen' : 'Auf fehlende Infos prüfen'" @click="runCheck"
>🔍</button>
<button
class="el-tool" :class="{ busy: styling }"
:title="styling ? 'Prüfung abbrechen' : 'Stil prüfen & anpassen'" @click="runStyle"
></button>
<button class="el-close" title="Schließen" @click="emit('close')">×</button>
</header>
<nav class="el-tabs">
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Übersicht</button>
<button :class="{ active: tab === 'chat' }" @click="tab = 'chat'">Chat</button>
<button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Bearbeiten</button>
</nav>
<!-- Übersicht: untrennbar mit styleChanges/Apply verzahnt bleibt hier -->
<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>Hinweise</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">Prüft auf fehlende Infos</p>
<p v-if="styling" class="check-empty busy-text">Prüft den Stil</p>
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
</div>
</div>
<!-- v-show erhält den Chat-Verlauf beim Tab-Wechsel -->
<ElementChatTab
v-show="tab === 'chat'"
:element="element"
:provider="provider"
@changes="onChatChanges"
/>
<!-- v-if lädt die Edit-Felder bei jedem Öffnen frisch -->
<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;
}
/* Hinweis-Text inline neben dem Bullet halten (p ist sonst block) */
.el-hints li > .markdown {
display: inline;
}
.el-hints li > .markdown :deep(p) {
display: inline;
margin: 0;
}
/* Markdown: Basis global (assets/markdown.css); schmale Sidebar → kompaktere Code-Blöcke */
.markdown :deep(pre) {
padding: 8px 10px;
}
/* --- KI-Prüfung --- */
.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>