This commit is contained in:
Team3
2026-07-26 16:00:49 +02:00
parent 714307f93e
commit 6b155affbd
12 changed files with 629 additions and 388 deletions

View File

@@ -1,22 +1,23 @@
<script setup>
import { nextTick, onMounted, ref, watch } from 'vue'
import { marked } from 'marked'
import { featureSpeichern, featureLoeschen } from '../api.js'
import { featureSpeichern } from '../api.js'
const props = defineProps({
projekt: { type: String, required: true },
feature: { type: Object, required: true },
titelBearbeiten: { type: Boolean, default: false }, // frisch angelegt: Titel direkt öffnen
vollmodus: { type: Boolean, default: false }, // frisch angelegt: direkt im Vollmodus öffnen
})
const emit = defineEmits(['aktualisiert', 'geloescht'])
const emit = defineEmits(['aktualisiert', 'kontextmenue'])
function kopie(sekt) {
return {
titel: sekt.titel,
art: sekt.art,
ziel: sekt.ziel,
regeln: sekt.regeln,
funktionen: sekt.funktionen
.map(f => ({ text: f.text, regeln: [...f.regeln], flow: f.flow }))
.map(f => ({ text: f.text, ziel: f.ziel, regeln: f.regeln, testen: f.testen }))
.sort((a, b) => a.text.localeCompare(b.text, 'de', { sensitivity: 'base' })),
sonstiges: sekt.sonstiges, // nicht angezeigt, aber beim Speichern erhalten
}
@@ -26,6 +27,7 @@ const s = ref(kopie(props.feature.sektionen))
watch(() => props.feature, f => { s.value = kopie(f.sektionen) })
const aktiv = ref(null) // {feld, i, j, original} — das eine gerade offene Eingabefeld
const gross = ref(false) // ausführliche Ansicht
const entwurf = ref('')
const meldung = ref('')
const feldRef = ref(null)
@@ -40,13 +42,14 @@ function istAktiv(feld, i, j) {
function wert(feld, i, j) {
if (feld === 'titel') return s.value.titel
if (feld === 'ziel') return s.value.ziel
if (feld === 'ziel') return i === undefined ? s.value.ziel : s.value.funktionen[i].ziel
if (feld === 'funktion') return s.value.funktionen[i].text
if (feld === 'flow') return s.value.funktionen[i].flow
return s.value.funktionen[i].regeln[j]
if (feld === 'testen') return s.value.funktionen[i].testen
return i === undefined ? s.value.regeln : s.value.funktionen[i].regeln
}
async function starte(feld, i, j) {
if (!gross.value) return // Bearbeiten nur in der ausführlichen Ansicht
const original = wert(feld, i, j)
aktiv.value = { feld, i, j, original }
entwurf.value = original
@@ -59,16 +62,16 @@ async function starte(feld, i, j) {
function abbrechen() {
const a = aktiv.value
aktiv.value = null
// frisch angelegte, noch leere Einträge wieder entfernen
if (a && !a.original) {
if (a.feld === 'funktion') s.value.funktionen.splice(a.i, 1)
if (a.feld === 'regel') s.value.funktionen[a.i].regeln.splice(a.j, 1)
// frisch angelegte, noch leere Funktion wieder entfernen
if (a && !a.original && a.feld === 'funktion') {
s.value.funktionen.splice(a.i, 1)
}
}
async function fertig() {
const a = aktiv.value
if (!a) return
blurZeit = Date.now() // der Klick, der das Feld schloss, soll die Ansicht nicht schließen
aktiv.value = null
const neu = entwurf.value.trim()
@@ -78,45 +81,34 @@ async function fertig() {
if (!neu) return // Titel ist Pflicht — leere Eingabe verwerfen
s.value.titel = neu
} else if (a.feld === 'ziel') {
s.value.ziel = neu
if (a.i === undefined) s.value.ziel = neu
else s.value.funktionen[a.i].ziel = neu
} else if (a.feld === 'funktion') {
if (!neu) s.value.funktionen.splice(a.i, 1) // leeren = löschen
else s.value.funktionen[a.i].text = neu
} else if (a.feld === 'flow') {
s.value.funktionen[a.i].flow = neu
} else if (a.feld === 'regel') {
if (!neu) s.value.funktionen[a.i].regeln.splice(a.j, 1)
else s.value.funktionen[a.i].regeln[a.j] = neu
} else if (a.feld === 'testen') {
s.value.funktionen[a.i].testen = neu
} else if (a.feld === 'regeln') {
if (a.i === undefined) s.value.regeln = neu
else s.value.funktionen[a.i].regeln = neu
}
await speichern()
}
function abbrechenLeer(a) {
if (a.original) return
if (a.feld === 'funktion') s.value.funktionen.splice(a.i, 1)
if (a.feld === 'regel') s.value.funktionen[a.i].regeln.splice(a.j, 1)
if (!a.original && a.feld === 'funktion') s.value.funktionen.splice(a.i, 1)
}
function neueFunktion() {
s.value.funktionen.unshift({ text: '', regeln: [], flow: '' })
s.value.funktionen.unshift({ text: '', ziel: '', regeln: '', testen: '' })
starte('funktion', 0)
}
function neueRegel(i) {
s.value.funktionen[i].regeln.push('')
starte('regel', i, s.value.funktionen[i].regeln.length - 1)
}
function entferneFunktion(i) {
s.value.funktionen.splice(i, 1)
speichern()
}
function entferneRegel(i, j) {
s.value.funktionen[i].regeln.splice(j, 1)
speichern()
}
function wechsleArt() {
s.value.art = s.value.art === 'kern' ? 'optional' : 'kern'
speichern()
@@ -131,10 +123,13 @@ function baueRoh() {
`<!-- art -->\n${s.value.art}`,
`<!-- ziel -->\n${s.value.ziel}`,
]
if (s.value.regeln) teile.push(`<!-- regeln -->\n${s.value.regeln}`)
const funktionen = s.value.funktionen
.map(f => {
const bloecke = [`<!-- funktion -->\n${f.text}`, ...f.regeln.map(r => `<!-- regel -->\n${r}`)]
if (f.flow) bloecke.push(`<!-- flow -->\n${f.flow}`)
const bloecke = [`<!-- funktion -->\n${f.text}`]
if (f.ziel) bloecke.push(`<!-- ziel -->\n${f.ziel}`)
if (f.regeln) bloecke.push(`<!-- regeln -->\n${f.regeln}`)
if (f.testen) bloecke.push(`<!-- testen -->\n${f.testen}`)
return bloecke.join('\n')
})
.join('\n')
@@ -153,42 +148,33 @@ async function speichern() {
}
}
const loeschBestaetigung = ref(false)
async function loeschen() {
// Erster Klick: Icon wird zum Bestätigungs-Icon, zweiter Klick löscht
if (!loeschBestaetigung.value) {
loeschBestaetigung.value = true
return
}
loeschBestaetigung.value = false
try {
await featureLoeschen(props.projekt, props.feature.id)
emit('geloescht')
} catch (e) {
meldung.value = e.message
}
}
function zeilen(text) {
return Math.max(2, text.split('\n').length)
}
// Flow-Text („- Schritt“ je Zeile) für die nummerierte Anzeige zerlegen
function flowSchritte(flow) {
return flow.split('\n').map(z => z.replace(/^[-*]\s*/, '').trim()).filter(Boolean)
// Blocktext („- Eintrag“ je Zeile) für die Listen-Anzeige zerlegen
function punkte(text) {
return text.split('\n').map(z => z.replace(/^[-*]\s*/, '').trim()).filter(Boolean)
}
// Ausführliche Ansicht: Klick auf freie Kartenfläche schaltet um
const gross = ref(false)
// Standardansicht ist reine Übersicht: Klick öffnet die ausführliche Ansicht,
// nur dort wird bearbeitet; Klick auf freie Fläche schließt sie wieder —
// außer der Klick hat gerade eine Bearbeitung beendet (Blur kommt vor dem Klick)
let blurZeit = 0
function wechsleAnsicht(e) {
if (e.target.closest('button, input, textarea, .klickbar, h3')) return
gross.value = !gross.value
if (gross.value) {
if (e.target.closest('button, input, textarea, .klickbar, h3, .hinweis')) return
if (Date.now() - blurZeit < 300) return
gross.value = false
} else {
if (e.target.closest('button, input, textarea')) return
gross.value = true
}
}
onMounted(() => {
if (props.titelBearbeiten) starte('titel')
if (props.vollmodus) gross.value = true
})
</script>
@@ -197,7 +183,7 @@ onMounted(() => {
class="karte"
:class="[s.art, { gross }]"
@click="wechsleAnsicht"
@mouseleave="loeschBestaetigung = false"
@contextmenu.prevent.stop="emit('kontextmenue', $event)"
>
<header>
<input
@@ -209,19 +195,15 @@ onMounted(() => {
@keyup.enter="$event.target.blur()"
@keydown.esc="abbrechen"
>
<h3 v-else :title="'Klicken zum Bearbeiten'" @click="starte('titel')">{{ s.titel }}</h3>
<h3 v-else :title="gross ? 'Klicken zum Bearbeiten' : null" @click="starte('titel')">{{ s.titel }}</h3>
<button
v-if="gross"
class="badge"
:class="s.art === 'kern' ? 'blau' : 'gruen'"
title="Klicken zum Umschalten"
@click="wechsleArt"
>{{ s.art }}</button>
<button
class="icon-knopf rot werkzeug"
:class="{ bestaetigen: loeschBestaetigung }"
:title="loeschBestaetigung ? 'Wirklich löschen?' : 'Feature löschen'"
@click="loeschen"
>{{ loeschBestaetigung ? '✓' : '✕' }}</button>
<span v-else class="badge" :class="s.art === 'kern' ? 'blau' : 'gruen'">{{ s.art }}</span>
</header>
<section :class="{ leer: !s.ziel && !istAktiv('ziel') }">
@@ -235,12 +217,29 @@ onMounted(() => {
@keydown.esc="abbrechen"
/>
<div v-else-if="s.ziel" class="inhalt klickbar" v-html="md(s.ziel)" @click="starte('ziel')" />
<p v-else class="hinweis" @click="starte('ziel')">Ziel ergänzen</p>
<p v-else-if="gross" class="hinweis" @click="starte('ziel')">Ziel ergänzen</p>
</section>
<!-- Karten-Regeln nur in der ausführlichen Ansicht -->
<section v-if="gross" :class="{ leer: !s.regeln && !istAktiv('regeln') }">
<p class="gruppe">Regeln</p>
<textarea
v-if="istAktiv('regeln')"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
@blur="fertig"
@keydown.esc="abbrechen"
/>
<div v-else-if="s.regeln" class="klickbar" @click="starte('regeln')">
<div v-for="(r, k) in punkte(s.regeln)" :key="k" class="regel">{{ r }}</div>
</div>
<p v-else-if="gross" class="hinweis" @click="starte('regeln')">Regeln ergänzen</p>
</section>
<section :class="{ leer: !s.funktionen.length }">
<p class="gruppe">Funktionen</p>
<button class="hinzu werkzeug" @click="neueFunktion">+ Funktion</button>
<button v-if="gross" class="hinzu werkzeug" @click="neueFunktion">+ Funktion</button>
<div class="liste">
<div
v-for="(f, i) in s.funktionen"
@@ -259,44 +258,62 @@ onMounted(() => {
@keydown.esc="abbrechen"
>
<div v-else class="funktionstitel klickbar" @click="starte('funktion', i)">{{ f.text }}</div>
<button class="icon-knopf rot" title="Funktion entfernen" @click="entferneFunktion(i)"></button>
<button v-if="gross" class="icon-knopf rot" title="Funktion entfernen" @click="entferneFunktion(i)"></button>
</div>
<!-- Regeln und Flow nur beim Hover über die Funktion (oder während der Bearbeitung) -->
<div class="details">
<!-- Regeln als Punktliste -->
<div v-if="f.regeln.length" class="regeln">
<div v-for="(r, j) in f.regeln" :key="j" class="zeile">
<textarea
v-if="istAktiv('regel', i, j)"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
@blur="fertig"
@keydown.esc="abbrechen"
/>
<div v-else class="regel klickbar" @click="starte('regel', i, j)">{{ r }}</div>
<button class="icon-knopf rot" title="Regel entfernen" @click="entferneRegel(i, j)"></button>
<!-- Ziel der Funktion -->
<div v-if="istAktiv('ziel', i) || f.ziel" class="block">
<p class="mini">Ziel</p>
<textarea
v-if="istAktiv('ziel', i)"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
@blur="fertig"
@keydown.esc="abbrechen"
/>
<div v-else class="inhalt klickbar" v-html="md(f.ziel)" @click="starte('ziel', i)" />
</div>
<button v-if="!f.ziel && !istAktiv('ziel', i)" class="hinzu" @click="starte('ziel', i)">+ Ziel</button>
<!-- Regeln als Punktliste, ein Block je Funktion -->
<div v-if="istAktiv('regeln', i) || f.regeln" class="block">
<p class="mini">Regeln</p>
<textarea
v-if="istAktiv('regeln', i)"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
@blur="fertig"
@keydown.esc="abbrechen"
/>
<div v-else class="klickbar" @click="starte('regeln', i)">
<div v-for="(r, k) in punkte(f.regeln)" :key="k" class="regel">{{ r }}</div>
</div>
</div>
<button class="hinzu" @click="neueRegel(i)">+ Regel</button>
<button v-if="!f.regeln && !istAktiv('regeln', i)" class="hinzu" @click="starte('regeln', i)">+ Regeln</button>
<!-- Flow als nummerierte Schritte -->
<textarea
v-if="istAktiv('flow', i)"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
@blur="fertig"
@keydown.esc="abbrechen"
/>
<div v-else-if="f.flow" class="flow klickbar" @click="starte('flow', i)">
<div v-for="(schritt, k) in flowSchritte(f.flow)" :key="k" class="schritt">
<span class="nr">{{ k + 1 }}.</span>{{ schritt }}
<!-- Testen als nummerierte Schritte -->
<div v-if="istAktiv('testen', i) || f.testen" class="block">
<p class="mini">Testen</p>
<textarea
v-if="istAktiv('testen', i)"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
@blur="fertig"
@keydown.esc="abbrechen"
/>
<div v-else class="klickbar" @click="starte('testen', i)">
<div v-for="(schritt, k) in punkte(f.testen)" :key="k" class="schritt">
<span class="nr">{{ k + 1 }}.</span>{{ schritt }}
</div>
</div>
</div>
<button v-if="!f.flow && !istAktiv('flow', i)" class="hinzu" @click="starte('flow', i)">+ Flow</button>
<button v-if="!f.testen && !istAktiv('testen', i)" class="hinzu" @click="starte('testen', i)">+ Testen</button>
</div>
</div>
</div>
@@ -314,7 +331,7 @@ onMounted(() => {
/* Ausführliche Ansicht: volle Breite, Funktionen in Spalten, alle Details offen.
Jede Funktion als eigene Box, Zufüge-Knöpfe nur beim Hover über die Box. */
.karte.gross { column-span: all; display: block; }
.karte.gross .liste { columns: 2; column-gap: 1.2rem; }
.karte.gross .liste { columns: 320px; column-gap: 1.2rem; }
.karte.gross section.leer { display: block; }
.karte.gross .funktion {
break-inside: avoid;
@@ -331,9 +348,6 @@ onMounted(() => {
margin: 0.1rem 0 0;
padding-left: 0.2rem;
}
.karte.gross .details .hinzu { display: none; }
.karte.gross .funktion:hover .details .hinzu { display: block; }
.icon-knopf.bestaetigen { color: var(--rot); }
header {
display: flex;
@@ -343,6 +357,13 @@ header {
border-bottom: 1px solid var(--rand);
}
h3 { margin: 0; font-size: 1rem; cursor: pointer; flex: 1; }
/* Im Bearbeitungsmodus zeigt Hover die klickbare Titelfläche */
.karte.gross h3 {
padding: 0.1rem 0.3rem;
margin-left: -0.3rem;
border-radius: 4px;
}
.karte.gross h3:hover { background: var(--code-bg); }
.titel-feld { flex: 1; font-weight: 600; padding: 0.1rem 0.4rem; }
.badge { cursor: pointer; }
@@ -357,9 +378,8 @@ section + section { border-top: 1px solid var(--rand); }
margin: 0 0 0.15rem;
}
/* Leere Sektionen und Werkzeuge erscheinen erst beim Hover über die Karte */
/* Leere Sektionen nur in der ausführlichen Ansicht */
section.leer { display: none; }
.karte:hover section.leer { display: block; }
.werkzeug { opacity: 0; transition: opacity 0.1s; }
.karte:hover .werkzeug, .werkzeug:focus { opacity: 1; }
/* „+ Funktion" belegt ohne Hover auch keinen Platz */
@@ -369,9 +389,12 @@ section.leer { display: none; }
.hinweis {
color: var(--dezent);
font-size: 0.85rem;
margin: 0.1rem 0;
padding: 0.1rem 0.3rem;
margin: 0.1rem 0 0.1rem -0.3rem;
border-radius: 4px;
cursor: pointer;
}
.hinweis:hover { background: var(--code-bg); }
.hinzu {
border: none;
background: none;
@@ -391,38 +414,35 @@ section.leer { display: none; }
.zeile .icon-knopf { font-size: 0.8rem; opacity: 0; }
.zeile:hover .icon-knopf { opacity: 1; }
/* Funktionsliste: Zeilen mit Aufklapp-Pfeil */
/* Funktionsliste */
.zeile.kopf { padding: 0.15rem 0.2rem; border-radius: 4px; }
.zeile.kopf:hover { background: var(--code-bg); }
.karte.gross .zeile.kopf:hover { background: var(--code-bg); }
.funktionstitel { flex: 1; }
.pfeil {
color: var(--dezent);
font-size: 0.75rem;
display: inline-block;
transition: transform 0.12s;
.pfeil { color: var(--dezent); font-size: 0.75rem; }
/* Regeln und Flow nur in der ausführlichen Ansicht */
.details { display: none; font-size: 0.9rem; }
/* Regeln (Punktliste) und Flow (nummerierte Schritte) als getönte Blöcke */
.block {
background: var(--code-bg);
border-radius: 6px;
padding: 0.3rem 0.55rem 0.35rem;
margin: 0.3rem 0 0.1rem;
}
.funktion:hover .pfeil, .funktion.offen .pfeil {
transform: rotate(90deg);
.mini {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--akzent);
margin: 0 0 0.1rem;
}
/* Regeln und Flow erst beim Hover über die Funktion; offen bleiben während Bearbeitung */
.details { display: none; }
.funktion:hover .details, .funktion.offen .details {
display: block;
margin: 0.15rem 0 0.45rem 0.35rem;
padding: 0.1rem 0 0.1rem 0.75rem;
border-left: 2px solid var(--akzent);
font-size: 0.9rem;
}
/* Regeln als Punktliste, Flow als nummerierte Schritte */
.regeln { margin: 0.15rem 0; }
.regel {
flex: 1;
.block .klickbar { border-radius: 4px; }
.block .klickbar:hover { background: var(--bg); }
.regel, .schritt {
position: relative;
padding-left: 1.15rem;
border-radius: 4px;
}
.regel::before {
content: "•";
@@ -430,25 +450,18 @@ section.leer { display: none; }
left: 0.35rem;
color: var(--dezent);
}
.regel:hover { background: var(--code-bg); }
/* „+ Regel"/„+ Flow" bündig zur Listen-Einrückung */
.details .hinzu {
display: block;
padding: 0.05rem 0 0.05rem 1.15rem;
margin: 0.05rem 0;
}
.flow { border-radius: 4px; }
.flow:hover { background: var(--code-bg); }
.schritt {
position: relative;
padding-left: 1.15rem;
}
.nr {
position: absolute;
left: 0.15rem;
color: var(--dezent);
font-size: 0.85em;
}
/* „+ Regeln"/„+ Flow" bündig zur Listen-Einrückung */
.details .hinzu {
display: block;
padding: 0.05rem 0 0.05rem 0.55rem;
margin: 0.05rem 0;
}
textarea {
width: 100%;