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
```
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.

View File

@@ -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):

View File

@@ -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):

View File

@@ -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}")

View File

@@ -70,6 +70,12 @@ def test_loeschen(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):
with pytest.raises(FileNotFoundError):
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"})
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"

View File

@@ -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() {
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 () => {
</SeitenLeiste>
<main>
<FeatureListe v-if="aktivesProjekt" :projekt="aktivesProjekt" @geaendert="ladeProjekte" />
<p v-else class="leer">Projekt wählen oder anlegen.</p>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<FeatureListe v-if="aktivesProjekt" :projekt="aktivesProjekt" />
<p v-else-if="!meldung" class="leer">Projekt wählen oder anlegen.</p>
</main>
<KontextMenue />
</div>
</template>

View File

@@ -1,7 +1,9 @@
<script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { nextTick, ref, watch } from 'vue'
import { marked } from 'marked'
import { featureSpeichern } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
import ModalBox from './ModalBox.vue'
const props = defineProps({
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) })
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 meldung = ref('')
const feldRef = ref(null)
@@ -60,12 +62,7 @@ async function starte(feld, i, j) {
}
function abbrechen() {
const a = aktiv.value
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() {
@@ -73,78 +70,51 @@ async function fertig() {
if (!a) return
aktiv.value = null
const neu = entwurf.value.trim()
if (neu === a.original) { abbrechenLeer(a); return }
if (neu === a.original) return
if (a.feld === 'titel') {
if (!neu) return // Titel ist Pflicht — leere Eingabe verwerfen
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') {
if (a.i === undefined) s.value.ziel = neu
else s.value.funktionen[a.i].ziel = neu
} else if (a.feld === 'funktion') {
if (!neu) s.value.funktionen.splice(a.i, 1) // leeren = löschen
else s.value.funktionen[a.i].text = neu
} else if (a.feld === 'testen') {
s.value.funktionen[a.i].testen = neu
} else if (a.feld === 'regeln') {
if (a.i === undefined) s.value.regeln = neu
else s.value.funktionen[a.i].regeln = neu
} else if (a.feld === 'testen') {
s.value.funktionen[a.i].testen = neu
}
await speichern()
}
function abbrechenLeer(a) {
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
// Rechtsklick auf eine Funktion (entfernen) oder auf die Karte (erstellen/Feature entfernen)
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', i}
const modalName = ref('')
const modalFeld = ref(null)
async function menueFunktionErstellen() {
kontextMenue.value = null
modal.value = { art: 'erstellen' }
modalName.value = ''
await nextTick()
modalFeld.value?.focus()
function funktionsMenue(e, i) {
oeffneMenue(e, [
{ text: '✕ Funktion entfernen', aktion: () => (modal.value = { art: 'entfernen', i }) },
])
}
function menueFunktionEntfernen() {
const i = kontextMenue.value.funktion
kontextMenue.value = null
modal.value = { art: 'entfernen', i }
function kartenMenue(e) {
const eintraege = []
if (gross.value) {
eintraege.push({ text: '+ Funktion erstellen', aktion: () => (modal.value = { art: 'erstellen' }) })
}
eintraege.push({ text: '✕ Feature entfernen', aktion: () => emit('entfernen') })
oeffneMenue(e, eintraege)
}
function menueFeatureEntfernen() {
kontextMenue.value = null
emit('entfernen')
}
async function erstellenBestaetigen() {
const name = modalName.value.trim()
if (!name) return
s.value.funktionen.push({ text: name, ziel: '', regeln: '', testen: '' })
async function funktionErstellen(titel) {
s.value.funktionen.push({ text: titel, ziel: '', regeln: '', testen: '' })
modal.value = null
await speichern()
}
async function entfernenBestaetigen() {
async function funktionEntfernen() {
s.value.funktionen.splice(modal.value.i, 1)
modal.value = null
await speichern()
@@ -203,10 +173,6 @@ function punkte(text) {
function wechsleAnsicht() {
gross.value = !gross.value
}
onMounted(() => {
if (props.vollmodus) gross.value = true
})
</script>
<template>
@@ -215,7 +181,7 @@ onMounted(() => {
:class="[s.art, { gross }]"
@mousedown.middle.prevent
@auxclick.middle.prevent="wechsleAnsicht"
@contextmenu.prevent.stop="oeffneKontextMenue($event)"
@contextmenu.prevent.stop="kartenMenue"
>
<header>
<input
@@ -282,7 +248,7 @@ onMounted(() => {
:key="i"
class="funktion"
:class="{ offen: aktiv && aktiv.i === i }"
@contextmenu.prevent.stop="oeffneKontextMenue($event, i)"
@contextmenu.prevent.stop="funktionsMenue($event, i)"
>
<div class="zeile kopf">
<span class="pfeil"></span>
@@ -297,7 +263,7 @@ onMounted(() => {
<div v-else class="funktionstitel klickbar" @click="starte('funktion', i)">{{ f.text }}</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">
<!-- Ziel der Funktion -->
<div v-if="istAktiv('ziel', i) || f.ziel" class="block">
@@ -357,44 +323,21 @@ onMounted(() => {
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<div
v-if="kontextMenue"
class="kontextmenue"
:style="{ left: kontextMenue.x + 'px', top: kontextMenue.y + 'px' }"
>
<button v-if="kontextMenue.funktion !== null" @click="menueFunktionEntfernen"> Funktion entfernen</button>
<template v-else>
<button v-if="gross" @click="menueFunktionErstellen">+ Funktion erstellen</button>
<button @click="menueFeatureEntfernen"> Feature entfernen</button>
</template>
</div>
<div v-if="modal" class="schleier" @click.self="modal = null">
<div class="modal karte">
<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>
<ModalBox
v-if="modal && modal.art === 'erstellen'"
titel="Funktion erstellen"
platzhalter="Funktionstitel"
@bestaetigt="funktionErstellen"
@abbrechen="modal = null"
/>
<ModalBox
v-else-if="modal"
titel="Funktion entfernen"
:frage="`„${s.funktionen[modal.i].text}“ entfernen?`"
knopf="Entfernen"
@bestaetigt="funktionEntfernen"
@abbrechen="modal = null"
/>
</article>
</template>
@@ -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;

View File

@@ -1,22 +1,34 @@
<script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
import FeatureKarte from './FeatureKarte.vue'
import ModalBox from './ModalBox.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: 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() {
meldung.value = ''
neuId.value = null
neuKey.value = null
try {
features.value = await fetchFeatures(props.projekt)
features.value = (await fetchFeatures(props.projekt)).map(mitKey)
} catch (e) {
features.value = []
meldung.value = e.message
@@ -25,65 +37,38 @@ async function laden() {
function aktualisiert(altId, f) {
const idx = features.value.findIndex(x => x.id === altId)
if (idx >= 0) features.value[idx] = f
if (neuId.value === altId) neuId.value = f.id
if (idx < 0) return
features.value[idx] = { ...f, key: features.value[idx].key }
sortiere()
}
// Kontextmenü auf dem Hauptbereich (erstellen); das Menü der Card führt die Karte selbst
const kontextMenue = ref(null) // {x, y}
function oeffneBereichsMenue(e) {
kontextMenue.value = { x: e.clientX, y: e.clientY }
function bereichsMenue(e) {
oeffneMenue(e, [{ text: '+ Feature erstellen', aktion: () => oeffneModal({ art: 'erstellen' }) }])
}
function schliesseKontextMenue() {
kontextMenue.value = null
}
onMounted(() => window.addEventListener('click', schliesseKontextMenue))
onUnmounted(() => window.removeEventListener('click', schliesseKontextMenue))
// Modal für Erstellen und Entfernen
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', feature}
const modalName = ref('')
const modalFeld = ref(null)
async function menueErstellen() {
kontextMenue.value = null
function oeffneModal(m) {
meldung.value = ''
modal.value = { art: 'erstellen' }
modalName.value = ''
await nextTick()
modalFeld.value?.focus()
modal.value = m
}
function featureEntfernen(feature) {
meldung.value = ''
modal.value = { art: 'entfernen', feature }
}
async function erstellenBestaetigen() {
meldung.value = ''
const name = modalName.value.trim()
if (!name) return
async function erstellen(titel) {
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
features.value.push(f)
neuId.value = f.id // öffnet die Karte im Vollmodus
emit('geaendert')
sortiere()
neuKey.value = f.key // öffnet die Karte im Vollmodus
} catch (e) {
meldung.value = e.message
}
}
async function entfernenBestaetigen() {
meldung.value = ''
async function entfernen() {
const id = modal.value.feature.id
try {
await featureLoeschen(props.projekt, modal.value.feature.id)
features.value = features.value.filter(f => f.id !== modal.value.feature.id)
await featureLoeschen(props.projekt, id)
features.value = features.value.filter(f => f.id !== id)
modal.value = null
emit('geaendert')
} catch (e) {
meldung.value = e.message
}
@@ -93,57 +78,38 @@ watch(() => props.projekt, laden, { immediate: true })
</script>
<template>
<div class="bereich" @contextmenu.prevent="oeffneBereichsMenue">
<div class="bereich" @contextmenu.prevent="bereichsMenue">
<p v-if="meldung && !modal" class="meldung">{{ meldung }}</p>
<div class="gitter">
<FeatureKarte
v-for="f in features"
:key="f.id"
:key="f.key"
:projekt="projekt"
:feature="f"
:vollmodus="f.id === neuId"
:vollmodus="f.key === neuKey"
@aktualisiert="aktualisiert"
@entfernen="featureEntfernen(f)"
@entfernen="oeffneModal({ art: 'entfernen', feature: f })"
/>
</div>
<div
v-if="kontextMenue"
class="kontextmenue"
:style="{ left: kontextMenue.x + 'px', top: kontextMenue.y + 'px' }"
>
<button @click="menueErstellen">+ Feature erstellen</button>
</div>
<div v-if="modal" class="schleier" @click.self="modal = null">
<div class="modal karte">
<template v-if="modal.art === 'erstellen'">
<h2>Feature erstellen</h2>
<input
ref="modalFeld"
v-model="modalName"
placeholder="Feature-Titel"
@keyup.enter="erstellenBestaetigen"
@keydown.esc="modal = null"
>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<footer>
<button @click="modal = null">Abbrechen</button>
<button class="primaer" :disabled="!modalName.trim()" @click="erstellenBestaetigen">Bestätigen</button>
</footer>
</template>
<template v-else>
<h2>Feature entfernen</h2>
<p class="frage">{{ modal.feature.sektionen.titel }} entfernen?</p>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<footer>
<button @click="modal = null">Abbrechen</button>
<button class="primaer" @click="entfernenBestaetigen">Entfernen</button>
</footer>
</template>
</div>
</div>
<ModalBox
v-if="modal && modal.art === 'erstellen'"
titel="Feature erstellen"
platzhalter="Feature-Titel"
:meldung="meldung"
@bestaetigt="erstellen"
@abbrechen="modal = null"
/>
<ModalBox
v-else-if="modal"
titel="Feature entfernen"
:frage="`„${modal.feature.sektionen.titel}“ entfernen?`"
knopf="Entfernen"
:meldung="meldung"
@bestaetigt="entfernen"
@abbrechen="modal = null"
/>
</div>
</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>
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { nextTick, ref } from 'vue'
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 },
aktiv: { type: String, default: null },
})
@@ -12,6 +14,7 @@ const meldung = ref('')
const bearbeitet = ref(null) // Projektname, der gerade umbenannt wird
const umbenennung = ref('')
const umbenennFeld = ref(null)
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', name}
async function starteUmbenennen(name) {
meldung.value = ''
@@ -33,56 +36,24 @@ async function umbenennen(alt) {
}
}
// Kontextmenü: Rechtsklick auf Projektname (anpassen/entfernen)
// oder auf freie Sidebar-Fläche (erstellen)
const kontextMenue = ref(null) // {name|null, x, y}
function oeffneProjektMenue(e, name) {
kontextMenue.value = { name, x: e.clientX, y: e.clientY }
// Rechtsklick auf einen Projektnamen (anpassen/entfernen) oder auf die Sidebar (erstellen)
function projektMenue(e, name) {
oeffneMenue(e, [
{ text: '✎ Projekt anpassen', aktion: () => starteUmbenennen(name) },
{ text: '✕ Projekt entfernen', aktion: () => oeffneModal({ art: 'entfernen', name }) },
])
}
function oeffneLeistenMenue(e) {
kontextMenue.value = { name: null, x: e.clientX, y: e.clientY }
function leistenMenue(e) {
oeffneMenue(e, [{ text: '+ Projekt erstellen', aktion: () => oeffneModal({ art: 'erstellen' }) }])
}
function schliesseKontextMenue() {
kontextMenue.value = null
}
onMounted(() => window.addEventListener('click', schliesseKontextMenue))
onUnmounted(() => window.removeEventListener('click', schliesseKontextMenue))
// Modal für Erstellen und Entfernen
const modal = ref(null) // {art: 'erstellen'} | {art: 'entfernen', name}
const modalName = ref('')
const modalFeld = ref(null)
async function menueErstellen() {
kontextMenue.value = null
function oeffneModal(m) {
meldung.value = ''
modal.value = { art: 'erstellen' }
modalName.value = ''
await nextTick()
modalFeld.value?.focus()
modal.value = m
}
function menueAnpassen() {
const name = kontextMenue.value.name
kontextMenue.value = null
starteUmbenennen(name)
}
function menueEntfernen() {
const name = kontextMenue.value.name
kontextMenue.value = null
meldung.value = ''
modal.value = { art: 'entfernen', name }
}
async function erstellenBestaetigen() {
meldung.value = ''
const name = modalName.value.trim()
if (!name) return
async function erstellen(name) {
try {
await projektAnlegen(name)
modal.value = null
@@ -93,8 +64,7 @@ async function erstellenBestaetigen() {
}
}
async function entfernenBestaetigen() {
meldung.value = ''
async function entfernen() {
try {
await projektLoeschen(modal.value.name)
modal.value = null
@@ -106,7 +76,7 @@ async function entfernenBestaetigen() {
</script>
<template>
<aside @contextmenu.prevent="oeffneLeistenMenue">
<aside @contextmenu.prevent="leistenMenue">
<header>
<p class="gruppe">Projekte</p>
<slot />
@@ -117,65 +87,39 @@ async function entfernenBestaetigen() {
:key="p.name"
:class="{ aktiv: p.name === aktiv }"
@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
v-if="bearbeitet === p.name"
ref="umbenennFeld"
v-model="umbenennung"
@keyup.enter="umbenennen(p.name)"
@keyup.escape="bearbeitet = null"
@keydown.esc="bearbeitet = null"
@blur="umbenennen(p.name)"
@click.stop
>
</template>
<template v-else>
<span class="name">{{ p.name }}</span>
</template>
<span v-else class="name">{{ p.name }}</span>
</li>
</ul>
<p v-if="meldung && !modal" class="meldung">{{ meldung }}</p>
<div
v-if="kontextMenue"
class="kontextmenue"
:style="{ left: kontextMenue.x + 'px', top: kontextMenue.y + 'px' }"
>
<template v-if="kontextMenue.name">
<button @click="menueAnpassen"> Projekt anpassen</button>
<button @click="menueEntfernen"> Projekt entfernen</button>
</template>
<button v-else @click="menueErstellen">+ Projekt erstellen</button>
</div>
<div v-if="modal" class="schleier" @click.self="modal = null">
<div class="modal karte">
<template v-if="modal.art === 'erstellen'">
<h2>Projekt erstellen</h2>
<input
ref="modalFeld"
v-model="modalName"
placeholder="Projektname"
@keyup.enter="erstellenBestaetigen"
@keydown.esc="modal = null"
>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<footer>
<button @click="modal = null">Abbrechen</button>
<button class="primaer" :disabled="!modalName.trim()" @click="erstellenBestaetigen">Bestätigen</button>
</footer>
</template>
<template v-else>
<h2>Projekt entfernen</h2>
<p class="frage">{{ modal.name }} mit allen Features entfernen?</p>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
<footer>
<button @click="modal = null">Abbrechen</button>
<button class="primaer" @click="entfernenBestaetigen">Entfernen</button>
</footer>
</template>
</div>
</div>
<ModalBox
v-if="modal && modal.art === 'erstellen'"
titel="Projekt erstellen"
platzhalter="Projektname"
:meldung="meldung"
@bestaetigt="erstellen"
@abbrechen="modal = null"
/>
<ModalBox
v-else-if="modal"
titel="Projekt entfernen"
:frage="`„${modal.name}“ mit allen Features entfernen?`"
knopf="Entfernen"
:meldung="meldung"
@bestaetigt="entfernen"
@abbrechen="modal = null"
/>
</aside>
</template>
@@ -220,5 +164,4 @@ li.aktiv { background: var(--bg); font-weight: 600; }
white-space: nowrap;
}
li input { width: 100%; padding: 0.1rem 0.4rem; }
</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)