364 lines
16 KiB
Vue
364 lines
16 KiB
Vue
<script setup>
|
||
import BausteinPanel from './BausteinPanel.vue'
|
||
import { renderMarkdown, renderBloecke } from '../markdown.js'
|
||
import { stufeFuer, STUFEN } from '../stufen.js'
|
||
import { pruefeBlock, uebernehmeBlock } from '../api.js'
|
||
|
||
const props = defineProps({
|
||
baustein: { type: Object, required: true }, // { title, md, num }
|
||
topic: { type: String, required: true },
|
||
provider: { type: String, default: 'claude' },
|
||
status: { type: Object, default: null },
|
||
cap: { type: Number, default: 6 },
|
||
guideId: { type: String, default: '' },
|
||
tab: { type: String, default: 'pruefung' },
|
||
ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — linke Guide-Spalte
|
||
hasPrev: { type: Boolean, default: false },
|
||
hasNext: { type: Boolean, default: false },
|
||
fortschritt: { type: Object, default: () => ({ total: 0, anfaenger: 0, fortgeschritten: 0, experte: 0, meister: 0 }) },
|
||
})
|
||
|
||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||
|
||
const emit = defineEmits(['close', 'prev', 'next', 'statusChanged', 'setAnsicht', 'openSidebar', 'sectionUpdated'])
|
||
|
||
const guideEl = ref(null) // linke Guide-Spalte (Scroll-Ziel für ALT+↑/↓)
|
||
const rightEl = ref(null) // rechte Spalte (Prüfungs-Panel mit Eingabefeld)
|
||
|
||
// ALT ist der Modifier der Fokus-Ansicht. Plain Pfeile bleiben Browser-Standard.
|
||
// ALT+←/→ blättert Baustein, ALT+↑/↓ scrollt den Guide. Ein reiner ALT-Tipp
|
||
// (drücken+loslassen ohne andere Taste) toggelt den Fokus aufs Eingabefeld.
|
||
let altAlone = false
|
||
function onKeyDown(e) {
|
||
if (e.key === 'Alt') { altAlone = true; e.preventDefault(); return } // unterdrückt Firefox-Menüleiste
|
||
if (e.ctrlKey || e.metaKey || !e.altKey) return // plain/andere → normal
|
||
altAlone = false // ALT+irgendwas = kein Lone-Tap
|
||
if (e.key === 'ArrowLeft') { e.preventDefault(); if (!e.repeat && props.hasPrev) emit('prev') }
|
||
else if (e.key === 'ArrowRight') { e.preventDefault(); if (!e.repeat && props.hasNext) emit('next') }
|
||
else if (e.key === 'ArrowUp') { e.preventDefault(); guideEl.value?.scrollBy(0, -120) }
|
||
else if (e.key === 'ArrowDown') { e.preventDefault(); guideEl.value?.scrollBy(0, 120) }
|
||
}
|
||
function onKeyUp(e) {
|
||
if (e.key === 'Alt') { e.preventDefault(); if (altAlone) { altAlone = false; toggleInput() } }
|
||
}
|
||
function resetAlt() { altAlone = false } // Fenster-Blur (ALT+Tab) → kein falscher Tipp
|
||
function toggleInput() {
|
||
// Antwortfeld der aktuellen Form: Erklären = textarea, Lückentext-frei = input.
|
||
const el = rightEl.value?.querySelector('textarea, input')
|
||
if (!el) return
|
||
document.activeElement === el ? el.blur() : el.focus()
|
||
}
|
||
onMounted(() => {
|
||
window.addEventListener('keydown', onKeyDown)
|
||
window.addEventListener('keyup', onKeyUp)
|
||
window.addEventListener('blur', resetAlt)
|
||
})
|
||
onUnmounted(() => {
|
||
window.removeEventListener('keydown', onKeyDown)
|
||
window.removeEventListener('keyup', onKeyUp)
|
||
window.removeEventListener('blur', resetAlt)
|
||
})
|
||
|
||
const pct = (n) => (100 * n / (props.fortschritt.total || 1)) + '%'
|
||
|
||
// Akzentfarbe nach Stufe des aktuellen Bausteins (grün/blau/lila/gold) oder neutral.
|
||
const standFarbe = computed(() => {
|
||
const s = props.status || {}
|
||
return stufeFuer(s.gute_antworten || 0, s.cap || props.cap)?.farbe || 'var(--border)'
|
||
})
|
||
const stufe = computed(() => {
|
||
const s = props.status || {}
|
||
return stufeFuer(s.gute_antworten || 0, s.cap || props.cap)
|
||
})
|
||
|
||
// --- Block-Prüfen (Rechtsklick auf einen Abschnitt) ---
|
||
const angezeigterText = computed(() =>
|
||
props.ansicht === 'kompakt' ? (props.baustein.kompakt || props.baustein.md) : props.baustein.md,
|
||
)
|
||
const bloecke = computed(() => renderBloecke(angezeigterText.value))
|
||
// Feld, in das übernommen wird (muss zum angezeigten Text passen).
|
||
const stelle = computed(() => (props.ansicht === 'kompakt' && props.baustein.kompakt) ? 'kompakt' : 'ausführlich')
|
||
|
||
const menu = reactive({ show: false, x: 0, y: 0, index: null })
|
||
function blockMenue(i, e) { menu.show = true; menu.x = e.clientX; menu.y = e.clientY; menu.index = i }
|
||
function menueZu() { menu.show = false }
|
||
|
||
// Vorschlag je Block-Index: { neu, laeuft, fehler, editOffen, hinweis }
|
||
const vorschlaege = reactive({})
|
||
|
||
async function blockPruefen(i, zusatz = '') {
|
||
const raw = bloecke.value[i]?.raw
|
||
if (!raw || !props.guideId) return
|
||
menueZu()
|
||
vorschlaege[i] = { neu: '', laeuft: true, fehler: '', editOffen: false, hinweis: '' }
|
||
try {
|
||
const res = await pruefeBlock(props.guideId, { baustein: props.baustein.title, stelle: stelle.value, block: raw, hinweis: zusatz, provider: props.provider })
|
||
vorschlaege[i] = { neu: res.neu, laeuft: false, fehler: '', editOffen: false, hinweis: '' }
|
||
} catch (e) {
|
||
vorschlaege[i] = { neu: '', laeuft: false, fehler: e.message || 'Prüfung fehlgeschlagen', editOffen: false, hinweis: '' }
|
||
}
|
||
}
|
||
|
||
async function blockUebernehmen(i) {
|
||
const v = vorschlaege[i]; const raw = bloecke.value[i]?.raw
|
||
if (!v || v.laeuft || !raw) return
|
||
v.laeuft = true; v.fehler = ''
|
||
try {
|
||
const res = await uebernehmeBlock(props.guideId, { baustein: props.baustein.title, stelle: stelle.value, alt: raw, neu: v.neu, provider: props.provider })
|
||
if (res.gefunden) emit('sectionUpdated', { title: props.baustein.title, kompakt: res.kompakt, md: res.md })
|
||
else { v.laeuft = false; v.fehler = 'Stelle nicht gefunden — evtl. schon geändert.' }
|
||
} catch (e) { v.laeuft = false; v.fehler = e.message || 'Übernehmen fehlgeschlagen' }
|
||
}
|
||
|
||
function blockVerwerfen(i) { delete vorschlaege[i] }
|
||
function blockEdit(i) { const v = vorschlaege[i]; if (v) v.editOffen = !v.editOffen }
|
||
function blockEditSenden(i) {
|
||
const z = (vorschlaege[i]?.hinweis || '').trim()
|
||
if (z) blockPruefen(i, z)
|
||
}
|
||
|
||
// Baustein/Inhalt-Wechsel → Vorschläge + Menü zurücksetzen (Block-Indizes neu).
|
||
watch(() => `${props.baustein.title}|${props.baustein.md}|${props.baustein.kompakt || ''}`, () => {
|
||
for (const k of Object.keys(vorschlaege)) delete vorschlaege[k]
|
||
menueZu()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="fokus-overlay" :style="{ '--stand': standFarbe }">
|
||
<div class="fokus-bar">
|
||
<div class="fokus-bar-inner">
|
||
<button class="fokus-btn" title="Navigation öffnen" @click="$emit('openSidebar')">☰</button>
|
||
<button class="fokus-btn" :disabled="!hasPrev" title="Voriger Baustein" @click="$emit('prev')">←</button>
|
||
<button class="fokus-btn" :disabled="!hasNext" title="Nächster Baustein" @click="$emit('next')">→</button>
|
||
<span class="fokus-titel">{{ baustein.title }}</span>
|
||
<span v-if="stufe" class="stand-badge" :style="{ color: stufe.farbe, borderColor: stufe.farbe }">{{ stufe.kurz }} {{ stufe.label }}</span>
|
||
<span class="fokus-spacer"></span>
|
||
<button class="fokus-btn" title="Vollansicht beenden" @click="$emit('close')">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="fokus-xp" :title="`${fortschritt.anfaenger}/${fortschritt.total} ab Anfänger · ${fortschritt.fortgeschritten} Fortgeschritten · ${fortschritt.experte} Experte · ${fortschritt.meister} Meister`">
|
||
<div class="xp-seg" :style="{ width: pct(fortschritt.meister), background: 'var(--stufe-meister)' }"></div>
|
||
<div class="xp-seg" :style="{ width: pct(fortschritt.experte - fortschritt.meister), background: 'var(--stufe-experte)' }"></div>
|
||
<div class="xp-seg" :style="{ width: pct(fortschritt.fortgeschritten - fortschritt.experte), background: 'var(--stufe-fortgeschritten)' }"></div>
|
||
<div class="xp-seg" :style="{ width: pct(fortschritt.anfaenger - fortschritt.fortgeschritten), background: 'var(--stufe-anfaenger)' }"></div>
|
||
</div>
|
||
<div class="fokus-body">
|
||
<div ref="guideEl" class="fokus-col left">
|
||
<div class="markdown">
|
||
<template v-for="(b, i) in bloecke" :key="i">
|
||
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenue(i, $event)"></div>
|
||
<div v-if="vorschlaege[i]" class="block-vorschlag">
|
||
<div v-if="vorschlaege[i].laeuft" class="bv-status">Prüfe Abschnitt…</div>
|
||
<template v-else>
|
||
<p v-if="vorschlaege[i].fehler" class="bv-fehler">{{ vorschlaege[i].fehler }}</p>
|
||
<div class="markdown bv-neu" v-html="renderMarkdown(vorschlaege[i].neu)"></div>
|
||
<div class="bv-aktionen">
|
||
<button class="bv-btn ja" title="Übernehmen" @click="blockUebernehmen(i)">✓</button>
|
||
<button class="bv-btn" title="Verwerfen" @click="blockVerwerfen(i)">✗</button>
|
||
<button class="bv-btn" :class="{ aktiv: vorschlaege[i].editOffen }" title="Hinweis ergänzen" @click="blockEdit(i)">✏️</button>
|
||
</div>
|
||
<div v-if="vorschlaege[i].editOffen" class="bv-edit">
|
||
<input v-model="vorschlaege[i].hinweis" class="bv-input" placeholder="Zusatz-Info → erneut prüfen" @keyup.enter="blockEditSenden(i)" />
|
||
<button class="bv-btn ja" title="Erneut prüfen" @click="blockEditSenden(i)">↻</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div v-if="menu.show" class="menu-overlay" @click="menueZu" @contextmenu.prevent="menueZu">
|
||
<div class="block-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }" @click.stop>
|
||
<button class="bm-item" @click="blockPruefen(menu.index)">Prüfen</button>
|
||
</div>
|
||
</div>
|
||
<div ref="rightEl" class="fokus-col right">
|
||
<BausteinPanel
|
||
mode="full"
|
||
:key="baustein.title + '|' + tab"
|
||
:initial-tab="tab"
|
||
:topic="topic"
|
||
:baustein="baustein.title"
|
||
:section="baustein.md"
|
||
:section-kompakt="baustein.kompakt || ''"
|
||
:provider="provider"
|
||
:status="status"
|
||
:cap="cap"
|
||
:ansicht="ansicht"
|
||
@set-ansicht="$emit('setAnsicht', $event)"
|
||
@status-changed="$emit('statusChanged', $event)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.fokus-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 40;
|
||
/* Halbtransparent + Blur: die Guide-Liste dahinter scheint verschwommen durch die grauen Flächen. */
|
||
background: color-mix(in srgb, var(--bg-preview) 60%, transparent);
|
||
backdrop-filter: blur(10px);
|
||
display: grid;
|
||
grid-template-rows: auto auto 1fr; /* Header / XP-Leiste / Body */
|
||
}
|
||
.fokus-bar {
|
||
padding: 0.5rem 0;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--panel);
|
||
}
|
||
/* Header-Inhalt fluchtet mit dem Body (gleiche max-width + Innenabstand). */
|
||
.fokus-bar-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
max-width: 1600px;
|
||
width: 100%;
|
||
margin-inline: auto;
|
||
padding-inline: 1.25rem;
|
||
}
|
||
.fokus-spacer { flex: 1; }
|
||
|
||
.stand-badge {
|
||
margin-left: 0.5rem;
|
||
padding: 0.12rem 0.6rem;
|
||
font-size: 0.72rem; font-weight: 600;
|
||
border-radius: 999px; border: 1px solid; white-space: nowrap;
|
||
}
|
||
.stand-badge.gruen { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||
.stand-badge.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||
.stand-badge.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||
|
||
/* Erfahrungsleiste oben: füllt sich von links — gold (gemeistert) → lila (verstanden) → grün (absolviert). */
|
||
.fokus-xp { display: flex; height: 6px; background: var(--panel-soft); }
|
||
.xp-seg { height: 100%; transition: width 0.3s ease; }
|
||
.xp-seg.gold { background: #d4af37; }
|
||
.xp-seg.lila { background: #8b5cf6; }
|
||
.xp-seg.gruen { background: var(--success-border); }
|
||
.fokus-titel { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
|
||
.fokus-btn {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
|
||
font-size: 1rem;
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: 6px;
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
}
|
||
.fokus-btn:hover { border-color: var(--accent); }
|
||
.fokus-btn:disabled { opacity: 0.4; cursor: default; }
|
||
|
||
/* Zwei abgehobene Karten auf grauem „Schreibtisch" (--bg-preview vom Overlay). */
|
||
.fokus-body {
|
||
min-height: 0;
|
||
display: flex;
|
||
gap: 1.25rem;
|
||
padding: 1.25rem;
|
||
max-width: 1600px;
|
||
width: 100%;
|
||
margin-inline: auto;
|
||
}
|
||
.fokus-col {
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
/* Karte kippt nach Baustein-Stand: farbiger Rahmen + getönter Hintergrund (grün/lila/gold). */
|
||
background: color-mix(in srgb, var(--stand) 7%, var(--panel));
|
||
border: 1px solid var(--stand);
|
||
border-top: 3px solid var(--stand);
|
||
border-radius: 12px;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||
}
|
||
/* Links: breite Lese-Karte, Text in Lesebreite zentriert. */
|
||
.fokus-col.left { flex: 1; padding: 2rem 2.5rem; }
|
||
.fokus-col.left > * { max-width: 74ch; margin-inline: auto; }
|
||
/* Rechts: Arbeits-Karte; Panel darin randlos (Karte ist der Rahmen). */
|
||
.fokus-col.right { flex: none; width: clamp(440px, 34%, 600px); padding: 1.5rem; }
|
||
.fokus-col.right :deep(.bp) { margin-top: 0; }
|
||
.fokus-col.right :deep(.bp-panel) { border: none; background: transparent; padding: 0; }
|
||
.fokus-h2 { font-size: 1.1rem; margin: 0 0 0.75rem; }
|
||
|
||
/* Desktop: die rechte Karte als Flex-Spalte — Prüfungs-/Chat-Verlauf füllt die
|
||
volle Höhe und scrollt intern, Eingabe + Buttons bleiben unten. */
|
||
@media (min-width: 901px) {
|
||
.fokus-col.right { display: flex; flex-direction: column; overflow: hidden; }
|
||
.fokus-col.right :deep(.bp) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||
.fokus-col.right :deep(.bp-panel) { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow-y: auto; }
|
||
.fokus-col.right :deep(.bp-panel > div) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||
.fokus-col.right :deep(.bp-messages) { flex: 1; min-height: 0; max-height: none; }
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.fokus-body { flex-direction: column; overflow-y: auto; }
|
||
.fokus-col { overflow-y: visible; }
|
||
.fokus-col.right { width: auto; }
|
||
}
|
||
|
||
/* Block-Prüfen: Rechtsklick-Menü + Vorschlag unter dem Abschnitt */
|
||
.md-block { border-radius: 6px; transition: background 0.15s; }
|
||
.md-block:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
||
|
||
.menu-overlay { position: fixed; inset: 0; z-index: 60; }
|
||
.block-menu {
|
||
position: fixed;
|
||
min-width: 8rem;
|
||
padding: 0.25rem;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||
}
|
||
.bm-item {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 0.4rem 0.7rem;
|
||
text-align: left;
|
||
font-size: 0.9rem;
|
||
border: none;
|
||
border-radius: 5px;
|
||
background: transparent;
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
}
|
||
.bm-item:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
|
||
|
||
.block-vorschlag {
|
||
margin: 0.5rem 0 1rem;
|
||
padding: 0.75rem 1rem;
|
||
border: 1px solid var(--accent);
|
||
border-left: 3px solid var(--accent);
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, var(--accent) 6%, var(--panel));
|
||
}
|
||
.bv-status { font-size: 0.9rem; color: var(--text-soft, #888); }
|
||
.bv-fehler { color: var(--danger, #dc2626); font-size: 0.88rem; margin: 0 0 0.5rem; }
|
||
.bv-neu > :first-child { margin-top: 0; }
|
||
.bv-neu > :last-child { margin-bottom: 0; }
|
||
.bv-aktionen { display: flex; gap: 0.4rem; margin-top: 0.6rem; }
|
||
.bv-btn {
|
||
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
|
||
font-size: 0.95rem;
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: 6px;
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
}
|
||
.bv-btn:hover { border-color: var(--accent); }
|
||
.bv-btn.aktiv { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--panel)); }
|
||
.bv-btn.ja { border-color: var(--success-border); color: var(--success); }
|
||
.bv-edit { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
|
||
.bv-input {
|
||
flex: 1;
|
||
padding: 0.4rem 0.6rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
font-size: 0.9rem;
|
||
}
|
||
</style>
|