Files
creator/frontend/src/components/BlockFocus.vue
2026-07-03 11:45:27 +02:00

412 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import BlockPanel from './BlockPanel.vue'
import { renderMarkdown, renderBlocks } from '../markdown.js'
import { stufeFuer, LEVELS } from '../levels.js'
import { pruefeBlock, uebernehmeBlock, resetBlockProgress } from '../api.js'
import { clearPruef } from '../pruefungCache.js'
import { useConfirm } from '../composables/useConfirm.js'
const props = defineProps({
block: { 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: 'exam' },
ansicht: { type: String, default: 'compact' }, // compact | erklärend — left guide column
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
fortschritt: { type: Object, default: () => ({ total: 0, beginner: 0, advanced: 0, expert: 0, master: 0 }) },
})
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
const emit = defineEmits(['close', 'prev', 'next', 'statusChanged', 'setAnsicht', 'openSidebar', 'sectionUpdated'])
// --- Reset per block: questions session (slot) resp. progress (score) ---
const { isArmed, armOrRun } = useConfirm()
const resetN = ref(0) // increment → panel remount (fresh slot, patterns reset)
const examKey = computed(() => `${props.topic}::${props.block.title}`)
// New question session: discard slot → remount loads patterns + pool fresh (fixes explain-only).
function resetQuestions() {
clearPruef(examKey.value)
resetN.value++
}
// Progress to 0: delete DB row, zero badge/learn state, fresh session.
async function resetProgress() {
try {
await resetBlockProgress(props.topic, props.block.title)
emit('statusChanged', { block: props.block.title, good_answers: 0, streak: 0, cap: props.cap })
clearPruef(examKey.value)
resetN.value++
} catch (e) { /* stay quiet — reset failed */ }
}
const guideEl = ref(null) // left guide column (scroll target for ALT+↑/↓)
const rightEl = ref(null) // right column (exam panel with input field)
// ALT is the modifier of the focus view. Plain arrows stay browser-default.
// ALT+←/→ pages blocks, ALT+↑/↓ scrolls the guide. A bare ALT tap
// (press+release without another key) toggles focus on the input field.
let altAlone = false
function onKeyDown(e) {
if (e.key === 'Alt') { altAlone = true; e.preventDefault(); return } // suppresses the Firefox menu bar
if (e.ctrlKey || e.metaKey || !e.altKey) return // plain/other → normal
altAlone = false // ALT+anything = no 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 } // window blur (ALT+Tab) → no false tap
function toggleInput() {
// Answer field of the current form: explain = textarea, free gap-text = 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)) + '%'
// Accent color by the current block's level (green/blue/purple/gold) or neutral.
const levelColor = computed(() => {
const s = props.status || {}
return stufeFuer(s.good_answers || 0, s.cap || props.cap)?.farbe || 'var(--border)'
})
const level = computed(() => {
const s = props.status || {}
return stufeFuer(s.good_answers || 0, s.cap || props.cap)
})
// --- Check block (right-click on a section) ---
const displayedText = computed(() =>
props.ansicht === 'compact' ? (props.block.compact || props.block.md) : props.block.md,
)
const blocks = computed(() => renderBlocks(displayedText.value))
// Field that edits are applied to (must match the displayed text).
const spot = computed(() => (props.ansicht === 'compact' && props.block.compact) ? 'compact' : 'ausführlich')
const menu = reactive({ show: false, x: 0, y: 0, index: null })
function blockMenu(i, e) { menu.show = true; menu.x = e.clientX; menu.y = e.clientY; menu.index = i }
function closeMenu() { menu.show = false }
// Suggestion per block index: { new, running, error, editOpen, hint }
const suggestions = reactive({})
async function checkBlock(i, extra = '') {
const raw = blocks.value[i]?.raw
if (!raw || !props.guideId) return
closeMenu()
suggestions[i] = { revised: '', running: true, error: '', editOpen: false, hint: '' }
try {
const res = await pruefeBlock(props.guideId, { block: props.block.title, spot: spot.value, snippet: raw, hint: extra, provider: props.provider })
suggestions[i] = { revised: res.revised, running: false, error: '', editOpen: false, hint: '' }
} catch (e) {
suggestions[i] = { revised: '', running: false, error: e.message || 'Exam failed', editOpen: false, hint: '' }
}
}
async function applyBlock(i) {
const v = suggestions[i]; const raw = blocks.value[i]?.raw
if (!v || v.running || !raw) return
v.running = true; v.error = ''
try {
const res = await uebernehmeBlock(props.guideId, { block: props.block.title, spot: spot.value, alt: raw, revised: v.revised, provider: props.provider })
if (res.found) emit('sectionUpdated', { title: props.block.title, compact: res.compact, md: res.md })
else { v.running = false; v.error = 'Spot not found — may have already changed.' }
} catch (e) { v.running = false; v.error = e.message || 'Apply failed' }
}
function discardBlock(i) { delete suggestions[i] }
function editBlock(i) { const v = suggestions[i]; if (v) v.editOpen = !v.editOpen }
function sendBlockEdit(i) {
const z = (suggestions[i]?.hint || '').trim()
if (z) checkBlock(i, z)
}
// Block/content switch → reset suggestions + menu (block indices change).
watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}`, () => {
for (const k of Object.keys(suggestions)) delete suggestions[k]
closeMenu()
})
</script>
<template>
<div class="fokus-overlay" :style="{ '--stand': levelColor }">
<div class="fokus-bar">
<div class="fokus-bar-inner">
<button class="fokus-btn" title="Open navigation" @click="$emit('openSidebar')"></button>
<button class="fokus-btn" :disabled="!hasPrev" title="Previous block" @click="$emit('prev')"></button>
<button class="fokus-btn" :disabled="!hasNext" title="Next block" @click="$emit('next')"></button>
<span class="fokus-title">{{ block.title }}</span>
<span v-if="level" class="stand-badge" :style="{ color: level.farbe, borderColor: level.farbe }">{{ level.kurz }} {{ level.label }}</span>
<span class="fokus-spacer"></span>
<button class="fokus-btn" title="Reset questions (new question session)" @click="resetQuestions"></button>
<button
class="fokus-btn"
:class="{ armed: isArmed('reset-fortschritt') }"
:title="isArmed('reset-fortschritt') ? 'Click again: set progress to 0' : 'Reset progress (score to 0)'"
@click="armOrRun('reset-fortschritt', resetProgress)"
></button>
<button class="fokus-btn" title="Exit full view" @click="$emit('close')"></button>
</div>
</div>
<div class="fokus-xp" :title="`${fortschritt.beginner}/${fortschritt.total} from Beginner · ${fortschritt.advanced} Advanced · ${fortschritt.expert} Expert · ${fortschritt.master} Master`">
<div class="xp-seg" :style="{ width: pct(fortschritt.master), background: 'var(--level-master)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.expert - fortschritt.master), background: 'var(--level-expert)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.advanced - fortschritt.expert), background: 'var(--level-advanced)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.beginner - fortschritt.advanced), background: 'var(--level-beginner)' }"></div>
</div>
<div class="fokus-body">
<div ref="guideEl" class="fokus-col left">
<div class="markdown">
<template v-for="(b, i) in blocks" :key="i">
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenu(i, $event)"></div>
<div v-if="suggestions[i]" class="block-vorschlag">
<div v-if="suggestions[i].running" class="bv-status">Check Section</div>
<template v-else>
<p v-if="suggestions[i].error" class="bv-fehler">{{ suggestions[i].error }}</p>
<div class="markdown bv-new" v-html="renderMarkdown(suggestions[i].revised)"></div>
<div class="bv-aktionen">
<button class="bv-btn ja" title="Apply" @click="applyBlock(i)"></button>
<button class="bv-btn" title="Discard" @click="discardBlock(i)"></button>
<button class="bv-btn" :class="{ aktiv: suggestions[i].editOpen }" title="Add hint" @click="editBlock(i)"></button>
</div>
<div v-if="suggestions[i].editOpen" class="bv-edit">
<input v-model="suggestions[i].hint" class="bv-input" placeholder="Extra info → check again" @keyup.enter="sendBlockEdit(i)" />
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(i)"></button>
</div>
</template>
</div>
</template>
</div>
</div>
<div v-if="menu.show" class="menu-overlay" @click="closeMenu" @contextmenu.prevent="closeMenu">
<div class="block-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }" @click.stop>
<button class="bm-item" @click="checkBlock(menu.index)">Check</button>
</div>
</div>
<div ref="rightEl" class="fokus-col right">
<BlockPanel
mode="full"
:key="block.title + '|' + tab + '|' + resetN"
:initial-tab="tab"
:topic="topic"
:block="block.title"
:section="block.md"
:section-compact="block.compact || ''"
: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;
/* Semi-transparent + blur: the guide list behind shows blurred through the gray surfaces. */
background: color-mix(in srgb, var(--bg-preview) 60%, transparent);
backdrop-filter: blur(10px);
display: grid;
grid-template-rows: auto auto 1fr; /* header / XP bar / body */
}
.fokus-bar {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
/* Header content aligns with the body (same max-width + inner padding). */
.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; }
/* Experience bar on top: fills from the left — gold (mastered) → purple (understood) → green (completed). */
.fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); }
/* 9 divider lines every 10% → 10 visible segments (fill stays continuous). */
.fokus-xp::after {
content: '';
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to right,
transparent 0,
transparent calc(10% - 2px),
var(--panel) calc(10% - 2px),
var(--panel) 10%
);
}
.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-title { 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; }
.fokus-btn.armed { border-color: var(--danger, #dc2626); color: var(--danger, #dc2626); background: color-mix(in srgb, var(--danger, #dc2626) 12%, var(--panel)); }
/* Two raised cards on a gray "desk" (--bg-preview from the 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;
/* Card tilts by block level: colored border + tinted background (green/purple/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);
}
/* Left: wide reading card, text centered at reading width. */
.fokus-col.left { flex: 1; padding: 2rem 2.5rem; }
.fokus-col.left > * { max-width: 74ch; margin-inline: auto; }
/* Right: work card; the panel inside is borderless (the card is the frame). */
.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: the right card as a flex column — exam/chat history fills the
full height and scrolls internally, input + buttons stay at the bottom. */
@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; }
}
/* Check block: right-click menu + suggestion below the section */
/* Keep the spacing on the wrapper — the inner p is now :last-child (margin 0). */
.md-block { border-radius: 6px; transition: background 0.15s; margin-bottom: 0.8em; }
.md-block:last-child { margin-bottom: 0; }
.md-block > :first-child { margin-top: 0; }
.md-block > :last-child { margin-bottom: 0; }
.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-new > :first-child { margin-top: 0; }
.bv-new > :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>