update
This commit is contained in:
387
frontend/src/components/BlocksOverview.vue
Normal file
387
frontend/src/components/BlocksOverview.vue
Normal file
@@ -0,0 +1,387 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { fetchBlocksOverview } from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
steps: { type: Array, default: () => [] }, // fine sub-steps {label, phase, state}
|
||||
generating: { type: Boolean, default: false },
|
||||
progress: { type: String, default: null },
|
||||
ready: { type: Boolean, default: false },
|
||||
partial: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['close', 'restartFrom', 'resetFrom', 'restartAll', 'removeAll', 'cancel'])
|
||||
|
||||
// Group sub-steps by phase, carrying the global index for the re-run.
|
||||
const phaseGroups = computed(() => {
|
||||
const out = []
|
||||
props.steps.forEach((s, i) => {
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.phase === s.phase) last.steps.push({ ...s, idx: i })
|
||||
else out.push({ phase: s.phase, steps: [{ ...s, idx: i }] })
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
const selected = ref(null) // marked start point (step index)
|
||||
const confirm = ref(null) // which destructive action currently shows "Sure?"
|
||||
const selectedLabel = computed(() => props.steps[selected.value]?.label || '')
|
||||
|
||||
function stepClick(idx) {
|
||||
if (props.generating) return
|
||||
selected.value = selected.value === idx ? null : idx
|
||||
confirm.value = null
|
||||
}
|
||||
// 2-click confirmation for destructive actions: first click "arms", second runs it.
|
||||
function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
function regenerateFromHere() { const i = selected.value; selected.value = null; confirm.value = null; emit('restartFrom', i) }
|
||||
function deleteFromHere() { const i = selected.value; selected.value = null; confirm.value = null; emit('resetFrom', i) }
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
|
||||
// Learning-path levels: order + label (color via CSS class st-<key>).
|
||||
const LEVELS = [
|
||||
{ key: 'beginner', label: 'Beginner' },
|
||||
{ key: 'advanced', label: 'Advanced' },
|
||||
{ key: 'expert', label: 'Expert' },
|
||||
]
|
||||
// Legacy topics still carry einfach/mittel/schwer → map them to the new keys.
|
||||
const LEGACY_LEVEL = { einfach: 'beginner', mittel: 'advanced', schwer: 'expert' }
|
||||
|
||||
watch(() => props.topic, load, { immediate: true })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
items.value = []
|
||||
try {
|
||||
items.value = await fetchBlocksOverview(props.topic)
|
||||
} catch (e) {
|
||||
error.value = 'Overview not available — create blocks first.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Block relevant = has ≥1 relevant subblock (same rule as the guide).
|
||||
// Without relevance data (legacy topics) don't dim.
|
||||
function relevant(b) {
|
||||
const withRelevance = (b.subblocks || []).filter((s) => s.relevance)
|
||||
return !withRelevance.length || withRelevance.some((s) => s.relevance === 'relevant')
|
||||
}
|
||||
|
||||
// Only non-empty level groups per block (v-if + v-for not on one element)
|
||||
function groups(b) {
|
||||
return LEVELS
|
||||
.map((st) => ({ ...st, subs: (b.subblocks || []).filter((s) => (LEGACY_LEVEL[s.level] || s.level) === st.key) }))
|
||||
.filter((g) => g.subs.length)
|
||||
}
|
||||
|
||||
const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.length || 0), 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bk-view">
|
||||
<header class="bk-head">
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="bk-sub">Blocks overview</span>
|
||||
<span v-if="items.length" class="bk-count">{{ items.length }} Blocks · {{ subTotal }} Subblocks</span>
|
||||
<span class="bk-spacer"></span>
|
||||
<button class="bk-close" title="Close" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<section v-if="steps.length" class="bk-steps">
|
||||
<div class="bk-steps-top">
|
||||
<div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div>
|
||||
<div v-if="!generating" class="bk-global-actions">
|
||||
<button class="bk-act play" @click="emit('restartAll')">{{ partial ? 'Continue' : ready ? 'Regenerate' : 'Generate' }}</button>
|
||||
<button
|
||||
v-if="ready || partial"
|
||||
class="bk-act danger"
|
||||
:class="{ armed: confirm === 'remove' }"
|
||||
@click="arm('remove', () => emit('removeAll'))"
|
||||
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
|
||||
</div>
|
||||
<div v-else class="bk-global-actions">
|
||||
<button class="bk-act danger" @click="emit('cancel')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bk-phasen">
|
||||
<div v-for="g in phaseGroups" :key="g.phase" class="bk-phase">
|
||||
<span class="bk-phase-label">{{ g.phase }}</span>
|
||||
<div class="bk-steps">
|
||||
<button
|
||||
v-for="s in g.steps"
|
||||
:key="s.idx"
|
||||
class="bk-step"
|
||||
:class="[s.state, { sel: selected === s.idx }]"
|
||||
:disabled="generating"
|
||||
:title="`Choose start point «${s.label}»`"
|
||||
@click="stepClick(s.idx)"
|
||||
>{{ s.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selected !== null && !generating" class="bk-step-actions">
|
||||
<span class="bk-step-actions-label">From «{{ selectedLabel }}»:</span>
|
||||
<button class="bk-act play" @click="regenerateFromHere">↻ regenerate</button>
|
||||
<button class="bk-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', deleteFromHere)">{{ confirm === 'reset' ? 'Sure?' : '✕ delete all' }}</button>
|
||||
<button class="bk-act ghost" @click="selected = null; confirm = null">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="bk-empty-state">Loading…</div>
|
||||
<div v-else-if="error" class="bk-empty-state">{{ error }}</div>
|
||||
<div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div>
|
||||
|
||||
<div v-else class="bk-grid">
|
||||
<article
|
||||
v-for="b in items"
|
||||
:key="b.num"
|
||||
class="bk-card"
|
||||
:class="{ 'bk-irrelevant': !relevant(b) }"
|
||||
:title="relevant(b) ? null : 'Not relevant — comes later in the "Rest"'"
|
||||
>
|
||||
<h3 class="bk-title"><span class="bk-num">{{ b.num }}</span>{{ b.title }}</h3>
|
||||
<p v-if="b.description" class="bk-desc">{{ b.description }}</p>
|
||||
<div v-if="b.subblocks && b.subblocks.length" class="bk-levels">
|
||||
<div v-for="g in groups(b)" :key="g.key" class="bk-level" :class="'st-' + g.key">
|
||||
<span class="bk-level-label">{{ g.label }}</span>
|
||||
<ul>
|
||||
<li v-for="s in g.subs" :key="s.title" :class="{ rand: s.relevance === 'peripheral' }">
|
||||
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Peripheral topic — comes later in the 'Rest'">Edge</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="bk-no-subs">No subblocks.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bk-view {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-preview);
|
||||
}
|
||||
|
||||
.bk-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
.bk-head h1 { font-size: 1.5rem; }
|
||||
.bk-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
|
||||
.bk-count { color: var(--text-muted); font-size: 0.82rem; }
|
||||
.bk-spacer { flex: 1; }
|
||||
.bk-close {
|
||||
align-self: center;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bk-close:hover { border-color: var(--accent); }
|
||||
|
||||
/* Step overview above the blocks */
|
||||
.bk-steps {
|
||||
padding: 0.85rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.bk-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.bk-progress-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: bk-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
.bk-phasen { display: flex; flex-wrap: wrap; gap: 0.5rem 1.1rem; }
|
||||
.bk-phase { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.bk-phase-label {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.bk-steps { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.bk-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
padding: 0.22rem 0.5rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bk-step:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
||||
.bk-step:disabled { cursor: default; opacity: 0.7; }
|
||||
.bk-step.done { border-color: var(--success-border); color: var(--success); }
|
||||
.bk-step.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); font-weight: 600; }
|
||||
.bk-step.pending { color: var(--text-faint); }
|
||||
.bk-step.sel { border-color: var(--accent); color: var(--on-accent); background: var(--accent); font-weight: 700; box-shadow: 0 0 0 2px var(--accent-soft); }
|
||||
|
||||
/* Header: progress left, global buttons right */
|
||||
.bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.7rem; }
|
||||
.bk-steps-top .bk-progress { margin-bottom: 0; }
|
||||
.bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
||||
|
||||
/* Action bar for the selected start point */
|
||||
.bk-step-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
}
|
||||
.bk-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||||
|
||||
.bk-act {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bk-act:hover { border-color: var(--accent); }
|
||||
.bk-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
.bk-act.play:hover { background: var(--accent-hover); }
|
||||
.bk-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
|
||||
.bk-act.danger.armed { background: var(--danger); color: #fff; }
|
||||
.bk-act.ghost { color: var(--text-muted); }
|
||||
|
||||
.bk-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bk-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem 2rem 4rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.bk-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--accent-border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
/* Non-relevant blocks (no relevant subblock) dimmed */
|
||||
.bk-card.bk-irrelevant { opacity: 0.5; }
|
||||
|
||||
.bk-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.bk-num {
|
||||
flex: 0 0 auto;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
border-radius: 7px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bk-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.bk-levels { display: flex; flex-direction: column; gap: 0.55rem; }
|
||||
.bk-level {
|
||||
border-left: 3px solid var(--st);
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
.bk-level.st-beginner { --st: var(--level-beginner); }
|
||||
.bk-level.st-advanced { --st: var(--level-advanced); }
|
||||
.bk-level.st-expert { --st: var(--level-expert); }
|
||||
|
||||
.bk-level-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--st);
|
||||
}
|
||||
.bk-level ul {
|
||||
list-style: disc;
|
||||
margin: 0.25rem 0 0;
|
||||
padding-left: 1.15rem;
|
||||
}
|
||||
.bk-level li {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.bk-level li::marker { color: var(--text-faint); }
|
||||
.bk-level li.rand { color: var(--text-faint); }
|
||||
.rand-tag {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-faint);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.bk-no-subs { color: var(--text-faint); font-size: 0.8rem; font-style: italic; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user