This commit is contained in:
Team3
2026-07-26 20:53:01 +02:00
parent ebfb2fc360
commit 0115fd4ce9
13 changed files with 264 additions and 321 deletions

View File

@@ -36,12 +36,14 @@ Anmeldung mit E-Mail
Abmeldung Abmeldung
``` ```
Funktionen erscheinen als Titelliste; Regeln und Testen zeigt die ausführliche Ansicht. Der Kompaktmodus zeigt Ziel und Funktionstitel, der Vollmodus alles — umgeschaltet per
Text vor dem ersten Marker, Inhalt unbekannter Marker und verwaiste mittlerer Maustaste auf die Karte oder das Augen-Icon. Bearbeitet wird nur im Vollmodus:
regeln/testen-Blöcke (auch Alt-Marker `flow`) werden nicht angezeigt, aber beim Speichern Klick auf den Text, Verlassen speichert, Escape verwirft; Anlegen und Entfernen laufen
erhalten. Bearbeitet wird live auf über das Kontextmenü (Rechtsklick) mit Bestätigung im Modal. Jedes Speichern setzt die
der Karte (Klick ins Feld, Verlassen speichert, Escape verwirft); jedes Speichern setzt Datei neu zusammen, Titeländerungen benennen sie passend um.
die Datei neu zusammen. Titeländerungen benennen die Datei passend um.
Text vor dem ersten Marker, Inhalt unbekannter Marker und verwaiste regeln/testen-Blöcke
(auch Alt-Marker `flow`) werden nicht angezeigt, aber beim Speichern erhalten.
Hinweis: Sektionen werden im Frontend als Markdown gerendert (`marked`, ohne Sanitizing) — Hinweis: Sektionen werden im Frontend als Markdown gerendert (`marked`, ohne Sanitizing) —
die Daten sind lokal und selbst geschrieben. die Daten sind lokal und selbst geschrieben.

View File

@@ -63,11 +63,11 @@ def _folge_titel(datei, titel: str):
def speichern(projekt: str, id: str, roh: str) -> dict: def speichern(projekt: str, id: str, roh: str) -> dict:
datei = _datei(projekt, id) datei = _datei(projekt, id)
datei.write_text(roh)
titel = parser.zerlege(roh)["titel"] titel = parser.zerlege(roh)["titel"]
if titel: if not titel:
datei = _folge_titel(datei, titel) raise ValueError("Titel fehlt")
return _eintrag(datei) datei.write_text(roh)
return _eintrag(_folge_titel(datei, titel))
def loeschen(projekt: str, id: str): def loeschen(projekt: str, id: str):

View File

@@ -24,10 +24,7 @@ def liste() -> list[dict]:
(p for p in config.DATEN_DIR.iterdir() if p.is_dir()), (p for p in config.DATEN_DIR.iterdir() if p.is_dir()),
key=lambda p: p.name.lower(), key=lambda p: p.name.lower(),
) )
return [ return [{"name": p.name} for p in ordner]
{"name": p.name, "anzahl": len(list(p.glob("*.md")))}
for p in ordner
]
def anlegen(name: str): def anlegen(name: str):

View File

@@ -74,8 +74,10 @@ async def feature_anlegen(name: str, body: dict):
async def feature_speichern(name: str, id: str, body: dict): async def feature_speichern(name: str, id: str, body: dict):
try: try:
return features.speichern(name, id, body.get("roh") or "") return features.speichern(name, id, body.get("roh") or "")
except (FileNotFoundError, ValueError) as e: except FileNotFoundError as e:
raise HTTPException(404, str(e)) raise HTTPException(404, str(e))
except ValueError as e:
raise HTTPException(400, str(e))
@router.delete("/projekte/{name}/features/{id}") @router.delete("/projekte/{name}/features/{id}")

View File

@@ -70,6 +70,12 @@ def test_loeschen(projekt):
assert features.liste(projekt) == [] assert features.liste(projekt) == []
def test_speichern_ohne_titel(projekt):
f = features.anlegen(projekt, "<!-- titel -->\nDa\n")
with pytest.raises(ValueError):
features.speichern(projekt, f["id"], "<!-- ziel -->\nohne Titel\n")
def test_unbekannte_id(projekt): def test_unbekannte_id(projekt):
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
features.speichern(projekt, "gibts-nicht", "x") features.speichern(projekt, "gibts-nicht", "x")

View File

@@ -43,6 +43,10 @@ async def test_feature_crud(client):
res = await client.post("/api/projekte/Alpha/features", json={"roh": "kein Titel"}) res = await client.post("/api/projekte/Alpha/features", json={"roh": "kein Titel"})
assert res.status_code == 400 assert res.status_code == 400
# Titel fehlt → 400
res = await client.put("/api/projekte/Alpha/features/login", json={"roh": "kein Titel"})
assert res.status_code == 400
res = await client.put("/api/projekte/Alpha/features/login", json={"roh": "mehr\n" + ROH}) res = await client.put("/api/projekte/Alpha/features/login", json={"roh": "mehr\n" + ROH})
assert res.status_code == 200 assert res.status_code == 200
assert res.json()["sektionen"]["sonstiges"] == "mehr" assert res.json()["sektionen"]["sonstiges"] == "mehr"

View File

@@ -3,13 +3,21 @@ 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'
import KontextMenue from './components/KontextMenue.vue'
const projekte = ref([]) const projekte = ref([])
const aktivesProjekt = ref(null) const aktivesProjekt = ref(null)
const meldung = ref('')
const dunkel = ref(document.documentElement.classList.contains('dark')) const dunkel = ref(document.documentElement.classList.contains('dark'))
async function ladeProjekte() { async function ladeProjekte() {
projekte.value = await fetchProjekte() meldung.value = ''
try {
projekte.value = await fetchProjekte()
} catch (e) {
meldung.value = e.message
return
}
// Auswahl verwerfen, wenn das Projekt (z. B. nach Löschen) weg ist // Auswahl verwerfen, wenn das Projekt (z. B. nach Löschen) weg ist
if (aktivesProjekt.value && !projekte.value.some(p => p.name === aktivesProjekt.value)) { if (aktivesProjekt.value && !projekte.value.some(p => p.name === aktivesProjekt.value)) {
aktivesProjekt.value = null aktivesProjekt.value = null
@@ -57,9 +65,12 @@ onMounted(async () => {
</SeitenLeiste> </SeitenLeiste>
<main> <main>
<FeatureListe v-if="aktivesProjekt" :projekt="aktivesProjekt" @geaendert="ladeProjekte" /> <p v-if="meldung" class="meldung">{{ meldung }}</p>
<p v-else class="leer">Projekt wählen oder anlegen.</p> <FeatureListe v-if="aktivesProjekt" :projekt="aktivesProjekt" />
<p v-else-if="!meldung" class="leer">Projekt wählen oder anlegen.</p>
</main> </main>
<KontextMenue />
</div> </div>
</template> </template>

View File

@@ -1,7 +1,9 @@
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { nextTick, ref, watch } from 'vue'
import { marked } from 'marked' import { marked } from 'marked'
import { featureSpeichern } from '../api.js' import { featureSpeichern } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
import ModalBox from './ModalBox.vue'
const props = defineProps({ const props = defineProps({
projekt: { type: String, required: true }, projekt: { type: String, required: true },
@@ -27,7 +29,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 gross = ref(props.vollmodus) // ausführliche Ansicht
const entwurf = ref('') const entwurf = ref('')
const meldung = ref('') const meldung = ref('')
const feldRef = ref(null) const feldRef = ref(null)
@@ -60,12 +62,7 @@ async function starte(feld, i, j) {
} }
function abbrechen() { function abbrechen() {
const a = aktiv.value
aktiv.value = null aktiv.value = null
// frisch angelegte, noch leere Funktion wieder entfernen
if (a && !a.original && a.feld === 'funktion') {
s.value.funktionen.splice(a.i, 1)
}
} }
async function fertig() { async function fertig() {
@@ -73,78 +70,51 @@ async function fertig() {
if (!a) return if (!a) return
aktiv.value = null aktiv.value = null
const neu = entwurf.value.trim() const neu = entwurf.value.trim()
if (neu === a.original) return
if (neu === a.original) { abbrechenLeer(a); return }
if (a.feld === 'titel') { if (a.feld === 'titel') {
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 === 'funktion') {
if (!neu) return // Funktionstitel ebenfalls; entfernt wird über das Kontextmenü
s.value.funktionen[a.i].text = neu
} else if (a.feld === 'ziel') { } else if (a.feld === 'ziel') {
if (a.i === undefined) s.value.ziel = neu if (a.i === undefined) s.value.ziel = neu
else s.value.funktionen[a.i].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 === 'testen') {
s.value.funktionen[a.i].testen = neu
} else if (a.feld === 'regeln') { } else if (a.feld === 'regeln') {
if (a.i === undefined) s.value.regeln = neu if (a.i === undefined) s.value.regeln = neu
else s.value.funktionen[a.i].regeln = neu else s.value.funktionen[a.i].regeln = neu
} else if (a.feld === 'testen') {
s.value.funktionen[a.i].testen = neu
} }
await speichern() await speichern()
} }
function abbrechenLeer(a) { // Rechtsklick auf eine Funktion (entfernen) oder auf die Karte (erstellen/Feature entfernen)
if (!a.original && a.feld === 'funktion') s.value.funktionen.splice(a.i, 1)
}
// Kontextmenü: RMB auf eine Funktion (entfernen) oder auf die Karte (erstellen/Feature entfernen)
const kontextMenue = ref(null) // {funktion: index|null, x, y}
function oeffneKontextMenue(e, i = null) {
kontextMenue.value = { funktion: i, x: e.clientX, y: e.clientY }
}
function schliesseKontextMenue() {
kontextMenue.value = null
}
onMounted(() => window.addEventListener('click', schliesseKontextMenue))
onUnmounted(() => window.removeEventListener('click', schliesseKontextMenue))
// Modal für Funktion erstellen und entfernen
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', i} const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', i}
const modalName = ref('')
const modalFeld = ref(null)
async function menueFunktionErstellen() { function funktionsMenue(e, i) {
kontextMenue.value = null oeffneMenue(e, [
modal.value = { art: 'erstellen' } { text: '✕ Funktion entfernen', aktion: () => (modal.value = { art: 'entfernen', i }) },
modalName.value = '' ])
await nextTick()
modalFeld.value?.focus()
} }
function menueFunktionEntfernen() { function kartenMenue(e) {
const i = kontextMenue.value.funktion const eintraege = []
kontextMenue.value = null if (gross.value) {
modal.value = { art: 'entfernen', i } eintraege.push({ text: '+ Funktion erstellen', aktion: () => (modal.value = { art: 'erstellen' }) })
}
eintraege.push({ text: '✕ Feature entfernen', aktion: () => emit('entfernen') })
oeffneMenue(e, eintraege)
} }
function menueFeatureEntfernen() { async function funktionErstellen(titel) {
kontextMenue.value = null s.value.funktionen.push({ text: titel, ziel: '', regeln: '', testen: '' })
emit('entfernen')
}
async function erstellenBestaetigen() {
const name = modalName.value.trim()
if (!name) return
s.value.funktionen.push({ text: name, ziel: '', regeln: '', testen: '' })
modal.value = null modal.value = null
await speichern() await speichern()
} }
async function entfernenBestaetigen() { async function funktionEntfernen() {
s.value.funktionen.splice(modal.value.i, 1) s.value.funktionen.splice(modal.value.i, 1)
modal.value = null modal.value = null
await speichern() await speichern()
@@ -203,10 +173,6 @@ function punkte(text) {
function wechsleAnsicht() { function wechsleAnsicht() {
gross.value = !gross.value gross.value = !gross.value
} }
onMounted(() => {
if (props.vollmodus) gross.value = true
})
</script> </script>
<template> <template>
@@ -215,7 +181,7 @@ onMounted(() => {
:class="[s.art, { gross }]" :class="[s.art, { gross }]"
@mousedown.middle.prevent @mousedown.middle.prevent
@auxclick.middle.prevent="wechsleAnsicht" @auxclick.middle.prevent="wechsleAnsicht"
@contextmenu.prevent.stop="oeffneKontextMenue($event)" @contextmenu.prevent.stop="kartenMenue"
> >
<header> <header>
<input <input
@@ -282,7 +248,7 @@ onMounted(() => {
:key="i" :key="i"
class="funktion" class="funktion"
:class="{ offen: aktiv && aktiv.i === i }" :class="{ offen: aktiv && aktiv.i === i }"
@contextmenu.prevent.stop="oeffneKontextMenue($event, i)" @contextmenu.prevent.stop="funktionsMenue($event, i)"
> >
<div class="zeile kopf"> <div class="zeile kopf">
<span class="pfeil"></span> <span class="pfeil"></span>
@@ -297,7 +263,7 @@ onMounted(() => {
<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>
</div> </div>
<!-- Regeln und Flow nur beim Hover über die Funktion (oder während der Bearbeitung) --> <!-- Ziel, Regeln und Testen nur im Vollmodus -->
<div class="details"> <div class="details">
<!-- Ziel der Funktion --> <!-- Ziel der Funktion -->
<div v-if="istAktiv('ziel', i) || f.ziel" class="block"> <div v-if="istAktiv('ziel', i) || f.ziel" class="block">
@@ -357,44 +323,21 @@ onMounted(() => {
<p v-if="meldung" class="meldung">{{ meldung }}</p> <p v-if="meldung" class="meldung">{{ meldung }}</p>
<div <ModalBox
v-if="kontextMenue" v-if="modal && modal.art === 'erstellen'"
class="kontextmenue" titel="Funktion erstellen"
:style="{ left: kontextMenue.x + 'px', top: kontextMenue.y + 'px' }" platzhalter="Funktionstitel"
> @bestaetigt="funktionErstellen"
<button v-if="kontextMenue.funktion !== null" @click="menueFunktionEntfernen"> Funktion entfernen</button> @abbrechen="modal = null"
<template v-else> />
<button v-if="gross" @click="menueFunktionErstellen">+ Funktion erstellen</button> <ModalBox
<button @click="menueFeatureEntfernen"> Feature entfernen</button> v-else-if="modal"
</template> titel="Funktion entfernen"
</div> :frage="`„${s.funktionen[modal.i].text}“ entfernen?`"
knopf="Entfernen"
<div v-if="modal" class="schleier" @click.self="modal = null"> @bestaetigt="funktionEntfernen"
<div class="modal karte"> @abbrechen="modal = null"
<template v-if="modal.art === 'erstellen'"> />
<h2>Funktion erstellen</h2>
<input
ref="modalFeld"
v-model="modalName"
placeholder="Funktionstitel"
@keyup.enter="erstellenBestaetigen"
@keydown.esc="modal = null"
>
<footer>
<button @click="modal = null">Abbrechen</button>
<button class="primaer" :disabled="!modalName.trim()" @click="erstellenBestaetigen">Bestätigen</button>
</footer>
</template>
<template v-else>
<h2>Funktion entfernen</h2>
<p class="frage">{{ s.funktionen[modal.i].text }} entfernen?</p>
<footer>
<button @click="modal = null">Abbrechen</button>
<button class="primaer" @click="entfernenBestaetigen">Entfernen</button>
</footer>
</template>
</div>
</div>
</article> </article>
</template> </template>
@@ -402,8 +345,8 @@ onMounted(() => {
.karte.kern { border-color: var(--akzent); } .karte.kern { border-color: var(--akzent); }
.karte.optional { border-color: var(--gruen); } .karte.optional { border-color: var(--gruen); }
/* Ausführliche Ansicht: volle Breite, Funktionen in Spalten, alle Details offen. /* Vollmodus: volle Breite, Funktionen als Boxen in responsiven Spalten,
Jede Funktion als eigene Box, Zufüge-Knöpfe nur beim Hover über die Box. */ alle Details offen */
.karte.gross { column-span: all; display: block; } .karte.gross { column-span: all; display: block; }
.karte.gross .liste { columns: 320px; 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; }
@@ -454,11 +397,6 @@ section + section { border-top: 1px solid var(--rand); }
/* Leere Sektionen nur in der ausführlichen Ansicht */ /* Leere Sektionen nur in der ausführlichen Ansicht */
section.leer { display: none; } section.leer { display: none; }
.werkzeug { opacity: 0; transition: opacity 0.1s; }
.karte:hover .werkzeug, .werkzeug:focus { opacity: 1; }
/* „+ Funktion" belegt ohne Hover auch keinen Platz */
.hinzu.werkzeug { display: none; }
.karte:hover .hinzu.werkzeug { display: block; }
.hinweis { .hinweis {
color: var(--dezent); color: var(--dezent);
@@ -484,9 +422,6 @@ section.leer { display: none; }
gap: 0.3rem; gap: 0.3rem;
} }
.zeile .inhalt, .zeile input, .zeile textarea { flex: 1; min-width: 0; } .zeile .inhalt, .zeile input, .zeile textarea { flex: 1; min-width: 0; }
/* ✕ nur auf der Zeile, über der die Maus steht */
.zeile .icon-knopf { font-size: 0.8rem; opacity: 0; }
.zeile:hover .icon-knopf { opacity: 1; }
/* Funktionsliste */ /* Funktionsliste */
.zeile.kopf { padding: 0.15rem 0.2rem; border-radius: 4px; } .zeile.kopf { padding: 0.15rem 0.2rem; border-radius: 4px; }
@@ -494,10 +429,10 @@ section.leer { display: none; }
.funktionstitel { flex: 1; } .funktionstitel { flex: 1; }
.pfeil { color: var(--dezent); font-size: 0.75rem; } .pfeil { color: var(--dezent); font-size: 0.75rem; }
/* Regeln und Flow nur in der ausführlichen Ansicht */ /* Ziel, Regeln und Testen nur im Vollmodus */
.details { display: none; font-size: 0.9rem; } .details { display: none; font-size: 0.9rem; }
/* Regeln (Punktliste) und Flow (nummerierte Schritte) als getönte Blöcke */ /* Regeln (Punktliste) und Testen (nummerierte Schritte) als getönte Blöcke */
.block { .block {
background: var(--code-bg); background: var(--code-bg);
border-radius: 6px; border-radius: 6px;
@@ -530,7 +465,7 @@ section.leer { display: none; }
color: var(--dezent); color: var(--dezent);
font-size: 0.85em; font-size: 0.85em;
} }
/* „+ Regeln"/„+ Flow" bündig zur Listen-Einrückung */ /* „+ Regeln"/„+ Testen" bündig zur Listen-Einrückung */
.details .hinzu { .details .hinzu {
display: block; display: block;
padding: 0.05rem 0 0.05rem 0.55rem; padding: 0.05rem 0 0.05rem 0.55rem;

View File

@@ -1,22 +1,34 @@
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { ref, watch } from 'vue'
import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js' import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
import FeatureKarte from './FeatureKarte.vue' import FeatureKarte from './FeatureKarte.vue'
import ModalBox from './ModalBox.vue'
const props = defineProps({ const props = defineProps({
projekt: { type: String, required: true }, projekt: { type: String, required: true },
}) })
const emit = defineEmits(['geaendert'])
const features = ref([]) const features = ref([])
const meldung = ref('') const meldung = ref('')
const neuId = ref(null) // frisch angelegte Karte: direkt im Vollmodus öffnen const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', feature}
const neuKey = ref(null) // frisch angelegte Karte: direkt im Vollmodus öffnen
// Stabiler Key je Karte: die id ändert sich mit dem Titel, ein Wechsel würde
// die Karte neu aufbauen und dabei den Vollmodus verlieren.
let letzterKey = 0
const mitKey = (f) => ({ ...f, key: ++letzterKey })
function sortiere() {
features.value.sort((a, b) =>
a.sektionen.titel.localeCompare(b.sektionen.titel, 'de', { sensitivity: 'base' }))
}
async function laden() { async function laden() {
meldung.value = '' meldung.value = ''
neuId.value = null neuKey.value = null
try { try {
features.value = await fetchFeatures(props.projekt) features.value = (await fetchFeatures(props.projekt)).map(mitKey)
} catch (e) { } catch (e) {
features.value = [] features.value = []
meldung.value = e.message meldung.value = e.message
@@ -25,65 +37,38 @@ async function laden() {
function aktualisiert(altId, f) { function aktualisiert(altId, f) {
const idx = features.value.findIndex(x => x.id === altId) const idx = features.value.findIndex(x => x.id === altId)
if (idx >= 0) features.value[idx] = f if (idx < 0) return
if (neuId.value === altId) neuId.value = f.id features.value[idx] = { ...f, key: features.value[idx].key }
sortiere()
} }
// Kontextmenü auf dem Hauptbereich (erstellen); das Menü der Card führt die Karte selbst function bereichsMenue(e) {
const kontextMenue = ref(null) // {x, y} oeffneMenue(e, [{ text: '+ Feature erstellen', aktion: () => oeffneModal({ art: 'erstellen' }) }])
function oeffneBereichsMenue(e) {
kontextMenue.value = { x: e.clientX, y: e.clientY }
} }
function schliesseKontextMenue() { function oeffneModal(m) {
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' } modal.value = m
modalName.value = ''
await nextTick()
modalFeld.value?.focus()
} }
function featureEntfernen(feature) { async function erstellen(titel) {
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 -->\n${name}\n`) const f = mitKey(await featureAnlegen(props.projekt, `<!-- titel -->\n${titel}\n`))
modal.value = null modal.value = null
features.value.push(f) features.value.push(f)
neuId.value = f.id // öffnet die Karte im Vollmodus sortiere()
emit('geaendert') neuKey.value = f.key // öffnet die Karte im Vollmodus
} catch (e) { } catch (e) {
meldung.value = e.message meldung.value = e.message
} }
} }
async function entfernenBestaetigen() { async function entfernen() {
meldung.value = '' const id = modal.value.feature.id
try { try {
await featureLoeschen(props.projekt, modal.value.feature.id) await featureLoeschen(props.projekt, id)
features.value = features.value.filter(f => f.id !== modal.value.feature.id) features.value = features.value.filter(f => f.id !== id)
modal.value = null modal.value = null
emit('geaendert')
} catch (e) { } catch (e) {
meldung.value = e.message meldung.value = e.message
} }
@@ -93,57 +78,38 @@ watch(() => props.projekt, laden, { immediate: true })
</script> </script>
<template> <template>
<div class="bereich" @contextmenu.prevent="oeffneBereichsMenue"> <div class="bereich" @contextmenu.prevent="bereichsMenue">
<p v-if="meldung && !modal" class="meldung">{{ meldung }}</p> <p v-if="meldung && !modal" class="meldung">{{ meldung }}</p>
<div class="gitter"> <div class="gitter">
<FeatureKarte <FeatureKarte
v-for="f in features" v-for="f in features"
:key="f.id" :key="f.key"
:projekt="projekt" :projekt="projekt"
:feature="f" :feature="f"
:vollmodus="f.id === neuId" :vollmodus="f.key === neuKey"
@aktualisiert="aktualisiert" @aktualisiert="aktualisiert"
@entfernen="featureEntfernen(f)" @entfernen="oeffneModal({ art: 'entfernen', feature: f })"
/> />
</div> </div>
<div <ModalBox
v-if="kontextMenue" v-if="modal && modal.art === 'erstellen'"
class="kontextmenue" titel="Feature erstellen"
:style="{ left: kontextMenue.x + 'px', top: kontextMenue.y + 'px' }" platzhalter="Feature-Titel"
> :meldung="meldung"
<button @click="menueErstellen">+ Feature erstellen</button> @bestaetigt="erstellen"
</div> @abbrechen="modal = null"
/>
<div v-if="modal" class="schleier" @click.self="modal = null"> <ModalBox
<div class="modal karte"> v-else-if="modal"
<template v-if="modal.art === 'erstellen'"> titel="Feature entfernen"
<h2>Feature erstellen</h2> :frage="`„${modal.feature.sektionen.titel}“ entfernen?`"
<input knopf="Entfernen"
ref="modalFeld" :meldung="meldung"
v-model="modalName" @bestaetigt="entfernen"
placeholder="Feature-Titel" @abbrechen="modal = null"
@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>
</template> </template>

View File

@@ -0,0 +1,18 @@
<script setup>
import { kontextMenue, schliesseMenue } from '../composables/kontextmenue.js'
function waehle(eintrag) {
schliesseMenue()
eintrag.aktion()
}
</script>
<template>
<div
v-if="kontextMenue"
class="kontextmenue"
:style="{ left: kontextMenue.x + 'px', top: kontextMenue.y + 'px' }"
>
<button v-for="e in kontextMenue.eintraege" :key="e.text" @click="waehle(e)">{{ e.text }}</button>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup>
import { onMounted, ref } from 'vue'
const props = defineProps({
titel: { type: String, required: true },
frage: { type: String, default: '' }, // gesetzt = Rückfrage statt Eingabe
platzhalter: { type: String, default: '' },
knopf: { type: String, default: 'Bestätigen' },
meldung: { type: String, default: '' },
})
const emit = defineEmits(['bestaetigt', 'abbrechen'])
const wert = ref('')
const feld = ref(null)
function bestaetigen() {
if (!props.frage && !wert.value.trim()) return
emit('bestaetigt', wert.value.trim())
}
onMounted(() => feld.value?.focus())
</script>
<template>
<div class="schleier" @click.self="emit('abbrechen')">
<div class="modal karte">
<h2>{{ titel }}</h2>
<p v-if="frage" class="frage">{{ frage }}</p>
<input
v-else
ref="feld"
v-model="wert"
:placeholder="platzhalter"
@keyup.enter="bestaetigen"
@keydown.esc="emit('abbrechen')"
>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<footer>
<button @click="emit('abbrechen')">Abbrechen</button>
<button class="primaer" :disabled="!frage && !wert.trim()" @click="bestaetigen">{{ knopf }}</button>
</footer>
</div>
</div>
</template>

View File

@@ -1,8 +1,10 @@
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, ref } from 'vue' import { nextTick, ref } from 'vue'
import { projektAnlegen, projektUmbenennen, projektLoeschen } from '../api.js' import { projektAnlegen, projektUmbenennen, projektLoeschen } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
import ModalBox from './ModalBox.vue'
const props = defineProps({ defineProps({
projekte: { type: Array, required: true }, projekte: { type: Array, required: true },
aktiv: { type: String, default: null }, aktiv: { type: String, default: null },
}) })
@@ -12,6 +14,7 @@ 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 modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', name}
async function starteUmbenennen(name) { async function starteUmbenennen(name) {
meldung.value = '' meldung.value = ''
@@ -33,56 +36,24 @@ async function umbenennen(alt) {
} }
} }
// Kontextmenü: Rechtsklick auf Projektname (anpassen/entfernen) // Rechtsklick auf einen Projektnamen (anpassen/entfernen) oder auf die Sidebar (erstellen)
// oder auf freie Sidebar-Fläche (erstellen) function projektMenue(e, name) {
const kontextMenue = ref(null) // {name|null, x, y} oeffneMenue(e, [
{ text: '✎ Projekt anpassen', aktion: () => starteUmbenennen(name) },
function oeffneProjektMenue(e, name) { { text: '✕ Projekt entfernen', aktion: () => oeffneModal({ art: 'entfernen', name }) },
kontextMenue.value = { name, x: e.clientX, y: e.clientY } ])
} }
function oeffneLeistenMenue(e) { function leistenMenue(e) {
kontextMenue.value = { name: null, x: e.clientX, y: e.clientY } oeffneMenue(e, [{ text: '+ Projekt erstellen', aktion: () => oeffneModal({ art: 'erstellen' }) }])
} }
function schliesseKontextMenue() { function oeffneModal(m) {
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 = '' meldung.value = ''
modal.value = { art: 'erstellen' } modal.value = m
modalName.value = ''
await nextTick()
modalFeld.value?.focus()
} }
function menueAnpassen() { async function erstellen(name) {
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 { try {
await projektAnlegen(name) await projektAnlegen(name)
modal.value = null modal.value = null
@@ -93,8 +64,7 @@ async function erstellenBestaetigen() {
} }
} }
async function entfernenBestaetigen() { async function entfernen() {
meldung.value = ''
try { try {
await projektLoeschen(modal.value.name) await projektLoeschen(modal.value.name)
modal.value = null modal.value = null
@@ -106,7 +76,7 @@ async function entfernenBestaetigen() {
</script> </script>
<template> <template>
<aside @contextmenu.prevent="oeffneLeistenMenue"> <aside @contextmenu.prevent="leistenMenue">
<header> <header>
<p class="gruppe">Projekte</p> <p class="gruppe">Projekte</p>
<slot /> <slot />
@@ -117,65 +87,39 @@ async function entfernenBestaetigen() {
: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)"
@contextmenu.prevent.stop="oeffneProjektMenue($event, p.name)" @contextmenu.prevent.stop="projektMenue($event, p.name)"
> >
<template v-if="bearbeitet === p.name"> <input
<input v-if="bearbeitet === p.name"
ref="umbenennFeld" ref="umbenennFeld"
v-model="umbenennung" v-model="umbenennung"
@keyup.enter="umbenennen(p.name)" @keyup.enter="umbenennen(p.name)"
@keyup.escape="bearbeitet = null" @keydown.esc="bearbeitet = null"
@blur="umbenennen(p.name)" @blur="umbenennen(p.name)"
@click.stop @click.stop
> >
</template> <span v-else class="name">{{ p.name }}</span>
<template v-else>
<span class="name">{{ p.name }}</span>
</template>
</li> </li>
</ul> </ul>
<p v-if="meldung && !modal" class="meldung">{{ meldung }}</p> <p v-if="meldung && !modal" class="meldung">{{ meldung }}</p>
<div <ModalBox
v-if="kontextMenue" v-if="modal && modal.art === 'erstellen'"
class="kontextmenue" titel="Projekt erstellen"
:style="{ left: kontextMenue.x + 'px', top: kontextMenue.y + 'px' }" platzhalter="Projektname"
> :meldung="meldung"
<template v-if="kontextMenue.name"> @bestaetigt="erstellen"
<button @click="menueAnpassen"> Projekt anpassen</button> @abbrechen="modal = null"
<button @click="menueEntfernen"> Projekt entfernen</button> />
</template> <ModalBox
<button v-else @click="menueErstellen">+ Projekt erstellen</button> v-else-if="modal"
</div> titel="Projekt entfernen"
:frage="`„${modal.name}“ mit allen Features entfernen?`"
<div v-if="modal" class="schleier" @click.self="modal = null"> knopf="Entfernen"
<div class="modal karte"> :meldung="meldung"
<template v-if="modal.art === 'erstellen'"> @bestaetigt="entfernen"
<h2>Projekt erstellen</h2> @abbrechen="modal = null"
<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>
@@ -220,5 +164,4 @@ li.aktiv { background: var(--bg); font-weight: 600; }
white-space: nowrap; white-space: nowrap;
} }
li input { width: 100%; padding: 0.1rem 0.4rem; } li input { width: 100%; padding: 0.1rem 0.4rem; }
</style> </style>

View File

@@ -0,0 +1,15 @@
import { ref } from 'vue'
// Ein einziges Kontextmenü für die ganze App — ein neues verdrängt das alte,
// darum kann nie mehr als eines offen sein.
export const kontextMenue = ref(null) // {eintraege: [{text, aktion}], x, y}
export function oeffneMenue(e, eintraege) {
kontextMenue.value = { eintraege, x: e.clientX, y: e.clientY }
}
export function schliesseMenue() {
kontextMenue.value = null
}
window.addEventListener('click', schliesseMenue)