This commit is contained in:
team3
2026-07-28 12:31:42 +02:00
parent 58efba988d
commit 2e86d682eb
15 changed files with 1823 additions and 65 deletions

View File

@@ -13,6 +13,8 @@
--rot: #b91c1c;
--rot-bg: #fee2e2;
--code-bg: #f1f5f9;
--gold: #a16207; /* amber-700: 4.9 auf weiß, trägt auch als Text */
--gold-bg: #fdf6e3;
}
html.dark {
@@ -30,6 +32,8 @@ html.dark {
--rot: #fca5a5;
--rot-bg: #450a0a;
--code-bg: #111827;
--gold: #fbbf24;
--gold-bg: #2a2410;
}
* { box-sizing: border-box; }

View File

@@ -1,9 +1,10 @@
<script setup>
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
import { problemeFuer } from '../composables/pruefung.js'
import FeatureKarte from './FeatureKarte.vue'
import VisionKarte from './VisionKarte.vue'
import ModalBox from './ModalBox.vue'
const props = defineProps({
@@ -43,8 +44,29 @@ function aktualisiert(altId, f) {
sortiere()
}
// Die Vision steht über den Features, nicht zwischen ihnen — eigene Zeile, goldener
// Rand, und höchstens eine je Projekt.
const vision = computed(() => features.value.find(f => f.sektionen.art === 'vision'))
const karten = computed(() => features.value.filter(f => f.sektionen.art !== 'vision'))
function bereichsMenue(e) {
oeffneMenue(e, [{ text: '+ Feature erstellen', aktion: () => oeffneModal({ art: 'erstellen' }) }])
const eintraege = [
{ text: '+ Feature erstellen', aktion: () => oeffneModal({ art: 'erstellen' }) },
]
if (!vision.value) {
eintraege.push({ text: '+ Vision erstellen', aktion: visionErstellen })
}
oeffneMenue(e, eintraege)
}
async function visionErstellen() {
meldung.value = ''
try {
features.value.push(mitKey(await featureAnlegen(
props.projekt, '<!-- titel -->\nVision\n<!-- art -->\nvision\n')))
} catch (e) {
meldung.value = e.message
}
}
function oeffneModal(m) {
@@ -83,8 +105,16 @@ watch(() => props.projekt, laden, { immediate: true })
<p v-if="meldung && !modal" class="meldung">{{ meldung }}</p>
<div class="gitter">
<VisionKarte
v-if="vision"
:key="vision.key"
:projekt="projekt"
:feature="vision"
@aktualisiert="aktualisiert"
@entfernen="oeffneModal({ art: 'entfernen', feature: vision })"
/>
<FeatureKarte
v-for="f in features"
v-for="f in karten"
:key="f.key"
:projekt="projekt"
:feature="f"
@@ -105,7 +135,7 @@ watch(() => props.projekt, laden, { immediate: true })
/>
<ModalBox
v-else-if="modal"
titel="Feature entfernen"
:titel="`${modal.feature.sektionen.titel === 'Vision' ? 'Vision' : 'Feature'} entfernen`"
:frage="`„${modal.feature.sektionen.titel}“ entfernen?`"
knopf="Entfernen"
:meldung="meldung"

View File

@@ -0,0 +1,184 @@
<script setup>
import { nextTick, ref, watch } from 'vue'
import { marked } from 'marked'
import { featureSpeichern } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
// Die Vision hält fest, wie es gedacht ist. Sie ist die Vorlage, nicht der
// Prüfling — deshalb hat sie weder Badge noch Funktionen noch Problemhinweise.
// Eine eigene Karte statt eines Sonderwegs in FeatureKarte: dort steckt die ganze
// Prüfmechanik, von der hier nichts gilt.
//
// Die beiden Modi hat sie wie jede Karte: Kompaktmodus zum Lesen, Vollmodus zum
// Anpassen. Kompakt bleiben nur die obersten Punkte stehen — eine Vision ist
// verschachtelt, und ausgeklappt drängt sie alles andere aus dem Bild.
const props = defineProps({
projekt: { type: String, required: true },
feature: { type: Object, required: true },
})
const emit = defineEmits(['aktualisiert', 'entfernen'])
const TITEL = 'Vision'
const TRENNER = '-'.repeat(40)
const text = ref(props.feature.sektionen.ziel)
watch(() => props.feature, f => { text.value = f.sektionen.ziel })
const gross = ref(false)
const aktiv = ref(false)
const entwurf = ref('')
const meldung = ref('')
const feldRef = ref(null)
// Lokale, selbst geschriebene Daten — kein Sanitizing nötig
const md = (t) => marked.parse(t)
async function starte() {
if (!gross.value) return // Bearbeiten nur in der ausführlichen Ansicht
entwurf.value = text.value
aktiv.value = true
await nextTick()
feldRef.value?.focus()
}
function abbrechen() {
aktiv.value = false
}
async function fertig() {
if (!aktiv.value) return
aktiv.value = false
const neu = entwurf.value.trim()
if (neu === text.value) return
text.value = neu
await speichern()
}
// Kompaktmodus zum Lesen, Vollmodus zum Anpassen — MMB auf die Karte
// oder das Augen-Icon schaltet um
function wechsleAnsicht() {
gross.value = !gross.value
if (!gross.value) aktiv.value = false
}
function kartenMenue(e) {
oeffneMenue(e, [{ text: '✕ Vision entfernen', aktion: () => emit('entfernen') }])
}
function baueRoh() {
return [
`<!-- titel -->\n${TITEL}`,
'<!-- art -->\nvision',
`<!-- ziel -->\n${text.value}`,
].join(`\n${TRENNER}\n`) + '\n'
}
async function speichern() {
meldung.value = ''
try {
emit('aktualisiert', props.feature.id,
await featureSpeichern(props.projekt, props.feature.id, baueRoh()))
} catch (e) {
meldung.value = e.message
}
}
function zeilen(t) {
return Math.max(6, t.split('\n').length + 1)
}
</script>
<template>
<article
class="karte vision"
:class="{ gross }"
@mousedown.middle.prevent
@auxclick.middle.prevent="wechsleAnsicht"
@contextmenu.prevent.stop="kartenMenue"
>
<header>
<h3>{{ TITEL }}</h3>
<button
class="icon-knopf"
:title="gross ? 'Kompaktmodus' : 'Vollmodus'"
@click="wechsleAnsicht"
>👁</button>
</header>
<textarea
v-if="aktiv"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
@blur="fertig"
@keydown.esc="abbrechen"
/>
<div
v-else-if="text"
class="inhalt"
:class="{ klickbar: gross }"
:title="gross ? 'Klicken zum Bearbeiten' : null"
v-html="md(text)"
@click="starte"
/>
<p v-else-if="gross" class="hinweis" @click="starte">Wie soll es werden?</p>
<p v-else class="hinweis">Noch nichts festgehalten.</p>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
</article>
</template>
<style scoped>
/* Goldener Rand. Kompakt eine Karte wie jede andere, im Vollmodus über alle
Spalten — sonst würde der ausgeklappte Text die schmale Spalte sprengen. */
.vision { border: 2px solid var(--gold); cursor: pointer; }
.vision.gross { cursor: default; column-span: all; display: block; }
header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
h3 {
margin: 0;
flex: 1;
font-size: 0.95rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--gold);
user-select: none;
}
.klickbar { cursor: text; border-radius: 4px; }
.klickbar:hover { background: var(--gold-bg); }
.hinweis {
margin: 0;
color: var(--dezent);
user-select: none;
}
.gross .hinweis { cursor: text; }
textarea {
width: 100%;
resize: vertical;
border-color: var(--gold);
}
.inhalt :deep(p) { margin: 0.2rem 0; }
.inhalt :deep(ul), .inhalt :deep(ol) { margin: 0.2rem 0; padding-left: 1.2rem; }
.inhalt :deep(li) { margin: 0.1rem 0; }
.inhalt :deep(h1), .inhalt :deep(h2), .inhalt :deep(h3) {
font-size: 0.95rem;
margin: 0.6rem 0 0.3rem;
}
/* Kompakt: nur die oberste Ebene. Die Verschachtelung ist die Ausarbeitung —
sie gehört in den Vollmodus, sonst füllt die Vision den halben Schirm. */
.vision:not(.gross) .inhalt {
user-select: none;
max-height: 11rem;
overflow: hidden;
}
.vision:not(.gross) .inhalt :deep(ul ul),
.vision:not(.gross) .inhalt :deep(ul ol),
.vision:not(.gross) .inhalt :deep(ol ul),
.vision:not(.gross) .inhalt :deep(ol ol) { display: none; }
.meldung { margin: 0.4rem 0 0; }
</style>