626 lines
14 KiB
Vue
626 lines
14 KiB
Vue
<script setup>
|
||
import { computed, reactive, ref } from 'vue'
|
||
import PersonName from './PersonName.vue'
|
||
|
||
const props = defineProps({
|
||
items: { type: Array, default: () => [] },
|
||
ich: { type: Object, default: null },
|
||
host: Boolean,
|
||
// Ohne gewählten Namen bleibt der Reiter zum Mitlesen da
|
||
angemeldet: Boolean,
|
||
})
|
||
|
||
const emit = defineEmits(['changed'])
|
||
|
||
const entwurf = ref('')
|
||
|
||
// Der Host darf jeden Wunsch anfassen, alle anderen nur die eigenen
|
||
const bearbeitet = ref(null)
|
||
const bearbeitung = ref('')
|
||
|
||
function darfAendern(item) {
|
||
return props.host || item.mine
|
||
}
|
||
|
||
function bearbeiten(item) {
|
||
bearbeitet.value = item.id
|
||
bearbeitung.value = item.text
|
||
}
|
||
|
||
async function speichern() {
|
||
const text = bearbeitung.value.trim()
|
||
if (text) {
|
||
await fetch(`/api/features/${bearbeitet.value}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text }),
|
||
})
|
||
emit('changed')
|
||
}
|
||
bearbeitet.value = null
|
||
}
|
||
|
||
// Die Liste kommt vom Server schon sortiert: offene nach Stimmen, dann
|
||
// die erledigten. Hier nur noch in zwei Abschnitte teilen.
|
||
const offene = computed(() => props.items.filter((w) => !w.done_at))
|
||
// Zuletzt Erledigtes zuerst — so sieht man gleich, was neu dazukam
|
||
const erledigte = computed(() =>
|
||
props.items.filter((w) => w.done_at).sort((a, b) => b.done_at.localeCompare(a.done_at))
|
||
)
|
||
|
||
async function anlegen() {
|
||
if (!entwurf.value.trim()) return
|
||
await fetch('/api/features', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: entwurf.value }),
|
||
})
|
||
entwurf.value = ''
|
||
emit('changed')
|
||
}
|
||
|
||
async function loeschen(item) {
|
||
await fetch(`/api/features/${item.id}`, { method: 'DELETE' })
|
||
emit('changed')
|
||
}
|
||
|
||
async function abstimmen(item) {
|
||
await fetch(`/api/features/${item.id}/vote`, { method: 'POST' })
|
||
emit('changed')
|
||
}
|
||
|
||
async function erledigen(item, done) {
|
||
await fetch(`/api/features/${item.id}/done`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ done }),
|
||
})
|
||
emit('changed')
|
||
}
|
||
|
||
// Ergänzungen: aufgeklappt je Wunsch, mit eigenem Entwurf
|
||
const aufgeklappt = reactive(new Set())
|
||
const notizEntwurf = reactive({})
|
||
|
||
function umschalten(id) {
|
||
if (aufgeklappt.has(id)) aufgeklappt.delete(id)
|
||
else aufgeklappt.add(id)
|
||
}
|
||
|
||
function ergaenzungsLabel(item) {
|
||
const n = item.notes.length
|
||
if (!n) return 'Ergänzen'
|
||
return n === 1 ? '1 Ergänzung' : `${n} Ergänzungen`
|
||
}
|
||
|
||
async function anmerken(item) {
|
||
const text = (notizEntwurf[item.id] || '').trim()
|
||
if (!text) return
|
||
await fetch(`/api/features/${item.id}/notes`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text }),
|
||
})
|
||
notizEntwurf[item.id] = ''
|
||
emit('changed')
|
||
}
|
||
|
||
async function notizLoeschen(notiz) {
|
||
await fetch(`/api/feature-notes/${notiz.id}`, { method: 'DELETE' })
|
||
emit('changed')
|
||
}
|
||
|
||
function datum(iso) {
|
||
return new Date(iso).toLocaleDateString('de-DE', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="wuensche-tab">
|
||
<p v-if="!items.length" class="note">Noch keine Wünsche.</p>
|
||
|
||
<!-- Links das Offene samt Eingabe, rechts das Erledigte -->
|
||
<div class="spalten">
|
||
<div v-if="angemeldet" class="neu">
|
||
<textarea
|
||
v-model="entwurf"
|
||
rows="2"
|
||
maxlength="500"
|
||
placeholder="Was fehlt dir auf der Seite?"
|
||
@keydown.ctrl.enter="anlegen"
|
||
></textarea>
|
||
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">Eintragen</button>
|
||
</div>
|
||
|
||
<section
|
||
v-for="[titel, liste] in [
|
||
['Offen', offene],
|
||
['Erledigt', erledigte],
|
||
]"
|
||
:key="titel"
|
||
:class="titel === 'Offen' ? 'spalte-offen' : 'spalte-erledigt'"
|
||
>
|
||
<template v-if="liste.length">
|
||
<h3>
|
||
{{ titel }}
|
||
<span class="anzahl">{{ liste.length }}</span>
|
||
</h3>
|
||
|
||
<ul class="liste">
|
||
<li v-for="w in liste" :key="w.id" :class="{ erledigt: w.done_at }">
|
||
<!-- Eine Stimme je Person; nochmal klicken nimmt sie zurück -->
|
||
<button
|
||
type="button"
|
||
class="stimme"
|
||
:class="{ an: w.voted, hat: w.votes > 0 }"
|
||
:disabled="!angemeldet"
|
||
:title="w.voted ? 'Stimme zurücknehmen' : 'Dafür stimmen'"
|
||
@click="abstimmen(w)"
|
||
>
|
||
▲ {{ w.votes }}
|
||
</button>
|
||
|
||
<div class="inhalt">
|
||
<template v-if="bearbeitet === w.id">
|
||
<textarea
|
||
v-model="bearbeitung"
|
||
rows="2"
|
||
maxlength="500"
|
||
@keydown.ctrl.enter="speichern"
|
||
@keydown.esc="bearbeitet = null"
|
||
></textarea>
|
||
<div class="zeile">
|
||
<button type="button" class="klein" @click="speichern">Speichern</button>
|
||
<button type="button" class="klein" @click="bearbeitet = null">
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<p class="text">{{ w.text }}</p>
|
||
<p class="meta">
|
||
<span :class="{ eigen: w.mine }"><PersonName :name="w.name" /></span>
|
||
<span class="punkt">·</span>{{ datum(w.created_at) }}
|
||
<template v-if="w.done_at">
|
||
<span class="punkt">·</span>erledigt
|
||
{{ datum(w.done_at) }}
|
||
</template>
|
||
<span class="punkt">·</span>
|
||
<button
|
||
type="button"
|
||
class="link"
|
||
:class="{ offen: aufgeklappt.has(w.id) }"
|
||
@click="umschalten(w.id)"
|
||
>
|
||
{{ ergaenzungsLabel(w) }}
|
||
</button>
|
||
</p>
|
||
</template>
|
||
|
||
<div v-if="aufgeklappt.has(w.id)" class="notizen">
|
||
<p v-for="n in w.notes" :key="n.id" class="notiz">
|
||
<span class="wer" :class="{ eigen: n.mine }"><PersonName :name="n.name" /></span>
|
||
<span class="notiz-text">{{ n.text }}</span>
|
||
<button
|
||
v-if="host"
|
||
type="button"
|
||
class="weg sichtbar"
|
||
title="Ergänzung entfernen"
|
||
@click="notizLoeschen(n)"
|
||
>
|
||
×
|
||
</button>
|
||
</p>
|
||
<div v-if="angemeldet" class="notiz-neu">
|
||
<input
|
||
v-model="notizEntwurf[w.id]"
|
||
type="text"
|
||
maxlength="500"
|
||
placeholder="Ergänzung …"
|
||
@keydown.enter="anmerken(w)"
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="klein"
|
||
:disabled="!(notizEntwurf[w.id] || '').trim()"
|
||
@click="anmerken(w)"
|
||
>
|
||
Anhängen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Erst beim Überfahren sichtbar, sonst ist jede Zeile voller Knöpfe -->
|
||
<div class="aktionen">
|
||
<button
|
||
v-if="host"
|
||
type="button"
|
||
class="weg"
|
||
:title="w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'"
|
||
@click="erledigen(w, !w.done_at)"
|
||
>
|
||
{{ w.done_at ? '↺' : '✓' }}
|
||
</button>
|
||
<button
|
||
v-if="darfAendern(w) && bearbeitet !== w.id"
|
||
type="button"
|
||
class="weg"
|
||
title="Wunsch bearbeiten"
|
||
@click="bearbeiten(w)"
|
||
>
|
||
✎
|
||
</button>
|
||
<button
|
||
v-if="darfAendern(w)"
|
||
type="button"
|
||
class="weg"
|
||
title="Wunsch entfernen"
|
||
@click="loeschen(w)"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</template>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.spalten {
|
||
display: grid;
|
||
grid-template-areas:
|
||
'neu erledigt'
|
||
'offen erledigt';
|
||
grid-template-columns: 1fr 1fr;
|
||
grid-template-rows: auto 1fr;
|
||
gap: 0 2.5rem;
|
||
}
|
||
|
||
.neu {
|
||
display: flex;
|
||
grid-area: neu;
|
||
gap: 0.5rem;
|
||
align-items: flex-end;
|
||
margin-bottom: 1.75rem;
|
||
}
|
||
|
||
.spalte-offen {
|
||
grid-area: offen;
|
||
}
|
||
|
||
.spalte-erledigt {
|
||
grid-area: erledigt;
|
||
}
|
||
|
||
.neu textarea {
|
||
flex: 1;
|
||
padding: 0.6rem 0.8rem;
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 0.92rem;
|
||
line-height: 1.5;
|
||
resize: vertical;
|
||
background: var(--bg-soft);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.neu textarea:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.neu button {
|
||
padding: 0.6rem 1.1rem;
|
||
font-size: 0.88rem;
|
||
font-weight: 600;
|
||
background: var(--bg-soft);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.neu button:hover:not(:disabled) {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.neu button:disabled {
|
||
color: var(--muted);
|
||
cursor: default;
|
||
}
|
||
|
||
section {
|
||
margin-bottom: 1.75rem;
|
||
}
|
||
|
||
h3 {
|
||
display: flex;
|
||
gap: 0.45rem;
|
||
align-items: center;
|
||
margin-bottom: 0.4rem;
|
||
color: var(--muted);
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.09em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.anzahl {
|
||
padding: 0.1rem 0.45rem;
|
||
letter-spacing: 0;
|
||
background: var(--bg-soft);
|
||
border: 1px solid var(--line);
|
||
border-radius: 20px;
|
||
}
|
||
|
||
/* Zeilen mit Trennlinie statt Kästen — zehn gleiche Rahmen wirken sonst
|
||
wie ein Formular */
|
||
.liste {
|
||
list-style: none;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
|
||
.liste li {
|
||
display: flex;
|
||
gap: 0.8rem;
|
||
align-items: flex-start;
|
||
padding: 0.55rem 0.5rem;
|
||
border-bottom: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.liste li:hover {
|
||
background: var(--bg-soft);
|
||
}
|
||
|
||
/* Umgesetzt — tritt zurück wie ein gesehener Film */
|
||
.liste li.erledigt {
|
||
opacity: 0.45;
|
||
filter: grayscale(1);
|
||
}
|
||
|
||
.liste li.erledigt:hover {
|
||
opacity: 1;
|
||
filter: none;
|
||
}
|
||
|
||
.stimme {
|
||
flex-shrink: 0;
|
||
min-width: 3rem;
|
||
margin-top: 0.1rem;
|
||
padding: 0.18rem 0.55rem;
|
||
color: var(--muted);
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
white-space: nowrap;
|
||
border: 1px solid var(--line);
|
||
border-radius: 20px;
|
||
}
|
||
|
||
.stimme:hover:not(:disabled) {
|
||
color: var(--text);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.stimme:disabled {
|
||
cursor: default;
|
||
}
|
||
|
||
.stimme.hat {
|
||
color: var(--text);
|
||
}
|
||
|
||
.stimme.an {
|
||
color: var(--accent);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.inhalt {
|
||
display: flex;
|
||
flex: 1;
|
||
min-width: 0;
|
||
flex-direction: column;
|
||
gap: 0.15rem;
|
||
}
|
||
|
||
.text {
|
||
font-size: 0.98rem;
|
||
line-height: 1.45;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.meta {
|
||
color: var(--muted);
|
||
font-size: 0.76rem;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.punkt {
|
||
margin: 0 0.35rem;
|
||
}
|
||
|
||
.eigen {
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Der Ergänzen-Knopf sitzt als Textlink in der Meta-Zeile */
|
||
.link {
|
||
padding: 0;
|
||
color: var(--muted);
|
||
font-size: inherit;
|
||
text-decoration: underline;
|
||
text-decoration-color: transparent;
|
||
text-underline-offset: 2px;
|
||
}
|
||
|
||
.link:hover,
|
||
.link.offen {
|
||
color: var(--text);
|
||
text-decoration-color: currentColor;
|
||
}
|
||
|
||
.zeile {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.notizen {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
margin-top: 0.35rem;
|
||
padding: 0.4rem 0 0.2rem 0.7rem;
|
||
border-left: 2px solid var(--line);
|
||
}
|
||
|
||
.notiz {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: baseline;
|
||
font-size: 0.86rem;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.wer {
|
||
flex-shrink: 0;
|
||
color: var(--muted);
|
||
font-size: 0.78rem;
|
||
}
|
||
|
||
.notiz-text {
|
||
flex: 1;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.notiz-neu {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
margin-top: 0.2rem;
|
||
}
|
||
|
||
.notiz-neu input {
|
||
flex: 1;
|
||
padding: 0.35rem 0.6rem;
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 0.86rem;
|
||
background: var(--bg);
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.notiz-neu input:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
/* Aktionen tauchen erst beim Überfahren auf — per Tastatur bleiben sie
|
||
über den Fokus erreichbar */
|
||
.aktionen {
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
gap: 0.15rem;
|
||
opacity: 0;
|
||
transition: opacity 0.12s;
|
||
}
|
||
|
||
.liste li:hover .aktionen,
|
||
.liste li:focus-within .aktionen {
|
||
opacity: 1;
|
||
}
|
||
|
||
@media (hover: none) {
|
||
.aktionen {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.spalten {
|
||
grid-template-areas:
|
||
'neu'
|
||
'offen'
|
||
'erledigt';
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px), (pointer: coarse) {
|
||
.stimme {
|
||
padding: 0.4rem 0.7rem;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.neu textarea,
|
||
.notiz-neu input,
|
||
.inhalt textarea {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.link,
|
||
.weg,
|
||
.klein {
|
||
padding: 0.3rem 0.4rem;
|
||
}
|
||
}
|
||
|
||
.weg {
|
||
padding: 0 0.25rem;
|
||
color: var(--muted);
|
||
font-size: 1.05rem;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.weg:hover {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.weg.sichtbar {
|
||
opacity: 1;
|
||
}
|
||
|
||
.note {
|
||
padding: 2rem 0;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.inhalt textarea {
|
||
padding: 0.4rem 0.6rem;
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 0.92rem;
|
||
line-height: 1.5;
|
||
background: var(--bg);
|
||
border: 1px solid var(--accent);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.klein {
|
||
padding: 0.3rem 0.6rem;
|
||
color: var(--muted);
|
||
font-size: 0.78rem;
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.klein:hover:not(:disabled) {
|
||
color: var(--text);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.klein:disabled {
|
||
opacity: 0.5;
|
||
cursor: default;
|
||
}
|
||
</style>
|