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

@@ -12,8 +12,11 @@ Rein manuelle Pflege: Sidebar links = Projekte, Hauptbereich = Feature-Cards.
Markdown-Dateien unter `daten/<projekt>/<feature>.md` — Projekt = Ordner, die Datei ist Markdown-Dateien unter `daten/<projekt>/<feature>.md` — Projekt = Ordner, die Datei ist
die Wahrheit. Der Editor bearbeitet immer den rohen Text; geparst wird nur für die Anzeige. die Wahrheit. Der Editor bearbeitet immer den rohen Text; geparst wird nur für die Anzeige.
Format (Marker allein auf einer Zeile; `<!-- funktion -->` ist wiederholbar, `<!-- regel -->` Format (Marker allein auf einer Zeile; `<!-- funktion -->` ist wiederholbar; `<!-- ziel -->`,
und `<!-- flow -->` gehören zur davorstehenden Funktion; Trennzeilen aus `---` sind Optik): `<!-- regeln -->` und `<!-- testen -->` nach einer Funktion gehören zu ihr — `ziel`/`regeln`
vor der ersten Funktion gehören zur Karte; regeln/testen: je eine `- `-Zeile pro Eintrag;
`<!-- regel -->`-Blöcke aus dem Alt-Format werden zu Zeilen zusammengeführt;
Trennzeilen aus `---` sind Optik):
```markdown ```markdown
<!-- titel --> <!-- titel -->
@@ -24,18 +27,19 @@ kern
Kurzer Satz, was das Ziel ist. Kurzer Satz, was das Ziel ist.
<!-- funktion --> <!-- funktion -->
Anmeldung mit E-Mail Anmeldung mit E-Mail
<!-- regel --> <!-- regeln -->
Passwort nie loggen - Passwort nie loggen
<!-- flow --> <!-- testen -->
- Formular öffnen - Formular öffnen
- Daten eingeben - Daten eingeben
<!-- funktion --> <!-- funktion -->
Abmeldung Abmeldung
``` ```
Funktionen erscheinen als Titelliste; Regeln und Flow zeigt die Karte erst beim Hover über Funktionen erscheinen als Titelliste; Regeln und Testen zeigt die ausführliche Ansicht.
die Funktion. Text vor dem ersten Marker, Inhalt unbekannter Marker und verwaiste Text vor dem ersten Marker, Inhalt unbekannter Marker und verwaiste
regel/flow-Blöcke werden nicht angezeigt, aber beim Speichern erhalten. Bearbeitet wird live auf regeln/testen-Blöcke (auch Alt-Marker `flow`) werden nicht angezeigt, aber beim Speichern
erhalten. Bearbeitet wird live auf
der Karte (Klick ins Feld, Verlassen speichert, Escape verwirft); jedes Speichern setzt der Karte (Klick ins Feld, Verlassen speichert, Escape verwirft); jedes Speichern setzt
die Datei neu zusammen. Titeländerungen benennen die Datei passend um. die Datei neu zusammen. Titeländerungen benennen die Datei passend um.

View File

@@ -1,9 +1,11 @@
"""Zerlegt den rohen Feature-Text in Sektionen — nur für die Anzeige. """Zerlegt den rohen Feature-Text in Sektionen — nur für die Anzeige.
Format: titel (eine Zeile), art, ziel, dann wiederholbare <!-- funktion -->-Blöcke; Format: titel (eine Zeile), art, ziel, dann wiederholbare <!-- funktion -->-Blöcke;
<!-- regel -->- und <!-- flow -->-Blöcke gehören zur davorstehenden Funktion. <!-- regeln -->- und <!-- testen -->-Blöcke (je eine `- `-Zeile pro Eintrag) gehören zur
Präambel, unbekannte Marker und verwaiste regel/flow-Blöcke landen in `sonstiges` davorstehenden Funktion. Alt-Format wird weiter gelesen: <!-- regel -->-Blöcke (einer je
(wird nicht angezeigt, aber beim Speichern erhalten). Regel) und <!-- flow --> (heute testen) werden zusammengeführt. Präambel, unbekannte
Marker und verwaiste Blöcke landen in `sonstiges` (wird nicht angezeigt, aber beim
Speichern erhalten).
Trennzeilen aus Bindestrichen (---) sind reine Optik und werden ignoriert. Trennzeilen aus Bindestrichen (---) sind reine Optik und werden ignoriert.
""" """
@@ -36,6 +38,7 @@ def zerlege(roh: str) -> dict:
titel = [] titel = []
ziel = [] ziel = []
regeln = []
funktionen = [] funktionen = []
sonstiges = [] sonstiges = []
art = [] art = []
@@ -45,18 +48,22 @@ def zerlege(roh: str) -> dict:
elif schluessel == "art": elif schluessel == "art":
art.append(text) art.append(text)
elif schluessel == "ziel": elif schluessel == "ziel":
ziel.append(text) # vor der ersten Funktion: Karten-Ziel, danach: Ziel der Funktion
(funktionen[-1]["ziel"] if funktionen else ziel).append(text)
elif schluessel == "funktion": elif schluessel == "funktion":
funktionen.append({"text": text, "regeln": [], "flow": []}) funktionen.append({"text": text, "ziel": [], "regeln": [], "testen": []})
elif schluessel == "regel" and funktionen: elif schluessel in ("regel", "regeln"):
funktionen[-1]["regeln"].append(text) # vor der ersten Funktion: Karten-Regeln, danach: Regeln der Funktion
elif schluessel == "flow" and funktionen: (funktionen[-1]["regeln"] if funktionen else regeln).append(text)
funktionen[-1]["flow"].append(text) elif schluessel in ("flow", "testen") and funktionen:
else: # Präambel, unbekannter Marker, regel/flow ohne funktion funktionen[-1]["testen"].append(text)
else: # Präambel, unbekannter Marker, regeln/testen ohne funktion
sonstiges.append(text) sonstiges.append(text)
for f in funktionen: for f in funktionen:
f["flow"] = "\n".join(f["flow"]) f["ziel"] = "\n\n".join(f["ziel"])
f["regeln"] = "\n".join(f["regeln"])
f["testen"] = "\n".join(f["testen"])
# Titel ist eine Zeile: erste nicht-leere # Titel ist eine Zeile: erste nicht-leere
titel_zeilen = [z.strip() for t in titel for z in t.splitlines() if z.strip()] titel_zeilen = [z.strip() for t in titel for z in t.splitlines() if z.strip()]
@@ -65,6 +72,7 @@ def zerlege(roh: str) -> dict:
"titel": titel_zeilen[0] if titel_zeilen else "", "titel": titel_zeilen[0] if titel_zeilen else "",
"art": "optional" if art and art[0].strip().lower() == "optional" else "kern", "art": "optional" if art and art[0].strip().lower() == "optional" else "kern",
"ziel": "\n\n".join(ziel), "ziel": "\n\n".join(ziel),
"regeln": "\n".join(regeln),
"funktionen": funktionen, "funktionen": funktionen,
"sonstiges": "\n\n".join(sonstiges), "sonstiges": "\n\n".join(sonstiges),
} }

View File

@@ -11,11 +11,10 @@ Nutzer können sich anmelden.
---------------------------------------- ----------------------------------------
<!-- funktion --> <!-- funktion -->
Anmeldung mit E-Mail Anmeldung mit E-Mail
<!-- regel --> <!-- regeln -->
Passwort nie loggen - Passwort nie loggen
<!-- regel --> - Sperre nach 5 Fehlversuchen
Sperre nach 5 Fehlversuchen <!-- testen -->
<!-- flow -->
- Formular öffnen - Formular öffnen
- Daten eingeben - Daten eingeben
<!-- funktion --> <!-- funktion -->
@@ -31,16 +30,33 @@ def test_alle_sektionen():
assert s["funktionen"] == [ assert s["funktionen"] == [
{ {
"text": "Anmeldung mit E-Mail", "text": "Anmeldung mit E-Mail",
"regeln": ["Passwort nie loggen", "Sperre nach 5 Fehlversuchen"], "ziel": "",
"flow": "- Formular öffnen\n- Daten eingeben", "regeln": "- Passwort nie loggen\n- Sperre nach 5 Fehlversuchen",
"testen": "- Formular öffnen\n- Daten eingeben",
}, },
{"text": "Abmeldung", "regeln": [], "flow": ""}, {"text": "Abmeldung", "ziel": "", "regeln": "", "testen": ""},
] ]
def test_mehrere_flow_bloecke_werden_verbunden(): def test_ziel_nach_funktion_gehoert_zur_funktion():
s = zerlege("<!-- funktion -->\nX\n<!-- flow -->\n- a\n<!-- flow -->\n- b\n") s = zerlege("<!-- ziel -->\nKarte\n<!-- funktion -->\nX\n<!-- ziel -->\nFunktion\n")
assert s["funktionen"][0]["flow"] == "- a\n- b" assert s["ziel"] == "Karte"
assert s["funktionen"][0]["ziel"] == "Funktion"
def test_mehrere_testen_bloecke_werden_verbunden():
s = zerlege("<!-- funktion -->\nX\n<!-- testen -->\n- a\n<!-- testen -->\n- b\n")
assert s["funktionen"][0]["testen"] == "- a\n- b"
def test_alter_flow_marker_wird_gelesen():
s = zerlege("<!-- funktion -->\nX\n<!-- flow -->\n- a\n")
assert s["funktionen"][0]["testen"] == "- a"
def test_alte_regel_bloecke_werden_zusammengefuehrt():
s = zerlege("<!-- funktion -->\nX\n<!-- regel -->\na\n<!-- regel -->\nb\n")
assert s["funktionen"][0]["regeln"] == "a\nb"
def test_trenner_zeilen_werden_ignoriert(): def test_trenner_zeilen_werden_ignoriert():
@@ -59,10 +75,16 @@ def test_unbekannter_marker_landet_in_sonstiges():
assert s["sonstiges"] == "Geheim" assert s["sonstiges"] == "Geheim"
def test_regel_und_flow_ohne_funktion_landen_in_sonstiges(): def test_regeln_vor_erster_funktion_gehoeren_zur_karte():
s = zerlege("<!-- regel -->\nverwaist\n<!-- flow -->\n- auch\n") s = zerlege("<!-- regeln -->\n- karte\n<!-- funktion -->\nX\n<!-- regeln -->\n- funktion\n")
assert s["regeln"] == "- karte"
assert s["funktionen"][0]["regeln"] == "- funktion"
def test_testen_ohne_funktion_landet_in_sonstiges():
s = zerlege("<!-- testen -->\n- verwaist\n")
assert s["funktionen"] == [] assert s["funktionen"] == []
assert s["sonstiges"] == "verwaist\n\n- auch" assert s["sonstiges"] == "- verwaist"
def test_doppelter_marker_wird_konkateniert(): def test_doppelter_marker_wird_konkateniert():
@@ -72,7 +94,7 @@ def test_doppelter_marker_wird_konkateniert():
def test_leerer_text(): def test_leerer_text():
s = zerlege("") s = zerlege("")
assert s == {"titel": "", "art": "kern", "ziel": "", "funktionen": [], "sonstiges": ""} assert s == {"titel": "", "art": "kern", "ziel": "", "regeln": "", "funktionen": [], "sonstiges": ""}
def test_art_optional(): def test_art_optional():

View File

@@ -9,11 +9,9 @@ optional
---------------------------------------- ----------------------------------------
<!-- funktion --> <!-- funktion -->
Darkmode umschalten Darkmode umschalten
<!-- regel --> <!-- regeln -->
Icon: ☾ / ☀ - Icon: ☾ / ☀
<!-- regel --> - Folgt beim ersten Start der System-Einstellung
Folgt beim ersten Start der System-Einstellung - Zustand bleibt gespeichert
<!-- regel --> <!-- testen -->
Zustand bleibt gespeichert
<!-- flow -->
- Klick auf Icon in der Sidebar oben - Klick auf Icon in der Sidebar oben

View File

@@ -5,74 +5,85 @@ Features
kern kern
---------------------------------------- ----------------------------------------
<!-- ziel --> <!-- ziel -->
Gruppieren Informationen zu ähnlichen Funktionen
----------------------------------------
<!-- regeln -->
- Alphabetisch sortiert
- Unter daten/ als md Datei gespeichert
- Verlassen speichert
- Escape verwirft
---------------------------------------- ----------------------------------------
<!-- funktion --> <!-- funktion -->
Ausführliche Ansicht umschalten Anzeigemodus umschalten
<!-- regel --> <!-- ziel -->
Volle Breite Kompaktmodus zum lesen und Vollmodus zum vertiefen und anpassen
<!-- regel --> <!-- regeln -->
Funktionen als Boxen in zwei Spalten - Standard ist Kompaktmodus
<!-- regel --> - Vollmodus geht über responsive Spalten
Alle Details offen - Vollmodus zeigt alles
<!-- flow --> - Kompaktmodus zeigt Ziel und Funktionstitel als Liste
<!-- testen -->
- Klick auf Karte
- Vollmodus sichtbar
- Klick auf freie Kartenfläche - Klick auf freie Kartenfläche
- Nochmal klicken schaltet zurück - Kompaktmodus sichtbar
<!-- funktion --> <!-- funktion -->
Feature als Datei speichern Badge umschalten
<!-- regel --> <!-- ziel -->
Projekt = Ordner unter daten/ Unterscheidet die Kern- und Optionalen Features
<!-- regel --> <!-- regeln -->
Feature = Markdown-Datei im Projektordner - Kernfarbe blau
<!-- regel --> - Optionalfarbe grün
Titeländerung benennt die Datei um - Verändert die Randfarbe vom Feature
<!-- regel --> <!-- testen -->
Versionierbar per git - Vollmodus öffnen
- Klick auf Badge zum toggeln
<!-- funktion -->
Feature entfernen
<!-- regeln -->
- x-Icon
<!-- testen -->
- RMB auf Featurecard
- Klick auf "Feature entfernen"
- Bestätigen im Modal
- Feature entfernt
<!-- funktion --> <!-- funktion -->
Feature erstellen Feature erstellen
<!-- regel --> <!-- regeln -->
Icon: + - +-Icon
<!-- regel --> - Feature direkt in Vollmodus geöffnet
Titel direkt zur Bearbeitung geöffnet <!-- testen -->
<!-- regel --> - RMB auf Hauptbereich
Karten alphabetisch nach Titel sortiert - Klick auf "Feature erstellen"
<!-- flow --> - Im Modal Namen eingeben
- Klick auf leere Card - Bestätigen
- Card wird erstellt
<!-- funktion --> <!-- funktion -->
Features entfernen Regeln anpassen
<!-- regel --> <!-- ziel -->
Icon: x Spezifische Hinweise für Features
<!-- flow --> <!-- regeln -->
- Hover über Card - Nur paar Wörter je Regel
- Auf Icon klicken <!-- testen -->
- Icon wird zum Bestätigungs Icon - Vollmodus öffnen
- Klick zum entfernen - Klick auf Regeln
<!-- funktion --> - Inhalt anpassen
Kern/Optional umschalten
<!-- regel -->
Kern blau umrandet
<!-- regel -->
Optional grün umrandet
<!-- flow -->
- Klick auf Badge
<!-- funktion --> <!-- funktion -->
Titel anpassen Titel anpassen
<!-- regel --> <!-- regeln -->
Enter schließt ab - 1-2 Wörter
<!-- regel --> - Aktualisiert den Dateinamen
Verlassen speichert <!-- testen -->
<!-- regel --> - Vollmodus öffnen
Escape verwirft
<!-- flow -->
- Hover über Card
- Klick auf Titel - Klick auf Titel
- Inhalt ändern - Inhalt ändern
<!-- funktion --> <!-- funktion -->
Ziel anpassen Ziel anpassen
<!-- regel --> <!-- ziel -->
Leer: Platzhalter „Ziel ergänzen" klicken Beschreibt kurz das Kernziel vom Feature
<!-- flow --> <!-- regeln -->
- Hover über Card - 1-2 simple Sätze
- Klick auf Zieltext - Nur wenn der Titel nicht selbsterklärend ist
<!-- testen -->
- Vollmodus öffnen
- Klick auf Ziel
- Inhalt anpassen - Inhalt anpassen

View File

@@ -8,52 +8,46 @@ kern
---------------------------------------- ----------------------------------------
<!-- funktion --> <!-- funktion -->
Flow anpassen Testen anpassen
<!-- regel --> <!-- regeln -->
Ein Flow je Funktion - Ein Testen-Block je Funktion
<!-- regel --> - Leeren entfernt den Block
Leeren entfernt den Flow <!-- testen -->
<!-- flow --> - Klick auf Testen oder + Testen
- Hover über Funktion
- Klick auf Flow oder + Flow
- Inhalt anpassen - Inhalt anpassen
<!-- funktion --> <!-- funktion -->
Funktion anpassen Funktion anpassen
<!-- flow --> <!-- testen -->
- Klick auf Text - Klick auf Text
- Inhalt ändern - Inhalt ändern
<!-- funktion --> <!-- funktion -->
Funktion entfernen Funktion entfernen
<!-- regel --> <!-- regeln -->
Feld leeren entfernt auch - Feld leeren entfernt auch
<!-- flow --> <!-- testen -->
- Hover über Funktion - Hover über Funktion
- Klick auf x - Klick auf x
<!-- funktion --> <!-- funktion -->
Funktion hinzufügen Funktion hinzufügen
<!-- regel --> <!-- regeln -->
Alphabetisch sortiert - Alphabetisch sortiert
<!-- regel --> - Neue Funktion rückt nach dem Speichern an ihre Position
Neue Funktion rückt nach dem Speichern an ihre Position <!-- testen -->
<!-- flow -->
- Hover über Card
- Klick auf + Funktion - Klick auf + Funktion
- Inhalt eingeben - Inhalt eingeben
<!-- funktion --> <!-- funktion -->
Regel anpassen Ziel anpassen
<!-- flow --> <!-- regeln -->
- Klick auf Text - Ein Ziel je Funktion
- Inhalt ändern - Leeren entfernt das Ziel
<!-- testen -->
- Klick auf Ziel oder + Ziel
- Inhalt anpassen
<!-- funktion --> <!-- funktion -->
Regel entfernen Regeln anpassen
<!-- regel --> <!-- regeln -->
Feld leeren entfernt auch - Eine Regel je Zeile
<!-- flow --> - Leeren entfernt alle Regeln
- Hover über Regel <!-- testen -->
- Klick auf x - Klick auf Regeln oder + Regeln
<!-- funktion --> - Inhalt anpassen
Regel hinzufügen
<!-- flow -->
- Hover über Funktion
- Klick auf + Regel
- Inhalt eingeben

View File

@@ -5,43 +5,45 @@ Projekte
kern kern
---------------------------------------- ----------------------------------------
<!-- ziel --> <!-- ziel -->
Gruppiert Features für ein Projekt
----------------------------------------
<!-- regeln -->
- Alphabetisch sortiert
- Aktives Projekt ist hervorgehoben
- 1-2 Wörter für den Namen
---------------------------------------- ----------------------------------------
<!-- funktion --> <!-- funktion -->
Projekt anpassen Projekt anpassen
<!-- regel --> <!-- regeln -->
Icon: Stift - Stift-Icon
<!-- regel --> <!-- testen -->
Enter bestätigt - RMB auf Projektname
<!-- regel --> - Klick auf "Projekt anpassen"
Escape bricht ab
<!-- flow -->
- Hover über Projektname
- Klick auf Icon
- Inhalt anpassen - Inhalt anpassen
- Rausklicken
- Name ist angepasst
<!-- funktion --> <!-- funktion -->
Projekt auswählen Projekt aufrufen
<!-- regel --> <!-- testen -->
Aktives Projekt ist hervorgehoben - LMB auf Projektname
<!-- flow --> - Features werden angezeigt
- Klick auf Name
- Features werden im Hauptbereich angezeigt
<!-- funktion --> <!-- funktion -->
Projekt entfernen Projekt entfernen
<!-- regel --> <!-- regeln -->
Icon: x - X-Icon
<!-- flow --> - Auch Features entfernen
- Hover über Name <!-- testen -->
- Klick auf Icon - RMB auf Projektname
- Icon wird zum Bestätigungs Icon - Klick auf "Projekt entfernen"
- Klick zum entfernen - Bestätigen im Modal
- Projekt entfernt
<!-- funktion --> <!-- funktion -->
Projekt erstellen Projekt erstellen
<!-- regel --> <!-- regeln -->
Alphabetisch in der Sidebar sortiert - Projekt wird direkt aufgerufen
<!-- regel --> - +-Icon
Feature-Anzahl neben dem Namen <!-- testen -->
<!-- flow --> - RMB auf Sidebar
- Inputfeld titel eingeben - Klick auf "Projekt erstellen"
- Mit Icon oder Enter erstellen - Im Modal Namen eingeben
- Projekt wird direkt ausgewählt - Bestätigen

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref, watch } from 'vue'
import { fetchProjekte } from './api.js' import { fetchProjekte } from './api.js'
import SeitenLeiste from './components/SeitenLeiste.vue' import SeitenLeiste from './components/SeitenLeiste.vue'
import FeatureListe from './components/FeatureListe.vue' import FeatureListe from './components/FeatureListe.vue'
@@ -21,13 +21,25 @@ function umbenannt(alt, neu) {
ladeProjekte() ladeProjekte()
} }
// Zuletzt aktives Projekt überlebt das Neuladen
watch(aktivesProjekt, name => {
if (name) localStorage.setItem('aktivesProjekt', name)
else localStorage.removeItem('aktivesProjekt')
})
function wechsleDarkMode() { function wechsleDarkMode() {
dunkel.value = !dunkel.value dunkel.value = !dunkel.value
document.documentElement.classList.toggle('dark', dunkel.value) document.documentElement.classList.toggle('dark', dunkel.value)
localStorage.setItem('darkMode', dunkel.value) localStorage.setItem('darkMode', dunkel.value)
} }
onMounted(ladeProjekte) onMounted(async () => {
await ladeProjekte()
const gemerkt = localStorage.getItem('aktivesProjekt')
if (gemerkt && projekte.value.some(p => p.name === gemerkt)) {
aktivesProjekt.value = gemerkt
}
})
</script> </script>
<template> <template>

View File

@@ -88,6 +88,50 @@ input, textarea {
.meldung { color: var(--rot); font-size: 0.85rem; } .meldung { color: var(--rot); font-size: 0.85rem; }
.kontextmenue {
position: fixed;
z-index: 10;
background: var(--panel);
border: 1px solid var(--rand);
border-radius: 6px;
padding: 0.2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.kontextmenue button {
display: block;
width: 100%;
border: none;
background: none;
text-align: left;
padding: 0.3rem 0.7rem;
border-radius: 4px;
}
.kontextmenue button:hover { background: var(--bg); }
.schleier {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.modal {
min-width: 300px;
display: flex;
flex-direction: column;
gap: 0.7rem;
cursor: default;
}
.modal h2 { margin: 0; font-size: 1rem; }
.modal .frage { margin: 0; }
.modal footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.badge { .badge {
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 600; font-weight: 600;

View File

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

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { fetchFeatures, featureAnlegen } from '../api.js' import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js'
import FeatureKarte from './FeatureKarte.vue' import FeatureKarte from './FeatureKarte.vue'
const props = defineProps({ const props = defineProps({
@@ -10,7 +10,7 @@ const emit = defineEmits(['geaendert'])
const features = ref([]) const features = ref([])
const meldung = ref('') const meldung = ref('')
const neuId = ref(null) // frisch angelegte Karte: Titel direkt bearbeiten const neuId = ref(null) // frisch angelegte Karte: direkt im Vollmodus öffnen
async function laden() { async function laden() {
meldung.value = '' meldung.value = ''
@@ -23,35 +23,84 @@ async function laden() {
} }
} }
async function neuesFeature() { function aktualisiert(altId, f) {
const idx = features.value.findIndex(x => x.id === altId)
if (idx >= 0) features.value[idx] = f
if (neuId.value === altId) neuId.value = f.id
}
// Kontextmenü: Rechtsklick auf eine Card (entfernen) oder den Hauptbereich (erstellen)
const kontextMenue = ref(null) // {feature|null, x, y}
function oeffneKartenMenue(e, f) {
kontextMenue.value = { feature: f, x: e.clientX, y: e.clientY }
}
function oeffneBereichsMenue(e) {
kontextMenue.value = { feature: null, x: e.clientX, y: e.clientY }
}
function schliesseKontextMenue() {
kontextMenue.value = null
}
onMounted(() => window.addEventListener('click', schliesseKontextMenue))
onUnmounted(() => window.removeEventListener('click', schliesseKontextMenue))
// Modal für Erstellen und Entfernen
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', feature}
const modalName = ref('')
const modalFeld = ref(null)
async function menueErstellen() {
kontextMenue.value = null
meldung.value = '' meldung.value = ''
modal.value = { art: 'erstellen' }
modalName.value = ''
await nextTick()
modalFeld.value?.focus()
}
function menueEntfernen() {
const feature = kontextMenue.value.feature
kontextMenue.value = null
meldung.value = ''
modal.value = { art: 'entfernen', feature }
}
async function erstellenBestaetigen() {
meldung.value = ''
const name = modalName.value.trim()
if (!name) return
try { try {
const f = await featureAnlegen(props.projekt, '<!-- titel -->\nNeues Feature\n') const f = await featureAnlegen(props.projekt, `<!-- titel -->\n${name}\n`)
modal.value = null
features.value.push(f) features.value.push(f)
neuId.value = f.id neuId.value = f.id // öffnet die Karte im Vollmodus
emit('geaendert') // Anzahl in der Seitenleiste aktualisieren emit('geaendert')
} catch (e) { } catch (e) {
meldung.value = e.message meldung.value = e.message
} }
} }
function aktualisiert(altId, f) { async function entfernenBestaetigen() {
const idx = features.value.findIndex(x => x.id === altId) meldung.value = ''
if (idx >= 0) features.value[idx] = f try {
if (neuId.value === altId) neuId.value = null await featureLoeschen(props.projekt, modal.value.feature.id)
} features.value = features.value.filter(f => f.id !== modal.value.feature.id)
modal.value = null
function geloescht(id) { emit('geaendert')
features.value = features.value.filter(f => f.id !== id) } catch (e) {
emit('geaendert') meldung.value = e.message
}
} }
watch(() => props.projekt, laden, { immediate: true }) watch(() => props.projekt, laden, { immediate: true })
</script> </script>
<template> <template>
<div> <div class="bereich" @contextmenu.prevent="oeffneBereichsMenue">
<p v-if="meldung" class="meldung">{{ meldung }}</p> <p v-if="meldung && !modal" class="meldung">{{ meldung }}</p>
<div class="gitter"> <div class="gitter">
<FeatureKarte <FeatureKarte
@@ -59,16 +108,54 @@ watch(() => props.projekt, laden, { immediate: true })
:key="f.id" :key="f.id"
:projekt="projekt" :projekt="projekt"
:feature="f" :feature="f"
:titel-bearbeiten="f.id === neuId" :vollmodus="f.id === neuId"
@aktualisiert="aktualisiert" @aktualisiert="aktualisiert"
@geloescht="geloescht(f.id)" @kontextmenue="oeffneKartenMenue($event, f)"
/> />
<button class="karte neu" @click="neuesFeature">+ Feature</button> </div>
<div
v-if="kontextMenue"
class="kontextmenue"
:style="{ left: kontextMenue.x + 'px', top: kontextMenue.y + 'px' }"
>
<button v-if="kontextMenue.feature" @click="menueEntfernen"> Feature entfernen</button>
<button v-else @click="menueErstellen">+ Feature erstellen</button>
</div>
<div v-if="modal" class="schleier" @click.self="modal = null">
<div class="modal karte">
<template v-if="modal.art === 'erstellen'">
<h2>Feature erstellen</h2>
<input
ref="modalFeld"
v-model="modalName"
placeholder="Feature-Titel"
@keyup.enter="erstellenBestaetigen"
@keydown.esc="modal = null"
>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<footer>
<button @click="modal = null">Abbrechen</button>
<button class="primaer" :disabled="!modalName.trim()" @click="erstellenBestaetigen">Bestätigen</button>
</footer>
</template>
<template v-else>
<h2>Feature entfernen</h2>
<p class="frage">{{ modal.feature.sektionen.titel }} entfernen?</p>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<footer>
<button @click="modal = null">Abbrechen</button>
<button class="primaer" @click="entfernenBestaetigen">Entfernen</button>
</footer>
</template>
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.bereich { min-height: 100%; }
/* Spaltenlayout: unterschiedlich hohe Karten stapeln sich ohne Lücken */ /* Spaltenlayout: unterschiedlich hohe Karten stapeln sich ohne Lücken */
.gitter { .gitter {
columns: 340px; columns: 340px;
@@ -83,13 +170,4 @@ watch(() => props.projekt, laden, { immediate: true })
margin-bottom: 0.9rem; margin-bottom: 0.9rem;
width: 100%; width: 100%;
} }
/* Geisterkarte statt Kopfzeilen-Knopf */
.neu {
border: 1px dashed var(--rand);
background: none;
color: var(--dezent);
min-height: 90px;
font-size: 0.95rem;
}
.neu:hover { color: var(--akzent); border-color: var(--akzent); }
</style> </style>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { nextTick, ref } from 'vue' import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { projektAnlegen, projektUmbenennen, projektLoeschen } from '../api.js' import { projektAnlegen, projektUmbenennen, projektLoeschen } from '../api.js'
const props = defineProps({ const props = defineProps({
@@ -8,26 +8,10 @@ const props = defineProps({
}) })
const emit = defineEmits(['auswaehlen', 'geaendert', 'umbenannt']) const emit = defineEmits(['auswaehlen', 'geaendert', 'umbenannt'])
const neuerName = ref('')
const meldung = ref('') const meldung = ref('')
const bearbeitet = ref(null) // Projektname, der gerade umbenannt wird const bearbeitet = ref(null) // Projektname, der gerade umbenannt wird
const umbenennung = ref('') const umbenennung = ref('')
const umbenennFeld = ref(null) const umbenennFeld = ref(null)
const loeschBestaetigung = ref(null) // Projektname, dessen ✕ auf Bestätigung wartet
async function anlegen() {
meldung.value = ''
const name = neuerName.value.trim()
if (!name) return
try {
await projektAnlegen(name)
neuerName.value = ''
emit('geaendert')
emit('auswaehlen', name)
} catch (e) {
meldung.value = e.message
}
}
async function starteUmbenennen(name) { async function starteUmbenennen(name) {
meldung.value = '' meldung.value = ''
@@ -49,16 +33,71 @@ async function umbenennen(alt) {
} }
} }
async function loeschen(name) { // Kontextmenü: Rechtsklick auf Projektname (anpassen/entfernen)
// Erster Klick: Icon wird zum Bestätigungs-Icon, zweiter Klick löscht // oder auf freie Sidebar-Fläche (erstellen)
if (loeschBestaetigung.value !== name) { const kontextMenue = ref(null) // {name|null, x, y}
loeschBestaetigung.value = name
return function oeffneProjektMenue(e, name) {
kontextMenue.value = { name, x: e.clientX, y: e.clientY }
}
function oeffneLeistenMenue(e) {
kontextMenue.value = { name: null, x: e.clientX, y: e.clientY }
}
function schliesseKontextMenue() {
kontextMenue.value = null
}
onMounted(() => window.addEventListener('click', schliesseKontextMenue))
onUnmounted(() => window.removeEventListener('click', schliesseKontextMenue))
// Modal für Erstellen und Entfernen
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', name}
const modalName = ref('')
const modalFeld = ref(null)
async function menueErstellen() {
kontextMenue.value = null
meldung.value = ''
modal.value = { art: 'erstellen' }
modalName.value = ''
await nextTick()
modalFeld.value?.focus()
}
function menueAnpassen() {
const name = kontextMenue.value.name
kontextMenue.value = null
starteUmbenennen(name)
}
function menueEntfernen() {
const name = kontextMenue.value.name
kontextMenue.value = null
meldung.value = ''
modal.value = { art: 'entfernen', name }
}
async function erstellenBestaetigen() {
meldung.value = ''
const name = modalName.value.trim()
if (!name) return
try {
await projektAnlegen(name)
modal.value = null
emit('geaendert')
emit('auswaehlen', name) // Projekt wird direkt aufgerufen
} catch (e) {
meldung.value = e.message
} }
loeschBestaetigung.value = null }
async function entfernenBestaetigen() {
meldung.value = '' meldung.value = ''
try { try {
await projektLoeschen(name) await projektLoeschen(modal.value.name)
modal.value = null
emit('geaendert') emit('geaendert')
} catch (e) { } catch (e) {
meldung.value = e.message meldung.value = e.message
@@ -67,7 +106,7 @@ async function loeschen(name) {
</script> </script>
<template> <template>
<aside> <aside @contextmenu.prevent="oeffneLeistenMenue">
<header> <header>
<p class="gruppe">Projekte</p> <p class="gruppe">Projekte</p>
<slot /> <slot />
@@ -78,7 +117,7 @@ async function loeschen(name) {
:key="p.name" :key="p.name"
:class="{ aktiv: p.name === aktiv }" :class="{ aktiv: p.name === aktiv }"
@click="emit('auswaehlen', p.name)" @click="emit('auswaehlen', p.name)"
@mouseleave="loeschBestaetigung = null" @contextmenu.prevent.stop="oeffneProjektMenue($event, p.name)"
> >
<template v-if="bearbeitet === p.name"> <template v-if="bearbeitet === p.name">
<input <input
@@ -92,25 +131,51 @@ async function loeschen(name) {
</template> </template>
<template v-else> <template v-else>
<span class="name">{{ p.name }}</span> <span class="name">{{ p.name }}</span>
<span class="anzahl">{{ p.anzahl }}</span>
<span class="aktionen" @click.stop>
<button class="icon-knopf" title="Umbenennen" @click="starteUmbenennen(p.name)"></button>
<button
class="icon-knopf rot"
:class="{ bestaetigen: loeschBestaetigung === p.name }"
:title="loeschBestaetigung === p.name ? 'Wirklich löschen?' : 'Löschen'"
@click="loeschen(p.name)"
>{{ loeschBestaetigung === p.name ? '✓' : '✕' }}</button>
</span>
</template> </template>
</li> </li>
</ul> </ul>
<p v-if="meldung && !modal" class="meldung">{{ meldung }}</p>
<form class="neu" @submit.prevent="anlegen"> <div
<input v-model="neuerName" placeholder="Neues Projekt"> v-if="kontextMenue"
<button class="primaer" :disabled="!neuerName.trim()">+</button> class="kontextmenue"
</form> :style="{ left: kontextMenue.x + 'px', top: kontextMenue.y + 'px' }"
<p v-if="meldung" class="meldung">{{ meldung }}</p> >
<template v-if="kontextMenue.name">
<button @click="menueAnpassen"> Projekt anpassen</button>
<button @click="menueEntfernen"> Projekt entfernen</button>
</template>
<button v-else @click="menueErstellen">+ Projekt erstellen</button>
</div>
<div v-if="modal" class="schleier" @click.self="modal = null">
<div class="modal karte">
<template v-if="modal.art === 'erstellen'">
<h2>Projekt erstellen</h2>
<input
ref="modalFeld"
v-model="modalName"
placeholder="Projektname"
@keyup.enter="erstellenBestaetigen"
@keydown.esc="modal = null"
>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<footer>
<button @click="modal = null">Abbrechen</button>
<button class="primaer" :disabled="!modalName.trim()" @click="erstellenBestaetigen">Bestätigen</button>
</footer>
</template>
<template v-else>
<h2>Projekt entfernen</h2>
<p class="frage">{{ modal.name }} mit allen Features entfernen?</p>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<footer>
<button @click="modal = null">Abbrechen</button>
<button class="primaer" @click="entfernenBestaetigen">Entfernen</button>
</footer>
</template>
</div>
</div>
</aside> </aside>
</template> </template>
@@ -154,16 +219,6 @@ li.aktiv { background: var(--bg); font-weight: 600; }
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.anzahl { color: var(--dezent); font-size: 0.78rem; }
.aktionen { display: none; }
li:hover .aktionen { display: inline-flex; }
li:hover .anzahl { display: none; }
li input { width: 100%; padding: 0.1rem 0.4rem; } li input { width: 100%; padding: 0.1rem 0.4rem; }
.icon-knopf.bestaetigen { color: var(--rot); }
.neu {
display: flex;
gap: 0.4rem;
margin-top: 0.4rem;
}
.neu input { flex: 1; min-width: 0; }
</style> </style>