update
This commit is contained in:
418
frontend/src/components/BlockFocus.vue
Normal file
418
frontend/src/components/BlockFocus.vue
Normal file
@@ -0,0 +1,418 @@
|
||||
<script setup>
|
||||
import BlockPanel from './BlockPanel.vue'
|
||||
import FlashcardWidget from './FlashcardWidget.vue'
|
||||
import WorkedExampleBlock from './WorkedExampleBlock.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 }
|
||||
artefakte: { type: Object, default: null }, // { flashcard[], example[], diagramm } for this block
|
||||
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>
|
||||
<template v-if="artefakte">
|
||||
<WorkedExampleBlock :examples="artefakte.example || []" />
|
||||
<FlashcardWidget :cards="artefakte.flashcard || []" />
|
||||
</template>
|
||||
</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>
|
||||
Reference in New Issue
Block a user