diff --git a/README.md b/README.md index e825b06..912e26a 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,14 @@ Anmeldung mit E-Mail Abmeldung ``` -Funktionen erscheinen als Titelliste; Regeln und Testen zeigt die ausführliche Ansicht. -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. Bearbeitet wird live auf -der Karte (Klick ins Feld, Verlassen speichert, Escape verwirft); jedes Speichern setzt -die Datei neu zusammen. Titeländerungen benennen die Datei passend um. +Der Kompaktmodus zeigt Ziel und Funktionstitel, der Vollmodus alles — umgeschaltet per +mittlerer Maustaste auf die Karte oder das Augen-Icon. Bearbeitet wird nur im Vollmodus: +Klick auf den Text, Verlassen speichert, Escape verwirft; Anlegen und Entfernen laufen +über das Kontextmenü (Rechtsklick) mit Bestätigung im Modal. Jedes Speichern setzt die +Datei neu zusammen, Titeländerungen benennen sie 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) — die Daten sind lokal und selbst geschrieben. diff --git a/backend/features.py b/backend/features.py index 1b3e91a..064a6ee 100644 --- a/backend/features.py +++ b/backend/features.py @@ -63,11 +63,11 @@ def _folge_titel(datei, titel: str): def speichern(projekt: str, id: str, roh: str) -> dict: datei = _datei(projekt, id) - datei.write_text(roh) titel = parser.zerlege(roh)["titel"] - if titel: - datei = _folge_titel(datei, titel) - return _eintrag(datei) + if not titel: + raise ValueError("Titel fehlt") + datei.write_text(roh) + return _eintrag(_folge_titel(datei, titel)) def loeschen(projekt: str, id: str): diff --git a/backend/projekte.py b/backend/projekte.py index d9bdd7a..c0d6de2 100644 --- a/backend/projekte.py +++ b/backend/projekte.py @@ -24,10 +24,7 @@ def liste() -> list[dict]: (p for p in config.DATEN_DIR.iterdir() if p.is_dir()), key=lambda p: p.name.lower(), ) - return [ - {"name": p.name, "anzahl": len(list(p.glob("*.md")))} - for p in ordner - ] + return [{"name": p.name} for p in ordner] def anlegen(name: str): diff --git a/backend/routes.py b/backend/routes.py index 81758bd..e5b47f8 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -74,8 +74,10 @@ async def feature_anlegen(name: str, body: dict): async def feature_speichern(name: str, id: str, body: dict): try: return features.speichern(name, id, body.get("roh") or "") - except (FileNotFoundError, ValueError) as e: + except FileNotFoundError as e: raise HTTPException(404, str(e)) + except ValueError as e: + raise HTTPException(400, str(e)) @router.delete("/projekte/{name}/features/{id}") diff --git a/backend/tests/test_features.py b/backend/tests/test_features.py index 444255f..e4b8631 100644 --- a/backend/tests/test_features.py +++ b/backend/tests/test_features.py @@ -70,6 +70,12 @@ def test_loeschen(projekt): assert features.liste(projekt) == [] +def test_speichern_ohne_titel(projekt): + f = features.anlegen(projekt, "\nDa\n") + with pytest.raises(ValueError): + features.speichern(projekt, f["id"], "\nohne Titel\n") + + def test_unbekannte_id(projekt): with pytest.raises(FileNotFoundError): features.speichern(projekt, "gibts-nicht", "x") diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py index 2779597..5f8f303 100644 --- a/backend/tests/test_routes.py +++ b/backend/tests/test_routes.py @@ -43,6 +43,10 @@ async def test_feature_crud(client): res = await client.post("/api/projekte/Alpha/features", json={"roh": "kein Titel"}) 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}) assert res.status_code == 200 assert res.json()["sektionen"]["sonstiges"] == "mehr" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8cce99d..9412423 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,13 +3,21 @@ import { onMounted, ref, watch } from 'vue' import { fetchProjekte } from './api.js' import SeitenLeiste from './components/SeitenLeiste.vue' import FeatureListe from './components/FeatureListe.vue' +import KontextMenue from './components/KontextMenue.vue' const projekte = ref([]) const aktivesProjekt = ref(null) +const meldung = ref('') const dunkel = ref(document.documentElement.classList.contains('dark')) 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 if (aktivesProjekt.value && !projekte.value.some(p => p.name === aktivesProjekt.value)) { aktivesProjekt.value = null @@ -57,9 +65,12 @@ onMounted(async () => {
- -

Projekt wählen oder anlegen.

+

{{ meldung }}

+ +

Projekt wählen oder anlegen.

+ + diff --git a/frontend/src/components/FeatureKarte.vue b/frontend/src/components/FeatureKarte.vue index 68da013..8caf3c0 100644 --- a/frontend/src/components/FeatureKarte.vue +++ b/frontend/src/components/FeatureKarte.vue @@ -1,7 +1,9 @@ @@ -402,8 +345,8 @@ onMounted(() => { .karte.kern { border-color: var(--akzent); } .karte.optional { border-color: var(--gruen); } -/* 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. */ +/* Vollmodus: volle Breite, Funktionen als Boxen in responsiven Spalten, + alle Details offen */ .karte.gross { column-span: all; display: block; } .karte.gross .liste { columns: 320px; column-gap: 1.2rem; } .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 */ 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 { color: var(--dezent); @@ -484,9 +422,6 @@ section.leer { display: none; } gap: 0.3rem; } .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 */ .zeile.kopf { padding: 0.15rem 0.2rem; border-radius: 4px; } @@ -494,10 +429,10 @@ section.leer { display: none; } .funktionstitel { flex: 1; } .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; } -/* Regeln (Punktliste) und Flow (nummerierte Schritte) als getönte Blöcke */ +/* Regeln (Punktliste) und Testen (nummerierte Schritte) als getönte Blöcke */ .block { background: var(--code-bg); border-radius: 6px; @@ -530,7 +465,7 @@ section.leer { display: none; } color: var(--dezent); font-size: 0.85em; } -/* „+ Regeln"/„+ Flow" bündig zur Listen-Einrückung */ +/* „+ Regeln"/„+ Testen" bündig zur Listen-Einrückung */ .details .hinzu { display: block; padding: 0.05rem 0 0.05rem 0.55rem; diff --git a/frontend/src/components/FeatureListe.vue b/frontend/src/components/FeatureListe.vue index 4ce78a6..92b13d6 100644 --- a/frontend/src/components/FeatureListe.vue +++ b/frontend/src/components/FeatureListe.vue @@ -1,22 +1,34 @@ diff --git a/frontend/src/components/KontextMenue.vue b/frontend/src/components/KontextMenue.vue new file mode 100644 index 0000000..6a82057 --- /dev/null +++ b/frontend/src/components/KontextMenue.vue @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/components/ModalBox.vue b/frontend/src/components/ModalBox.vue new file mode 100644 index 0000000..338d737 --- /dev/null +++ b/frontend/src/components/ModalBox.vue @@ -0,0 +1,44 @@ + + + diff --git a/frontend/src/components/SeitenLeiste.vue b/frontend/src/components/SeitenLeiste.vue index 27d5f6b..0143ba7 100644 --- a/frontend/src/components/SeitenLeiste.vue +++ b/frontend/src/components/SeitenLeiste.vue @@ -1,8 +1,10 @@ @@ -220,5 +164,4 @@ li.aktiv { background: var(--bg); font-weight: 600; } white-space: nowrap; } li input { width: 100%; padding: 0.1rem 0.4rem; } - diff --git a/frontend/src/composables/kontextmenue.js b/frontend/src/composables/kontextmenue.js new file mode 100644 index 0000000..cbe102e --- /dev/null +++ b/frontend/src/composables/kontextmenue.js @@ -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)