init
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
||||
.PHONY: test dev build web
|
||||
|
||||
test:
|
||||
cd backend && python3 -m pytest -q
|
||||
|
||||
# Entwicklung: Backend (8200) + Vite (5274)
|
||||
dev:
|
||||
cd backend && uvicorn server:app --reload --port 8200 & \
|
||||
cd frontend && npx vite --port 5274
|
||||
|
||||
# Produktion: Frontend bauen, dann nur den Server starten (serviert frontend/dist)
|
||||
build:
|
||||
cd frontend && npm install && npm run build
|
||||
|
||||
web:
|
||||
cd backend && uvicorn server:app --port 8200
|
||||
41
README.md
Normal file
41
README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Planer2 — Feature-Verwaltung
|
||||
|
||||
Verwaltet Features meiner Projekte als Cards und definiert damit die „Wahrheit" je Projekt.
|
||||
Rein manuelle Pflege: Sidebar links = Projekte, Hauptbereich = Feature-Cards.
|
||||
|
||||
- `make dev` — Entwicklung: Backend auf 8200, Frontend auf http://localhost:5274
|
||||
- `make build && make web` — Produktion: http://localhost:8200
|
||||
- `make test` — Backend-Tests
|
||||
|
||||
## Datenablage
|
||||
|
||||
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.
|
||||
|
||||
Format (Marker allein auf einer Zeile; `<!-- funktion -->` ist wiederholbar, jede
|
||||
`<!-- regel -->` gehört zur davorstehenden Funktion; Trennzeilen aus `---` sind Optik):
|
||||
|
||||
```markdown
|
||||
<!-- titel -->
|
||||
Login
|
||||
<!-- art -->
|
||||
kern
|
||||
<!-- ziel -->
|
||||
Kurzer Satz, was das Ziel ist.
|
||||
<!-- funktion -->
|
||||
Anmeldung mit E-Mail
|
||||
<!-- regel -->
|
||||
Passwort nie loggen
|
||||
<!-- funktion -->
|
||||
Abmeldung
|
||||
<!-- sonstiges -->
|
||||
Notizen.
|
||||
```
|
||||
|
||||
Text vor dem ersten Marker, Inhalt unbekannter Marker und Regeln ohne Funktion erscheinen
|
||||
in der Anzeige unter „Sonstiges"; die Datei bleibt unangetastet. 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.
|
||||
|
||||
Hinweis: Sektionen werden im Frontend als Markdown gerendert (`marked`, ohne Sanitizing) —
|
||||
die Daten sind lokal und selbst geschrieben.
|
||||
4
backend/config.py
Normal file
4
backend/config.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
DATEN_DIR = PROJECT_ROOT / "daten"
|
||||
74
backend/features.py
Normal file
74
backend/features.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Feature-Dateien: eine .md je Feature, Rohtext wird verbatim gespeichert."""
|
||||
|
||||
import re
|
||||
|
||||
import parser
|
||||
import projekte
|
||||
|
||||
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
|
||||
|
||||
|
||||
def _slug(titel: str) -> str:
|
||||
s = titel.lower().translate(_UMLAUTE)
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
|
||||
return s or "feature"
|
||||
|
||||
|
||||
def _eintrag(datei) -> dict:
|
||||
roh = datei.read_text()
|
||||
return {"id": datei.stem, "roh": roh, "sektionen": parser.zerlege(roh)}
|
||||
|
||||
|
||||
def _datei(projekt: str, id: str):
|
||||
datei = projekte.wurzel(projekt) / f"{id}.md"
|
||||
if not datei.is_file():
|
||||
raise FileNotFoundError(f"Feature „{id}“ nicht gefunden")
|
||||
return datei
|
||||
|
||||
|
||||
def liste(projekt: str) -> list[dict]:
|
||||
dateien = projekte.wurzel(projekt).glob("*.md")
|
||||
eintraege = [_eintrag(d) for d in sorted(dateien)]
|
||||
return sorted(eintraege, key=lambda e: e["sektionen"]["titel"].lower())
|
||||
|
||||
|
||||
def anlegen(projekt: str, roh: str) -> dict:
|
||||
ordner = projekte.wurzel(projekt)
|
||||
titel = parser.zerlege(roh)["titel"]
|
||||
if not titel:
|
||||
raise ValueError("Titel fehlt")
|
||||
slug = _slug(titel)
|
||||
datei = ordner / f"{slug}.md"
|
||||
n = 2
|
||||
while datei.exists():
|
||||
datei = ordner / f"{slug}-{n}.md"
|
||||
n += 1
|
||||
datei.write_text(roh)
|
||||
return _eintrag(datei)
|
||||
|
||||
|
||||
def _folge_titel(datei, titel: str):
|
||||
"""Benennt die Datei um, wenn der Slug nicht mehr zum Titel passt."""
|
||||
slug = _slug(titel)
|
||||
if re.fullmatch(re.escape(slug) + r"(-\d+)?", datei.stem):
|
||||
return datei # passt schon (inkl. Kollisions-Suffix)
|
||||
ziel = datei.parent / f"{slug}.md"
|
||||
n = 2
|
||||
while ziel.exists():
|
||||
ziel = datei.parent / f"{slug}-{n}.md"
|
||||
n += 1
|
||||
datei.rename(ziel)
|
||||
return ziel
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def loeschen(projekt: str, id: str):
|
||||
_datei(projekt, id).unlink()
|
||||
72
backend/parser.py
Normal file
72
backend/parser.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Zerlegt den rohen Feature-Text in Sektionen — nur für die Anzeige.
|
||||
|
||||
Format: titel (eine Zeile), ziel, dann wiederholbare <!-- funktion -->-Blöcke,
|
||||
denen <!-- regel -->-Blöcke zugeordnet werden, zuletzt sonstiges.
|
||||
Präambel, unbekannte Marker und Regeln ohne Funktion landen in `sonstiges`.
|
||||
Trennzeilen aus Bindestrichen (---) sind reine Optik und werden ignoriert.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
MARKER = re.compile(r"^<!--\s*([a-zä-ü]+)\s*-->\s*$")
|
||||
TRENNER = re.compile(r"^-{3,}\s*$")
|
||||
|
||||
|
||||
def zerlege(roh: str) -> dict:
|
||||
# Erst in Blöcke (schluessel, text) in Dateireihenfolge schneiden
|
||||
bloecke = []
|
||||
aktuell = None # None = Präambel vor dem ersten Marker
|
||||
puffer = []
|
||||
|
||||
def ablegen():
|
||||
text = "\n".join(z for z in puffer if not TRENNER.match(z)).strip()
|
||||
if text:
|
||||
bloecke.append((aktuell, text))
|
||||
puffer.clear()
|
||||
|
||||
for zeile in roh.splitlines():
|
||||
m = MARKER.match(zeile)
|
||||
if m:
|
||||
ablegen()
|
||||
aktuell = m.group(1)
|
||||
else:
|
||||
puffer.append(zeile)
|
||||
ablegen()
|
||||
|
||||
titel = []
|
||||
ziel = []
|
||||
funktionen = []
|
||||
sonstiges = []
|
||||
art = []
|
||||
for schluessel, text in bloecke:
|
||||
if schluessel == "titel":
|
||||
titel.append(text)
|
||||
elif schluessel == "art":
|
||||
art.append(text)
|
||||
elif schluessel == "ziel":
|
||||
ziel.append(text)
|
||||
elif schluessel == "funktion":
|
||||
funktionen.append({"text": text, "regeln": []})
|
||||
elif schluessel == "regel" and funktionen:
|
||||
funktionen[-1]["regeln"].append(text)
|
||||
else: # Präambel, unbekannter Marker, regel ohne funktion
|
||||
sonstiges.append(text)
|
||||
|
||||
# Titel ist eine Zeile: erste nicht-leere
|
||||
titel_zeilen = [z.strip() for t in titel for z in t.splitlines() if z.strip()]
|
||||
|
||||
# Funktionen-Bereich mit Markern rekonstruiert — für das Editor-Feld
|
||||
teile = []
|
||||
for f in funktionen:
|
||||
teile.append(f"<!-- funktion -->\n{f['text']}")
|
||||
for r in f["regeln"]:
|
||||
teile.append(f"<!-- regel -->\n{r}")
|
||||
|
||||
return {
|
||||
"titel": titel_zeilen[0] if titel_zeilen else "",
|
||||
"art": "optional" if art and art[0].strip().lower() == "optional" else "kern",
|
||||
"ziel": "\n\n".join(ziel),
|
||||
"funktionen": funktionen,
|
||||
"funktionen_roh": "\n".join(teile),
|
||||
"sonstiges": "\n\n".join(sonstiges),
|
||||
}
|
||||
62
backend/projekte.py
Normal file
62
backend/projekte.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Projekte = Ordner unter daten/. Kein Registry-File, das Listing ist die Liste."""
|
||||
|
||||
import shutil
|
||||
|
||||
import config
|
||||
|
||||
|
||||
def _pruefe_name(name: str):
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Projektname darf nicht leer sein")
|
||||
if "/" in name or "\\" in name or name.startswith("."):
|
||||
raise ValueError("Ungültiger Projektname")
|
||||
|
||||
|
||||
def _ordner(name: str):
|
||||
_pruefe_name(name)
|
||||
return config.DATEN_DIR / name
|
||||
|
||||
|
||||
def liste() -> list[dict]:
|
||||
if not config.DATEN_DIR.exists():
|
||||
return []
|
||||
ordner = sorted(
|
||||
(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
|
||||
]
|
||||
|
||||
|
||||
def anlegen(name: str):
|
||||
ziel = _ordner(name)
|
||||
if ziel.exists():
|
||||
raise ValueError(f"Projekt „{name}“ existiert bereits")
|
||||
ziel.mkdir(parents=True)
|
||||
|
||||
|
||||
def umbenennen(alt: str, neu: str):
|
||||
quelle = _ordner(alt)
|
||||
ziel = _ordner(neu)
|
||||
if not quelle.is_dir():
|
||||
raise FileNotFoundError(f"Projekt „{alt}“ nicht gefunden")
|
||||
if ziel.exists():
|
||||
raise ValueError(f"Projekt „{neu}“ existiert bereits")
|
||||
quelle.rename(ziel)
|
||||
|
||||
|
||||
def loeschen(name: str):
|
||||
ordner = _ordner(name)
|
||||
if not ordner.is_dir():
|
||||
raise FileNotFoundError(f"Projekt „{name}“ nicht gefunden")
|
||||
shutil.rmtree(ordner)
|
||||
|
||||
|
||||
def wurzel(name: str):
|
||||
"""Projektordner für Feature-Zugriffe; FileNotFoundError bei unbekanntem Projekt."""
|
||||
ordner = _ordner(name)
|
||||
if not ordner.is_dir():
|
||||
raise FileNotFoundError(f"Projekt „{name}“ nicht gefunden")
|
||||
return ordner
|
||||
3
backend/pytest.ini
Normal file
3
backend/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
87
backend/routes.py
Normal file
87
backend/routes.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Alle API-Routen. Fachlogik wirft ValueError/FileNotFoundError,
|
||||
hier wird auf 400/404/409 gemappt."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
import features
|
||||
import projekte
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/projekte")
|
||||
async def projekte_liste():
|
||||
return projekte.liste()
|
||||
|
||||
|
||||
@router.post("/projekte")
|
||||
async def projekt_anlegen(body: dict):
|
||||
name = (body.get("name") or "").strip()
|
||||
try:
|
||||
projekte.anlegen(name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(409 if "existiert" in str(e) else 400, str(e))
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@router.put("/projekte/{name}")
|
||||
async def projekt_umbenennen(name: str, body: dict):
|
||||
neu = (body.get("name") or "").strip()
|
||||
try:
|
||||
projekte.umbenennen(name, neu)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(409 if "existiert" in str(e) else 400, str(e))
|
||||
return {"name": neu}
|
||||
|
||||
|
||||
@router.delete("/projekte/{name}")
|
||||
async def projekt_loeschen(name: str):
|
||||
try:
|
||||
projekte.loeschen(name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/projekte/{name}/features")
|
||||
async def features_liste(name: str):
|
||||
try:
|
||||
return features.liste(name)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.post("/projekte/{name}/features")
|
||||
async def feature_anlegen(name: str, body: dict):
|
||||
try:
|
||||
return features.anlegen(name, body.get("roh") or "")
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.put("/projekte/{name}/features/{id}")
|
||||
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:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.delete("/projekte/{name}/features/{id}")
|
||||
async def feature_loeschen(name: str, id: str):
|
||||
try:
|
||||
features.loeschen(name, id)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
raise HTTPException(404, str(e))
|
||||
return {"ok": True}
|
||||
24
backend/server.py
Normal file
24
backend/server.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Planer2-Server: API + gebautes Frontend.
|
||||
|
||||
Start: uvicorn server:app --port 8200
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.staticfiles import StaticFiles # noqa: E402
|
||||
|
||||
import config # noqa: E402
|
||||
from routes import router # noqa: E402
|
||||
|
||||
app = FastAPI(title="Planer2")
|
||||
app.include_router(router)
|
||||
|
||||
config.DATEN_DIR.mkdir(exist_ok=True)
|
||||
|
||||
FRONTEND_DIST = config.PROJECT_ROOT / "frontend" / "dist"
|
||||
if FRONTEND_DIST.exists():
|
||||
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
|
||||
27
backend/tests/conftest.py
Normal file
27
backend/tests/conftest.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def datenordner(tmp_path, monkeypatch):
|
||||
"""Leitet DATEN_DIR auf einen frischen Ordner um."""
|
||||
ordner = tmp_path / "daten"
|
||||
ordner.mkdir()
|
||||
monkeypatch.setattr(config, "DATEN_DIR", ordner)
|
||||
return ordner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(datenordner):
|
||||
from server import app
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
80
backend/tests/test_features.py
Normal file
80
backend/tests/test_features.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import pytest
|
||||
|
||||
import features
|
||||
import projekte
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def projekt(datenordner):
|
||||
projekte.anlegen("Test")
|
||||
return "Test"
|
||||
|
||||
|
||||
def test_anlegen_erzeugt_slug(projekt, datenordner):
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nÜber Ätzend!\n")
|
||||
assert f["id"] == "ueber-aetzend"
|
||||
assert (datenordner / "Test" / "ueber-aetzend.md").is_file()
|
||||
|
||||
|
||||
def test_slug_kollision(projekt):
|
||||
a = features.anlegen(projekt, "<!-- titel -->\nGleich\n")
|
||||
b = features.anlegen(projekt, "<!-- titel -->\nGleich\n")
|
||||
assert a["id"] == "gleich"
|
||||
assert b["id"] == "gleich-2"
|
||||
|
||||
|
||||
def test_titel_pflicht(projekt):
|
||||
with pytest.raises(ValueError):
|
||||
features.anlegen(projekt, "nur Text ohne Titel-Marker")
|
||||
|
||||
|
||||
def test_titelaenderung_benennt_datei_um(projekt, datenordner):
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nAlt\n")
|
||||
g = features.speichern(projekt, f["id"], "<!-- titel -->\nNeu\n")
|
||||
assert g["id"] == "neu"
|
||||
assert not (datenordner / "Test" / "alt.md").exists()
|
||||
assert (datenordner / "Test" / "neu.md").is_file()
|
||||
|
||||
|
||||
def test_umbenennen_mit_kollision(projekt):
|
||||
features.anlegen(projekt, "<!-- titel -->\nZiel\n")
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nAnders\n")
|
||||
g = features.speichern(projekt, f["id"], "<!-- titel -->\nZiel\n")
|
||||
assert g["id"] == "ziel-2"
|
||||
|
||||
|
||||
def test_gleicher_titel_behaelt_kollisions_suffix(projekt):
|
||||
features.anlegen(projekt, "<!-- titel -->\nGleich\n")
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nGleich\n")
|
||||
g = features.speichern(projekt, f["id"], "<!-- titel -->\nGleich\n<!-- ziel -->\nx\n")
|
||||
assert g["id"] == "gleich-2"
|
||||
|
||||
|
||||
def test_speichern_verbatim(projekt, datenordner):
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nX\n")
|
||||
roh = "Vorspann\n<!-- titel -->\nX\n<!-- fremd -->\n unangetastet \n"
|
||||
features.speichern(projekt, f["id"], roh)
|
||||
assert (datenordner / "Test" / "x.md").read_text() == roh
|
||||
|
||||
|
||||
def test_liste_sortiert_nach_titel(projekt):
|
||||
features.anlegen(projekt, "<!-- titel -->\nZebra\n")
|
||||
features.anlegen(projekt, "<!-- titel -->\nanton\n")
|
||||
titel = [f["sektionen"]["titel"] for f in features.liste(projekt)]
|
||||
assert titel == ["anton", "Zebra"]
|
||||
|
||||
|
||||
def test_loeschen(projekt):
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nWeg\n")
|
||||
features.loeschen(projekt, f["id"])
|
||||
assert features.liste(projekt) == []
|
||||
|
||||
|
||||
def test_unbekannte_id(projekt):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
features.speichern(projekt, "gibts-nicht", "x")
|
||||
|
||||
|
||||
def test_unbekanntes_projekt(datenordner):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
features.liste("Fremd")
|
||||
92
backend/tests/test_parser.py
Normal file
92
backend/tests/test_parser.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from parser import zerlege
|
||||
|
||||
VOLL = """<!-- titel -->
|
||||
Login
|
||||
----------------------------------------
|
||||
<!-- ziel -->
|
||||
Nutzer können sich anmelden.
|
||||
----------------------------------------
|
||||
<!-- funktion -->
|
||||
Anmeldung mit E-Mail
|
||||
<!-- regel -->
|
||||
Passwort nie loggen
|
||||
<!-- regel -->
|
||||
Sperre nach 5 Fehlversuchen
|
||||
<!-- funktion -->
|
||||
Abmeldung
|
||||
----------------------------------------
|
||||
<!-- sonstiges -->
|
||||
Offen: 2FA.
|
||||
"""
|
||||
|
||||
|
||||
def test_alle_sektionen():
|
||||
s = zerlege(VOLL)
|
||||
assert s["titel"] == "Login"
|
||||
assert s["ziel"] == "Nutzer können sich anmelden."
|
||||
assert s["funktionen"] == [
|
||||
{"text": "Anmeldung mit E-Mail", "regeln": ["Passwort nie loggen", "Sperre nach 5 Fehlversuchen"]},
|
||||
{"text": "Abmeldung", "regeln": []},
|
||||
]
|
||||
assert s["sonstiges"] == "Offen: 2FA."
|
||||
|
||||
|
||||
def test_funktionen_roh_rekonstruiert_marker():
|
||||
s = zerlege(VOLL)
|
||||
assert s["funktionen_roh"] == (
|
||||
"<!-- funktion -->\nAnmeldung mit E-Mail\n"
|
||||
"<!-- regel -->\nPasswort nie loggen\n"
|
||||
"<!-- regel -->\nSperre nach 5 Fehlversuchen\n"
|
||||
"<!-- funktion -->\nAbmeldung"
|
||||
)
|
||||
|
||||
|
||||
def test_trenner_zeilen_werden_ignoriert():
|
||||
s = zerlege("<!-- ziel -->\nText\n-----\n")
|
||||
assert s["ziel"] == "Text"
|
||||
|
||||
|
||||
def test_praeambel_landet_in_sonstiges():
|
||||
s = zerlege("Vorspann ohne Marker\n<!-- titel -->\nX\n")
|
||||
assert s["titel"] == "X"
|
||||
assert s["sonstiges"] == "Vorspann ohne Marker"
|
||||
|
||||
|
||||
def test_unbekannter_marker_landet_in_sonstiges():
|
||||
s = zerlege("<!-- titel -->\nX\n<!-- notizen -->\nGeheim\n")
|
||||
assert s["sonstiges"] == "Geheim"
|
||||
|
||||
|
||||
def test_regel_ohne_funktion_landet_in_sonstiges():
|
||||
s = zerlege("<!-- regel -->\nverwaist\n")
|
||||
assert s["funktionen"] == []
|
||||
assert s["sonstiges"] == "verwaist"
|
||||
|
||||
|
||||
def test_doppelter_marker_wird_konkateniert():
|
||||
s = zerlege("<!-- ziel -->\na\n<!-- titel -->\nX\n<!-- ziel -->\nb\n")
|
||||
assert s["ziel"] == "a\n\nb"
|
||||
|
||||
|
||||
def test_leerer_text():
|
||||
s = zerlege("")
|
||||
assert s == {"titel": "", "art": "kern", "ziel": "", "funktionen": [], "funktionen_roh": "", "sonstiges": ""}
|
||||
|
||||
|
||||
def test_art_optional():
|
||||
assert zerlege("<!-- art -->\nOptional\n")["art"] == "optional"
|
||||
|
||||
|
||||
def test_art_default_und_unbekannter_wert():
|
||||
assert zerlege("<!-- titel -->\nX\n")["art"] == "kern"
|
||||
assert zerlege("<!-- art -->\nquatsch\n")["art"] == "kern"
|
||||
|
||||
|
||||
def test_marker_mit_leerraum():
|
||||
s = zerlege("<!-- titel -->\nX\n")
|
||||
assert s["titel"] == "X"
|
||||
|
||||
|
||||
def test_titel_erste_nicht_leere_zeile():
|
||||
s = zerlege("<!-- titel -->\n\nErste\nZweite\n")
|
||||
assert s["titel"] == "Erste"
|
||||
59
backend/tests/test_routes.py
Normal file
59
backend/tests/test_routes.py
Normal file
@@ -0,0 +1,59 @@
|
||||
ROH = "<!-- titel -->\nLogin\n<!-- funktion -->\nAnmeldung\n<!-- regel -->\nPasswort nie loggen\n"
|
||||
|
||||
|
||||
async def test_health(client):
|
||||
res = await client.get("/api/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"ok": True}
|
||||
|
||||
|
||||
async def test_projekt_crud(client):
|
||||
assert (await client.get("/api/projekte")).json() == []
|
||||
|
||||
res = await client.post("/api/projekte", json={"name": "Alpha"})
|
||||
assert res.status_code == 200
|
||||
|
||||
# Duplikat → 409, ungültig → 400
|
||||
assert (await client.post("/api/projekte", json={"name": "Alpha"})).status_code == 409
|
||||
assert (await client.post("/api/projekte", json={"name": ""})).status_code == 400
|
||||
assert (await client.post("/api/projekte", json={"name": "../boese"})).status_code == 400
|
||||
|
||||
res = await client.put("/api/projekte/Alpha", json={"name": "Beta"})
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"name": "Beta"}
|
||||
assert (await client.put("/api/projekte/Alpha", json={"name": "X"})).status_code == 404
|
||||
|
||||
assert [p["name"] for p in (await client.get("/api/projekte")).json()] == ["Beta"]
|
||||
|
||||
assert (await client.delete("/api/projekte/Beta")).status_code == 200
|
||||
assert (await client.delete("/api/projekte/Beta")).status_code == 404
|
||||
|
||||
|
||||
async def test_feature_crud(client):
|
||||
await client.post("/api/projekte", json={"name": "Alpha"})
|
||||
|
||||
res = await client.post("/api/projekte/Alpha/features", json={"roh": ROH})
|
||||
assert res.status_code == 200
|
||||
f = res.json()
|
||||
assert f["id"] == "login"
|
||||
assert f["roh"] == ROH
|
||||
assert f["sektionen"]["titel"] == "Login"
|
||||
|
||||
# Titel fehlt → 400
|
||||
res = await client.post("/api/projekte/Alpha/features", 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"
|
||||
|
||||
liste = (await client.get("/api/projekte/Alpha/features")).json()
|
||||
assert len(liste) == 1
|
||||
|
||||
assert (await client.delete("/api/projekte/Alpha/features/login")).status_code == 200
|
||||
assert (await client.delete("/api/projekte/Alpha/features/login")).status_code == 404
|
||||
|
||||
|
||||
async def test_unbekanntes_projekt(client):
|
||||
assert (await client.get("/api/projekte/Fremd/features")).status_code == 404
|
||||
assert (await client.post("/api/projekte/Fremd/features", json={"roh": ROH})).status_code == 404
|
||||
0
daten/.gitkeep
Normal file
0
daten/.gitkeep
Normal file
11
daten/Planer2/neues-feature-2.md
Normal file
11
daten/Planer2/neues-feature-2.md
Normal file
@@ -0,0 +1,11 @@
|
||||
<!-- titel -->
|
||||
Neues Feature
|
||||
----------------------------------------
|
||||
<!-- art -->
|
||||
kern
|
||||
----------------------------------------
|
||||
<!-- ziel -->
|
||||
adsda
|
||||
----------------------------------------
|
||||
<!-- sonstiges -->
|
||||
|
||||
21
frontend/index.html
Normal file
21
frontend/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Planer2</title>
|
||||
<script>
|
||||
(function () {
|
||||
var stored = localStorage.getItem('darkMode')
|
||||
var dark = stored === null
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: stored === 'true'
|
||||
if (dark) document.documentElement.classList.add('dark')
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1109
frontend/package-lock.json
generated
Normal file
1109
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "planer2-frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^18.0.4",
|
||||
"vue": "^3.5.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"vite": "^8.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
69
frontend/src/App.vue
Normal file
69
frontend/src/App.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { fetchProjekte } from './api.js'
|
||||
import SeitenLeiste from './components/SeitenLeiste.vue'
|
||||
import FeatureListe from './components/FeatureListe.vue'
|
||||
|
||||
const projekte = ref([])
|
||||
const aktivesProjekt = ref(null)
|
||||
const dunkel = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
async function ladeProjekte() {
|
||||
projekte.value = await fetchProjekte()
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
function umbenannt(alt, neu) {
|
||||
if (aktivesProjekt.value === alt) aktivesProjekt.value = neu
|
||||
ladeProjekte()
|
||||
}
|
||||
|
||||
function wechsleDarkMode() {
|
||||
dunkel.value = !dunkel.value
|
||||
document.documentElement.classList.toggle('dark', dunkel.value)
|
||||
localStorage.setItem('darkMode', dunkel.value)
|
||||
}
|
||||
|
||||
onMounted(ladeProjekte)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="raster">
|
||||
<SeitenLeiste
|
||||
:projekte="projekte"
|
||||
:aktiv="aktivesProjekt"
|
||||
@auswaehlen="aktivesProjekt = $event"
|
||||
@geaendert="ladeProjekte"
|
||||
@umbenannt="umbenannt"
|
||||
>
|
||||
<button class="icon-knopf" :title="dunkel ? 'Hellmodus' : 'Dunkelmodus'" @click="wechsleDarkMode">
|
||||
{{ dunkel ? '☀' : '☾' }}
|
||||
</button>
|
||||
</SeitenLeiste>
|
||||
|
||||
<main>
|
||||
<FeatureListe v-if="aktivesProjekt" :projekt="aktivesProjekt" @geaendert="ladeProjekte" />
|
||||
<p v-else class="leer">Projekt wählen oder anlegen.</p>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.raster {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
main {
|
||||
padding: 1.2rem 1.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.leer {
|
||||
color: var(--dezent);
|
||||
margin-top: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
42
frontend/src/api.js
Normal file
42
frontend/src/api.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const BASE = '/api'
|
||||
|
||||
// Ein Request-Weg für alle Aufrufe: Backend-Fehler werfen mit err.status,
|
||||
// damit Aufrufer 404/409 gesondert behandeln können.
|
||||
async function req(path, { method = 'GET', body } = {}) {
|
||||
const opts = { method }
|
||||
if (body !== undefined) {
|
||||
opts.headers = { 'Content-Type': 'application/json' }
|
||||
opts.body = JSON.stringify(body)
|
||||
}
|
||||
const res = await fetch(`${BASE}${path}`, opts)
|
||||
if (!res.ok) {
|
||||
let detail = `Fehler (HTTP ${res.status})`
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data.detail) detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
|
||||
} catch { /* kein JSON-Body */ }
|
||||
const err = new Error(detail)
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
const text = await res.text()
|
||||
return text ? JSON.parse(text) : null
|
||||
}
|
||||
|
||||
const p = encodeURIComponent
|
||||
|
||||
export const fetchProjekte = () => req('/projekte')
|
||||
export const projektAnlegen = (name) =>
|
||||
req('/projekte', { method: 'POST', body: { name } })
|
||||
export const projektUmbenennen = (alt, neu) =>
|
||||
req(`/projekte/${p(alt)}`, { method: 'PUT', body: { name: neu } })
|
||||
export const projektLoeschen = (name) =>
|
||||
req(`/projekte/${p(name)}`, { method: 'DELETE' })
|
||||
|
||||
export const fetchFeatures = (projekt) => req(`/projekte/${p(projekt)}/features`)
|
||||
export const featureAnlegen = (projekt, roh) =>
|
||||
req(`/projekte/${p(projekt)}/features`, { method: 'POST', body: { roh } })
|
||||
export const featureSpeichern = (projekt, id, roh) =>
|
||||
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'PUT', body: { roh } })
|
||||
export const featureLoeschen = (projekt, id) =>
|
||||
req(`/projekte/${p(projekt)}/features/${p(id)}`, { method: 'DELETE' })
|
||||
117
frontend/src/assets/base.css
Normal file
117
frontend/src/assets/base.css
Normal file
@@ -0,0 +1,117 @@
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--text: #1a202c;
|
||||
--dezent: #64748b;
|
||||
--rand: #e2e8f0;
|
||||
--akzent: #2563eb;
|
||||
--akzent-text: #ffffff;
|
||||
--gruen: #16a34a;
|
||||
--gruen-bg: #dcfce7;
|
||||
--gelb: #b45309;
|
||||
--gelb-bg: #fef3c7;
|
||||
--rot: #b91c1c;
|
||||
--rot-bg: #fee2e2;
|
||||
--code-bg: #f1f5f9;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--bg: #0f1420;
|
||||
--panel: #1a2130;
|
||||
--text: #e2e8f0;
|
||||
--dezent: #94a3b8;
|
||||
--rand: #2d3748;
|
||||
--akzent: #60a5fa;
|
||||
--akzent-text: #0f1420;
|
||||
--gruen: #86efac;
|
||||
--gruen-bg: #14532d;
|
||||
--gelb: #fcd34d;
|
||||
--gelb-bg: #451a03;
|
||||
--rot: #fca5a5;
|
||||
--rot-bg: #450a0a;
|
||||
--code-bg: #111827;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.55 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
padding: 0.35rem 0.8rem;
|
||||
}
|
||||
|
||||
button.primaer {
|
||||
background: var(--akzent);
|
||||
color: var(--akzent-text);
|
||||
border-color: var(--akzent);
|
||||
}
|
||||
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
input, textarea {
|
||||
font: inherit;
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
.karte {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem 1.1rem;
|
||||
}
|
||||
|
||||
/* Rahmenlose Icon-Knöpfe (✎ ✕ …) */
|
||||
.icon-knopf {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0.1rem 0.3rem;
|
||||
color: var(--dezent);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.icon-knopf:hover { color: var(--text); }
|
||||
.icon-knopf.rot:hover { color: var(--rot); }
|
||||
|
||||
.meldung { color: var(--rot); font-size: 0.85rem; }
|
||||
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 99px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border: none;
|
||||
}
|
||||
.badge.blau { color: var(--akzent); background: var(--code-bg); }
|
||||
.badge.gruen { color: var(--gruen); background: var(--gruen-bg); }
|
||||
|
||||
pre {
|
||||
background: var(--code-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
341
frontend/src/components/FeatureKarte.vue
Normal file
341
frontend/src/components/FeatureKarte.vue
Normal file
@@ -0,0 +1,341 @@
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { featureSpeichern, featureLoeschen } from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
projekt: { type: String, required: true },
|
||||
feature: { type: Object, required: true },
|
||||
titelBearbeiten: { type: Boolean, default: false }, // frisch angelegt: Titel direkt öffnen
|
||||
})
|
||||
const emit = defineEmits(['aktualisiert', 'geloescht'])
|
||||
|
||||
function kopie(sekt) {
|
||||
return {
|
||||
titel: sekt.titel,
|
||||
art: sekt.art,
|
||||
ziel: sekt.ziel,
|
||||
funktionen: sekt.funktionen.map(f => ({ text: f.text, regeln: [...f.regeln] })),
|
||||
sonstiges: sekt.sonstiges,
|
||||
}
|
||||
}
|
||||
|
||||
const s = ref(kopie(props.feature.sektionen))
|
||||
watch(() => props.feature, f => { s.value = kopie(f.sektionen) })
|
||||
|
||||
const aktiv = ref(null) // {feld, i, j, original} — das eine gerade offene Eingabefeld
|
||||
const entwurf = ref('')
|
||||
const meldung = ref('')
|
||||
const feldRef = ref(null)
|
||||
|
||||
// Lokale, selbst geschriebene Daten — kein Sanitizing nötig
|
||||
const md = (text) => marked.parse(text)
|
||||
|
||||
function istAktiv(feld, i, j) {
|
||||
const a = aktiv.value
|
||||
return !!a && a.feld === feld && a.i === i && a.j === j
|
||||
}
|
||||
|
||||
function wert(feld, i, j) {
|
||||
if (feld === 'titel') return s.value.titel
|
||||
if (feld === 'ziel') return s.value.ziel
|
||||
if (feld === 'sonstiges') return s.value.sonstiges
|
||||
if (feld === 'funktion') return s.value.funktionen[i].text
|
||||
return s.value.funktionen[i].regeln[j]
|
||||
}
|
||||
|
||||
async function starte(feld, i, j) {
|
||||
const original = wert(feld, i, j)
|
||||
aktiv.value = { feld, i, j, original }
|
||||
entwurf.value = original
|
||||
await nextTick()
|
||||
const el = Array.isArray(feldRef.value) ? feldRef.value[0] : feldRef.value
|
||||
el?.focus()
|
||||
if (feld === 'titel') el?.select()
|
||||
}
|
||||
|
||||
function abbrechen() {
|
||||
const a = aktiv.value
|
||||
aktiv.value = null
|
||||
// frisch angelegte, noch leere Einträge wieder entfernen
|
||||
if (a && !a.original) {
|
||||
if (a.feld === 'funktion') s.value.funktionen.splice(a.i, 1)
|
||||
if (a.feld === 'regel') s.value.funktionen[a.i].regeln.splice(a.j, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function fertig() {
|
||||
const a = aktiv.value
|
||||
if (!a) return
|
||||
aktiv.value = null
|
||||
const neu = entwurf.value.trim()
|
||||
|
||||
if (neu === a.original) { abbrechenLeer(a); return }
|
||||
|
||||
if (a.feld === 'titel') {
|
||||
if (!neu) return // Titel ist Pflicht — leere Eingabe verwerfen
|
||||
s.value.titel = neu
|
||||
} else if (a.feld === 'ziel') {
|
||||
s.value.ziel = neu
|
||||
} else if (a.feld === 'sonstiges') {
|
||||
s.value.sonstiges = 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 === 'regel') {
|
||||
if (!neu) s.value.funktionen[a.i].regeln.splice(a.j, 1)
|
||||
else s.value.funktionen[a.i].regeln[a.j] = neu
|
||||
}
|
||||
await speichern()
|
||||
}
|
||||
|
||||
function abbrechenLeer(a) {
|
||||
if (a.original) return
|
||||
if (a.feld === 'funktion') s.value.funktionen.splice(a.i, 1)
|
||||
if (a.feld === 'regel') s.value.funktionen[a.i].regeln.splice(a.j, 1)
|
||||
}
|
||||
|
||||
function neueFunktion() {
|
||||
s.value.funktionen.push({ text: '', regeln: [] })
|
||||
starte('funktion', s.value.funktionen.length - 1)
|
||||
}
|
||||
|
||||
function neueRegel(i) {
|
||||
s.value.funktionen[i].regeln.push('')
|
||||
starte('regel', i, s.value.funktionen[i].regeln.length - 1)
|
||||
}
|
||||
|
||||
function entferneFunktion(i) {
|
||||
s.value.funktionen.splice(i, 1)
|
||||
speichern()
|
||||
}
|
||||
|
||||
function entferneRegel(i, j) {
|
||||
s.value.funktionen[i].regeln.splice(j, 1)
|
||||
speichern()
|
||||
}
|
||||
|
||||
function wechsleArt() {
|
||||
s.value.art = s.value.art === 'kern' ? 'optional' : 'kern'
|
||||
speichern()
|
||||
}
|
||||
|
||||
const TRENNER = '-'.repeat(40)
|
||||
|
||||
// Aus dem Karten-Zustand wird die Markdown-Datei zusammengesetzt — sie bleibt die Wahrheit
|
||||
function baueRoh() {
|
||||
const teile = [
|
||||
`<!-- titel -->\n${s.value.titel}`,
|
||||
`<!-- art -->\n${s.value.art}`,
|
||||
`<!-- ziel -->\n${s.value.ziel}`,
|
||||
]
|
||||
const funktionen = s.value.funktionen
|
||||
.map(f => [`<!-- funktion -->\n${f.text}`, ...f.regeln.map(r => `<!-- regel -->\n${r}`)].join('\n'))
|
||||
.join('\n')
|
||||
if (funktionen) teile.push(funktionen)
|
||||
teile.push(`<!-- sonstiges -->\n${s.value.sonstiges}`)
|
||||
return teile.join(`\n${TRENNER}\n`) + '\n'
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
meldung.value = ''
|
||||
try {
|
||||
const f = await featureSpeichern(props.projekt, props.feature.id, baueRoh())
|
||||
emit('aktualisiert', props.feature.id, f) // id kann sich durch Titeländerung ändern
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen() {
|
||||
if (!confirm(`Feature „${s.value.titel}“ löschen?`)) return
|
||||
try {
|
||||
await featureLoeschen(props.projekt, props.feature.id)
|
||||
emit('geloescht')
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function zeilen(text) {
|
||||
return Math.max(2, text.split('\n').length)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.titelBearbeiten) starte('titel')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="karte" :class="s.art">
|
||||
<header>
|
||||
<input
|
||||
v-if="istAktiv('titel')"
|
||||
ref="feldRef"
|
||||
v-model="entwurf"
|
||||
class="titel-feld"
|
||||
@blur="fertig"
|
||||
@keyup.enter="$event.target.blur()"
|
||||
@keydown.esc="abbrechen"
|
||||
>
|
||||
<h3 v-else :title="'Klicken zum Bearbeiten'" @click="starte('titel')">{{ s.titel }}</h3>
|
||||
<button
|
||||
class="badge"
|
||||
:class="s.art === 'kern' ? 'blau' : 'gruen'"
|
||||
title="Klicken zum Umschalten"
|
||||
@click="wechsleArt"
|
||||
>{{ s.art }}</button>
|
||||
<button class="icon-knopf rot werkzeug" title="Feature löschen" @click="loeschen">✕</button>
|
||||
</header>
|
||||
|
||||
<section :class="{ leer: !s.ziel && !istAktiv('ziel') }">
|
||||
<p class="gruppe">Ziel</p>
|
||||
<textarea
|
||||
v-if="istAktiv('ziel')"
|
||||
ref="feldRef"
|
||||
v-model="entwurf"
|
||||
:rows="zeilen(entwurf)"
|
||||
@blur="fertig"
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
<div v-else-if="s.ziel" class="inhalt klickbar" v-html="md(s.ziel)" @click="starte('ziel')" />
|
||||
<p v-else class="hinweis" @click="starte('ziel')">Ziel ergänzen…</p>
|
||||
</section>
|
||||
|
||||
<section :class="{ leer: !s.funktionen.length }">
|
||||
<p class="gruppe">Funktionen</p>
|
||||
<div v-for="(f, i) in s.funktionen" :key="i" class="funktion">
|
||||
<div class="zeile">
|
||||
<textarea
|
||||
v-if="istAktiv('funktion', i)"
|
||||
ref="feldRef"
|
||||
v-model="entwurf"
|
||||
:rows="zeilen(entwurf)"
|
||||
@blur="fertig"
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
<div v-else class="inhalt klickbar" v-html="md(f.text)" @click="starte('funktion', i)" />
|
||||
<button class="icon-knopf rot werkzeug" title="Funktion entfernen" @click="entferneFunktion(i)">✕</button>
|
||||
</div>
|
||||
<div class="regeln">
|
||||
<div v-for="(r, j) in f.regeln" :key="j" class="zeile">
|
||||
<textarea
|
||||
v-if="istAktiv('regel', i, j)"
|
||||
ref="feldRef"
|
||||
v-model="entwurf"
|
||||
:rows="zeilen(entwurf)"
|
||||
@blur="fertig"
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
<div v-else class="inhalt klickbar" v-html="md(r)" @click="starte('regel', i, j)" />
|
||||
<button class="icon-knopf rot werkzeug" title="Regel entfernen" @click="entferneRegel(i, j)">✕</button>
|
||||
</div>
|
||||
<button class="hinzu werkzeug" @click="neueRegel(i)">+ Regel</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hinzu werkzeug" @click="neueFunktion">+ Funktion</button>
|
||||
</section>
|
||||
|
||||
<section :class="{ leer: !s.sonstiges && !istAktiv('sonstiges') }">
|
||||
<p class="gruppe">Sonstiges</p>
|
||||
<textarea
|
||||
v-if="istAktiv('sonstiges')"
|
||||
ref="feldRef"
|
||||
v-model="entwurf"
|
||||
:rows="zeilen(entwurf)"
|
||||
@blur="fertig"
|
||||
@keydown.esc="abbrechen"
|
||||
/>
|
||||
<div v-else-if="s.sonstiges" class="inhalt klickbar" v-html="md(s.sonstiges)" @click="starte('sonstiges')" />
|
||||
<p v-else class="hinweis" @click="starte('sonstiges')">Sonstiges ergänzen…</p>
|
||||
</section>
|
||||
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.karte.kern { border-color: var(--akzent); }
|
||||
.karte.optional { border-color: var(--gruen); }
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--rand);
|
||||
}
|
||||
h3 { margin: 0; font-size: 1rem; cursor: text; flex: 1; }
|
||||
.badge { cursor: pointer; }
|
||||
.titel-feld { flex: 1; font-weight: 600; padding: 0.1rem 0.4rem; }
|
||||
|
||||
section { padding: 0.6rem 0; }
|
||||
section + section { border-top: 1px solid var(--rand); }
|
||||
.gruppe {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--akzent);
|
||||
margin: 0 0 0.15rem;
|
||||
}
|
||||
|
||||
/* Leere Sektionen und Werkzeuge erscheinen erst beim Hover über die Karte */
|
||||
section.leer { display: none; }
|
||||
.karte:hover section.leer { display: block; }
|
||||
.werkzeug { opacity: 0; transition: opacity 0.1s; }
|
||||
.karte:hover .werkzeug, .werkzeug:focus { opacity: 1; }
|
||||
/* „+ Funktion"/„+ Regel" belegen ohne Hover auch keinen Platz */
|
||||
.hinzu.werkzeug { display: none; }
|
||||
.karte:hover .hinzu.werkzeug { display: block; }
|
||||
|
||||
.hinweis {
|
||||
color: var(--dezent);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.1rem 0;
|
||||
cursor: text;
|
||||
}
|
||||
.hinzu {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dezent);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
.hinzu:hover { color: var(--akzent); }
|
||||
|
||||
.zeile {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.zeile .inhalt, .zeile textarea { flex: 1; min-width: 0; }
|
||||
.zeile .icon-knopf { font-size: 0.8rem; }
|
||||
|
||||
.funktion + .funktion {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed var(--rand);
|
||||
}
|
||||
.regeln {
|
||||
margin: 0.25rem 0 0 0.3rem;
|
||||
padding-left: 0.6rem;
|
||||
border-left: 2px solid var(--rand);
|
||||
color: var(--dezent);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.klickbar { cursor: text; border-radius: 4px; }
|
||||
.klickbar:hover { background: var(--code-bg); }
|
||||
|
||||
.inhalt :deep(p) { margin: 0.2rem 0; }
|
||||
.inhalt :deep(ul) { margin: 0.2rem 0; padding-left: 1.2rem; }
|
||||
.inhalt :deep(li) { margin: 0.1rem 0; }
|
||||
</style>
|
||||
87
frontend/src/components/FeatureListe.vue
Normal file
87
frontend/src/components/FeatureListe.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { fetchFeatures, featureAnlegen } from '../api.js'
|
||||
import FeatureKarte from './FeatureKarte.vue'
|
||||
|
||||
const props = defineProps({
|
||||
projekt: { type: String, required: true },
|
||||
})
|
||||
const emit = defineEmits(['geaendert'])
|
||||
|
||||
const features = ref([])
|
||||
const meldung = ref('')
|
||||
const neuId = ref(null) // frisch angelegte Karte: Titel direkt bearbeiten
|
||||
|
||||
async function laden() {
|
||||
meldung.value = ''
|
||||
neuId.value = null
|
||||
try {
|
||||
features.value = await fetchFeatures(props.projekt)
|
||||
} catch (e) {
|
||||
features.value = []
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function neuesFeature() {
|
||||
meldung.value = ''
|
||||
try {
|
||||
const f = await featureAnlegen(props.projekt, '<!-- titel -->\nNeues Feature\n')
|
||||
features.value.push(f)
|
||||
neuId.value = f.id
|
||||
emit('geaendert') // Anzahl in der Seitenleiste aktualisieren
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
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 = null
|
||||
}
|
||||
|
||||
function geloescht(id) {
|
||||
features.value = features.value.filter(f => f.id !== id)
|
||||
emit('geaendert')
|
||||
}
|
||||
|
||||
watch(() => props.projekt, laden, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
|
||||
<div class="gitter">
|
||||
<FeatureKarte
|
||||
v-for="f in features"
|
||||
:key="f.id"
|
||||
:projekt="projekt"
|
||||
:feature="f"
|
||||
:titel-bearbeiten="f.id === neuId"
|
||||
@aktualisiert="aktualisiert"
|
||||
@geloescht="geloescht(f.id)"
|
||||
/>
|
||||
<button class="karte neu" @click="neuesFeature">+ Feature</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gitter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 0.9rem;
|
||||
align-items: start;
|
||||
}
|
||||
/* 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>
|
||||
156
frontend/src/components/SeitenLeiste.vue
Normal file
156
frontend/src/components/SeitenLeiste.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { projektAnlegen, projektUmbenennen, projektLoeschen } from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
projekte: { type: Array, required: true },
|
||||
aktiv: { type: String, default: null },
|
||||
})
|
||||
const emit = defineEmits(['auswaehlen', 'geaendert', 'umbenannt'])
|
||||
|
||||
const neuerName = ref('')
|
||||
const meldung = ref('')
|
||||
const bearbeitet = ref(null) // Projektname, der gerade umbenannt wird
|
||||
const umbenennung = ref('')
|
||||
const umbenennFeld = ref(null)
|
||||
|
||||
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) {
|
||||
meldung.value = ''
|
||||
bearbeitet.value = name
|
||||
umbenennung.value = name
|
||||
await nextTick()
|
||||
umbenennFeld.value?.[0]?.focus()
|
||||
}
|
||||
|
||||
async function umbenennen(alt) {
|
||||
const neu = umbenennung.value.trim()
|
||||
bearbeitet.value = null
|
||||
if (!neu || neu === alt) return
|
||||
try {
|
||||
await projektUmbenennen(alt, neu)
|
||||
emit('umbenannt', alt, neu)
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen(name) {
|
||||
meldung.value = ''
|
||||
if (!confirm(`Projekt „${name}“ mit allen Features löschen?`)) return
|
||||
try {
|
||||
await projektLoeschen(name)
|
||||
emit('geaendert')
|
||||
} catch (e) {
|
||||
meldung.value = e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside>
|
||||
<header>
|
||||
<p class="gruppe">Projekte</p>
|
||||
<slot />
|
||||
</header>
|
||||
<ul>
|
||||
<li
|
||||
v-for="p in projekte"
|
||||
:key="p.name"
|
||||
:class="{ aktiv: p.name === aktiv }"
|
||||
@click="emit('auswaehlen', p.name)"
|
||||
>
|
||||
<template v-if="bearbeitet === p.name">
|
||||
<input
|
||||
ref="umbenennFeld"
|
||||
v-model="umbenennung"
|
||||
@keyup.enter="umbenennen(p.name)"
|
||||
@keyup.escape="bearbeitet = null"
|
||||
@blur="umbenennen(p.name)"
|
||||
@click.stop
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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" title="Löschen" @click="loeschen(p.name)">✕</button>
|
||||
</span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form class="neu" @submit.prevent="anlegen">
|
||||
<input v-model="neuerName" placeholder="Neues Projekt">
|
||||
<button class="primaer" :disabled="!neuerName.trim()">+</button>
|
||||
</form>
|
||||
<p v-if="meldung" class="meldung">{{ meldung }}</p>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
aside {
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--rand);
|
||||
padding: 1rem 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.gruppe {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--dezent);
|
||||
margin: 0;
|
||||
}
|
||||
ul { list-style: none; margin: 0; padding: 0; }
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
li:hover { background: var(--bg); }
|
||||
li.aktiv { background: var(--bg); font-weight: 600; }
|
||||
.name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
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; }
|
||||
.neu {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.neu input { flex: 1; min-width: 0; }
|
||||
</style>
|
||||
5
frontend/src/main.js
Normal file
5
frontend/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './assets/base.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// Ports 5274/8200: kollisionsfrei zu planer (5273/8100) und creator (8000)
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
server: {
|
||||
port: 5274,
|
||||
proxy: { '/api': 'http://localhost:8200' },
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user