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:
@@ -4,7 +4,7 @@ import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as
|
|||||||
import { usePolling } from './composables/usePolling.js'
|
import { usePolling } from './composables/usePolling.js'
|
||||||
import TopicSidebar from './components/TopicSidebar.vue'
|
import TopicSidebar from './components/TopicSidebar.vue'
|
||||||
import TopicDetail from './components/TopicDetail.vue'
|
import TopicDetail from './components/TopicDetail.vue'
|
||||||
import ElementsSidebar from './components/ElementsSidebar.vue'
|
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
|
||||||
import ElementsOverview from './components/ElementsOverview.vue'
|
import ElementsOverview from './components/ElementsOverview.vue'
|
||||||
|
|
||||||
const guides = ref([])
|
const guides = ref([])
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
147
frontend/src/components/elements/ElementChatTab.vue
Normal file
147
frontend/src/components/elements/ElementChatTab.vue
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<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 } = chat
|
||||||
|
|
||||||
|
// Anderes Element gewählt → Verlauf verwerfen
|
||||||
|
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">
|
||||||
|
<p v-if="!messages.length" class="chat-hint">Schreib, was am Element geändert werden soll.</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">Passt an…</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input">
|
||||||
|
<textarea
|
||||||
|
ref="inputEl"
|
||||||
|
v-model="input"
|
||||||
|
placeholder="Element anpassen…"
|
||||||
|
@keydown.enter.exact.prevent="send"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
:disabled="!input.trim() && !loading"
|
||||||
|
:class="{ cancel: loading }"
|
||||||
|
:title="loading ? 'Abbrechen' : 'Senden'"
|
||||||
|
@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>
|
||||||
458
frontend/src/components/elements/ElementDetail.vue
Normal file
458
frontend/src/components/elements/ElementDetail.vue
Normal 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>
|
||||||
164
frontend/src/components/elements/ElementEditTab.vue
Normal file
164
frontend/src/components/elements/ElementEditTab.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<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 ? 'Speichert…' : 'Speichern' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label>Titel</label>
|
||||||
|
<input v-model="edit.title" placeholder="Titel" />
|
||||||
|
|
||||||
|
<label>Beschreibung</label>
|
||||||
|
<textarea v-model="edit.description" placeholder="Beschreibung"></textarea>
|
||||||
|
|
||||||
|
<label>Beispiele</label>
|
||||||
|
<div v-for="(ex, i) in edit.examples" :key="'ex' + i" class="edit-row">
|
||||||
|
<textarea v-model="edit.examples[i]" placeholder="Beispiel"></textarea>
|
||||||
|
<button class="edit-del" title="Entfernen" @click="edit.examples.splice(i, 1)">×</button>
|
||||||
|
</div>
|
||||||
|
<button class="edit-add" @click="edit.examples.push('')">+ Beispiel</button>
|
||||||
|
|
||||||
|
<label>Hinweise</label>
|
||||||
|
<div v-for="(h, i) in edit.hints" :key="'hi' + i" class="edit-row">
|
||||||
|
<textarea v-model="edit.hints[i]" placeholder="Hinweis"></textarea>
|
||||||
|
<button class="edit-del" title="Entfernen" @click="edit.hints.splice(i, 1)">×</button>
|
||||||
|
</div>
|
||||||
|
<button class="edit-add" @click="edit.hints.push('')">+ Hinweis</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>
|
||||||
198
frontend/src/components/elements/ElementList.vue
Normal file
198
frontend/src/components/elements/ElementList.vue
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useConfirm } from '../../composables/useConfirm.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()
|
||||||
|
|
||||||
|
// Markdown-Zeichen für Titel und Listen-Vorschau entfernen
|
||||||
|
function plain(text) {
|
||||||
|
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
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-Bestätigung: erster Klick „Sicher?", zweiter löscht
|
||||||
|
function confirmDelete(el) {
|
||||||
|
armOrRun('el-' + el.id, () => emit('remove', el))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="el-new">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
placeholder="Suchen oder Stichwort…"
|
||||||
|
:disabled="creating"
|
||||||
|
@keyup.enter="add"
|
||||||
|
/>
|
||||||
|
<button :disabled="creating" title="Element per KI erstellen" @click="add">+</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="creating" class="el-creating">KI erstellt 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">{{ plain(el.title) }}</span>
|
||||||
|
<span class="el-item-desc">{{ plain(el.description) }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="el-delete"
|
||||||
|
:class="{ armed: isArmed('el-' + el.id) }"
|
||||||
|
title="Element löschen"
|
||||||
|
@click.stop="confirmDelete(el)"
|
||||||
|
>{{ isArmed('el-' + el.id) ? 'Sicher?' : '×' }}</button>
|
||||||
|
</li>
|
||||||
|
<li v-if="!filtered.length && !creating" class="el-empty">
|
||||||
|
{{ elements.length ? 'Keine Treffer.' : 'Noch keine Elemente. Stichwort eingeben und + klicken.' }}
|
||||||
|
</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,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick } from 'vue'
|
||||||
import { renderMarkdown } from '../markdown.js'
|
import { renderMarkdown } from '../../markdown.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
change: { type: Object, required: true },
|
change: { type: Object, required: true },
|
||||||
156
frontend/src/components/elements/ElementsSidebar.vue
Normal file
156
frontend/src/components/elements/ElementsSidebar.vue
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<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, die geöffnet werden soll
|
||||||
|
openTick: { type: Number, default: 0 }, // Erhöhung = openId (erneut) öffnen
|
||||||
|
})
|
||||||
|
|
||||||
|
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('Fehler beim Laden der Elemente:', e)
|
||||||
|
}
|
||||||
|
openFromProp()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aus der Übersicht im Hauptbereich angeklicktes Element öffnen
|
||||||
|
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('Fehler beim Erstellen des Elements:', 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')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bearbeitetes Element in Liste und Auswahl synchron halten
|
||||||
|
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">Elemente</span>
|
||||||
|
<button class="el-close" title="Schließen" @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);
|
||||||
|
/* Über dem Guide-Chat (FAB/Panel: z-index 20) */
|
||||||
|
position: relative;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobil/schmal: als Overlay über den Hauptinhalt legen, statt ihn
|
||||||
|
im Flex-Fluss einzuquetschen. */
|
||||||
|
@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