Files
horror/frontend/src/components/WuenscheTab.vue
2026-09-29 01:01:15 +02:00

626 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>