Files
creator/frontend/src/components/elements/ElementDetail.vue
2026-06-12 08:15:41 +02:00

459 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>