update
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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"
|
||||
|
||||
184
frontend/src/components/VisionKarte.vue
Normal file
184
frontend/src/components/VisionKarte.vue
Normal 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>
|
||||
Reference in New Issue
Block a user