This commit is contained in:
Team3
2026-07-24 22:39:23 +02:00
commit 92889711a0
27 changed files with 2640 additions and 0 deletions

21
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View 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
View 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
View 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' })

View 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;
}

View 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>

View 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>

View 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
View 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
View 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' },
},
})