Files
horror/frontend/src/components/VerwaltungTab.vue
T
2026-10-09 22:14:01 +02:00

669 lines
17 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, onMounted, reactive, ref, watch } from 'vue'
import FilmSuche from './FilmSuche.vue'
import Einladung from './Einladung.vue'
import { posterUrl } from '../images'
import PersonName from './PersonName.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
// Der Host pflegt die ganze Runde, alle anderen nur den eigenen Zugang
host: Boolean,
ich: { type: Object, default: null },
})
const liste = computed(() =>
props.host ? props.items : props.items.filter((u) => u.id === props.ich?.id),
)
const neuerName = ref('')
const fehler = ref('')
// Der Anmeldefilm ist das Passwort: wer den Namen wählt, muss ihn in der
// Suche finden. Welcher es ist, gibt der Server nicht heraus — hier steht
// nur, ob überhaupt einer gesetzt ist.
const setzt = ref(null)
async function schutzSetzen(user, movieId) {
await fetch(`/api/users/${user.id}/schutz`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: movieId }),
})
delete gezeigt[user.id]
setzt.value = null
}
// Wer den Film ändern darf, darf ihn auch nachschlagen — sonst weiß man
// nicht, was man gerade überschreibt. Sichtbar wird er erst auf Klick.
const gezeigt = reactive({})
async function schutzZeigen(user) {
if (gezeigt[user.id]) {
delete gezeigt[user.id]
return
}
gezeigt[user.id] = { laden: true }
const resp = await fetch(`/api/users/${user.id}/schutz`)
gezeigt[user.id] = resp.ok ? await resp.json() : { fehler: true }
}
// Die Einladungskarte — ein eigenes Fenster
const einladung = ref(false)
// Der Host-Film: das Passwort für den Hostmodus, gesetzt vom Host
const hostFilmDialog = ref(false)
const hostFilm = ref(null)
async function hostFilmZeigen() {
if (hostFilm.value) {
hostFilm.value = null
return
}
hostFilm.value = { laden: true }
const resp = await fetch('/api/host/film')
hostFilm.value = resp.ok ? await resp.json() : { fehler: true }
}
async function hostFilmSetzen(movieId) {
await fetch('/api/host/film', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: movieId }),
})
hostFilm.value = null
hostFilmDialog.value = false
}
// Die Aufnahmen der Abende — der Host löscht, was weg kann. Erst ein
// zweiter Klick auf denselben Knopf löscht wirklich.
const aufnahmen = ref(null)
const aufnahmenFehler = ref('')
const loeschFrage = ref(null)
async function aufnahmenLaden() {
if (!props.host) return
loeschFrage.value = null
try {
const resp = await fetch('/api/aufnahmen')
if (!resp.ok) throw new Error()
aufnahmen.value = (await resp.json()).stuecke
} catch {
aufnahmen.value = []
aufnahmenFehler.value = 'Die Aufnahmen sind gerade nicht erreichbar.'
}
}
async function aufnahmeLoeschen(s) {
if (loeschFrage.value !== s.start) {
loeschFrage.value = s.start
return
}
aufnahmenFehler.value = ''
const params = new URLSearchParams({ start: s.start, dauer: s.dauer })
const resp = await fetch(`/api/aufnahmen?${params}`, { method: 'DELETE' })
if (!resp.ok) {
// Die Meldung vom Server sagt, woran es lag — sie bleibt stehen
const daten = await resp.json().catch(() => ({}))
aufnahmenFehler.value = daten.detail || 'Löschen hat nicht geklappt.'
}
await aufnahmenLaden()
}
// Welcher Film in einer Aufnahme lief, sagt der Host — per Filmsuche
const filmDialogFuer = ref(null)
async function filmZuweisen(start, movieId) {
aufnahmenFehler.value = ''
const resp = await fetch('/api/aufnahmen/film', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start, movie_id: movieId }),
})
if (!resp.ok) aufnahmenFehler.value = 'Zuweisen hat nicht geklappt.'
filmDialogFuer.value = null
await aufnahmenLaden()
}
function aufnahmeText(s) {
const d = new Date(s.start)
const datum = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const zeit = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
return `${datum} · ${zeit} Uhr · ${Math.max(1, Math.round(s.dauer / 60))} Min`
}
onMounted(aufnahmenLaden)
watch(() => props.host, aufnahmenLaden)
async function anlegen() {
fehler.value = ''
const resp = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: neuerName.value }),
})
if (resp.ok) neuerName.value = ''
else fehler.value = 'Der Name ist leer oder schon vergeben.'
}
async function entfernen(user) {
await fetch(`/api/users/${user.id}`, { method: 'DELETE' })
}
</script>
<template>
<div class="verwaltung-tab">
<section>
<h3>{{ host ? 'Benutzer' : 'Dein Zugang' }}</h3>
<p v-if="!host" class="erklaerung">
Hinterleg einen Film, dann fragt die Seite beim Wechsel auf deinen Namen danach. Ohne Film
bleibt dein Name für alle offen.
</p>
<div v-if="host" class="anlegen">
<input
v-model="neuerName"
type="text"
placeholder="Name hinzufügen"
maxlength="40"
@keyup.enter="anlegen"
/>
<button type="button" @click="anlegen">Hinzufügen</button>
</div>
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<ul v-if="liste.length" class="liste">
<li v-for="u in liste" :key="u.id">
<div class="zeile">
<span class="name"><PersonName :name="u.name" :level="u.level" /></span>
<button
v-if="u.geschuetzt"
type="button"
class="auge"
:class="{ an: gezeigt[u.id] }"
:title="gezeigt[u.id] ? 'Film verbergen' : 'Hinterlegten Film zeigen'"
@click="schutzZeigen(u)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M1 12s4-7.5 11-7.5S23 12 23 12s-4 7.5-11 7.5S1 12 1 12z" />
<circle cx="12" cy="12" r="3.2" />
<line v-if="!gezeigt[u.id]" x1="3" y1="21" x2="21" y2="3" />
</svg>
</button>
<button type="button" class="klein" @click="setzt = u">
{{ u.geschuetzt ? 'Ändern' : 'Anmeldefilm' }}
</button>
<button
v-if="u.geschuetzt"
type="button"
class="klein"
title="Namen wieder für alle öffnen"
@click="schutzSetzen(u, null)"
>
Aufheben
</button>
<button
v-if="host"
type="button"
class="weg"
title="Aus der Runde nehmen"
@click="entfernen(u)"
>
×
</button>
</div>
<p v-if="gezeigt[u.id]" class="verraten">
<template v-if="gezeigt[u.id].laden">Moment…</template>
<template v-else-if="gezeigt[u.id].film">
<img
v-if="posterUrl(gezeigt[u.id].film.poster_path, 'w92')"
:src="posterUrl(gezeigt[u.id].film.poster_path, 'w92')"
:alt="gezeigt[u.id].film.title"
/>
<span>
{{ gezeigt[u.id].film.title }}
<span v-if="gezeigt[u.id].film.year" class="jahr">
{{ gezeigt[u.id].film.year }}
</span>
</span>
</template>
<template v-else>Der Film ließ sich nicht laden.</template>
</p>
</li>
</ul>
<p v-else class="leer">Noch niemand in der Runde.</p>
</section>
<div v-if="host" class="rechts">
<section class="einladung">
<h3>Einladung</h3>
<p class="erklaerung">
Eine Karte zum Verschicken: Datum, Genre, Discord, Webseite. Als Bild fürs Handy.
</p>
<button type="button" class="klein" @click="einladung = true">Einladung erstellen</button>
</section>
<Einladung v-if="einladung" @zu="einladung = false" />
<section class="hostfilm">
<h3>Host-Film</h3>
<p class="erklaerung">
Die Tastenkombination fragt ohne Freischaltung nach diesem Film. Wer ihn anklickt, ist
Host — ohne Link.
</p>
<div class="zeile">
<button type="button" class="klein" @click="hostFilmDialog = true">Film setzen</button>
<button type="button" class="klein" title="Anzeigen" @click="hostFilmZeigen">👁</button>
<button type="button" class="klein" @click="hostFilmSetzen(null)">Aufheben</button>
<span v-if="hostFilm" class="film-anzeige">
<template v-if="hostFilm.laden">…</template>
<template v-else-if="hostFilm.fehler">nicht lesbar</template>
<template v-else-if="hostFilm.film"
>{{ hostFilm.film.title }} ({{ hostFilm.film.year }})</template
>
<template v-else>keiner gesetzt</template>
</span>
</div>
</section>
<section class="aufnahmen">
<h3>Aufnahmen</h3>
<p v-if="aufnahmenFehler" class="fehler">{{ aufnahmenFehler }}</p>
<p v-if="aufnahmen === null" class="leer">Suche Aufnahmen …</p>
<p v-else-if="!aufnahmen.length" class="leer">Keine Aufnahmen.</p>
<ul v-else>
<li v-for="s in aufnahmen" :key="s.start" class="zeile">
<span>
{{ aufnahmeText(s) }} ·
<strong v-if="s.film">{{ s.film.title }}</strong>
<em v-else>kein Film</em>
</span>
<span class="knoepfe">
<button type="button" class="klein" @click="filmDialogFuer = s.start">Film</button>
<button
v-if="s.zugewiesen"
type="button"
class="klein"
@click="filmZuweisen(s.start, null)"
>
Aufheben
</button>
<button
type="button"
class="klein"
:class="{ warnung: loeschFrage === s.start }"
@click="aufnahmeLoeschen(s)"
>
{{ loeschFrage === s.start ? 'Wirklich löschen?' : 'Löschen' }}
</button>
</span>
</li>
</ul>
</section>
</div>
<Teleport to="body">
<div v-if="filmDialogFuer" class="hintergrund" @click.self="filmDialogFuer = null">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Aufnahme</p>
<h2>Welcher Film lief?</h2>
<FilmSuche
platzhalter="Film suchen …"
@waehlen="filmZuweisen(filmDialogFuer, $event.id)"
/>
<div class="fuss">
<button type="button" class="abbrechen" @click="filmDialogFuer = null">
Abbrechen
</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="hostFilmDialog" class="hintergrund" @click.self="hostFilmDialog = false">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Host-Film</p>
<h2>Passwort für den Hostmodus</h2>
<p class="frage">Diesen Film klickt man nach der Tastenkombination an.</p>
<FilmSuche platzhalter="Film als Passwort …" @waehlen="hostFilmSetzen($event.id)" />
<div class="fuss">
<button type="button" class="abbrechen" @click="hostFilmDialog = false">
Abbrechen
</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="setzt" class="hintergrund" @click.self="setzt = null">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Anmeldefilm für</p>
<h2>{{ setzt.name }}</h2>
<p class="frage">
{{
setzt.id === ich?.id
? 'Diesen Film musst du beim Wechsel auf deinen Namen anklicken.'
: `Diesen Film muss ${setzt.name} beim Wechsel anklicken.`
}}
</p>
<FilmSuche platzhalter="Film als Passwort …" @waehlen="schutzSetzen(setzt, $event.id)" />
<div class="fuss">
<button type="button" class="abbrechen" @click="setzt = null">Abbrechen</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.verwaltung-tab {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 2.5rem 3.5rem;
align-items: start;
max-width: 72rem;
}
/* Mit Host-Rechten: Benutzer links, der Rest rechts */
.verwaltung-tab:has(.rechts) {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.rechts {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
@media (max-width: 900px) {
.verwaltung-tab:has(.rechts) {
grid-template-columns: minmax(0, 1fr);
}
}
h3 {
margin-bottom: 0.8rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.anlegen {
display: flex;
gap: 0.4rem;
margin-bottom: 1.25rem;
}
.anlegen input {
flex: 1;
padding: 0.55rem 0.8rem;
font-size: 0.92rem;
}
.anlegen button {
padding: 0.55rem 1rem;
font-size: 0.88rem;
font-weight: 600;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.anlegen button:hover {
border-color: var(--accent);
}
.liste {
list-style: none;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.liste li {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.4rem 0.7rem;
}
.liste li + li {
border-top: 1px solid var(--line);
}
.zeile {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Der hinterlegte Film, aufgeklappt */
.verraten {
display: flex;
gap: 0.6rem;
align-items: center;
padding-top: 0.5rem;
color: var(--text);
font-size: 0.9rem;
border-top: 1px solid var(--line);
}
.verraten img {
width: 40px;
border-radius: 4px;
}
.jahr {
color: var(--muted);
font-size: 0.82rem;
}
.auge {
display: grid;
place-items: center;
width: 1.8rem;
height: 1.8rem;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 6px;
}
.auge svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
}
.auge:hover,
.auge.an {
color: var(--text);
border-color: var(--accent);
}
.name {
flex: 1;
font-size: 0.95rem;
}
.weg {
padding: 0.15rem 0.4rem;
color: var(--muted);
font-size: 1.1rem;
line-height: 1;
}
.weg:hover {
color: var(--accent);
}
.leer,
.fehler,
.erklaerung {
color: var(--muted);
font-size: 0.88rem;
}
.erklaerung {
margin-bottom: 0.8rem;
line-height: 1.5;
}
.fehler {
margin-bottom: 0.8rem;
color: var(--accent);
}
.marke {
padding: 0.1rem 0.4rem;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
border: 1px solid var(--line);
border-radius: 4px;
}
.klein {
padding: 0.25rem 0.55rem;
color: var(--muted);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.klein:hover {
color: var(--text);
border-color: var(--accent);
}
/* ── Fenster zum Setzen ──────────────────────────────────────────── */
.hintergrund {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
}
.fenster {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(38rem, 100%);
max-height: min(42rem, 90vh);
padding: 1.5rem 1.75rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 14px;
}
.label {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.fenster h2 {
font-size: 1.35rem;
line-height: 1.2;
}
.frage {
color: var(--muted);
font-size: 0.9rem;
}
.fuss {
display: flex;
justify-content: flex-end;
}
.abbrechen {
padding: 0.55rem 1.1rem;
color: var(--muted);
font-size: 0.88rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.abbrechen:hover {
color: var(--text);
}
@media (max-width: 640px) {
.hintergrund {
padding: 0;
}
.fenster {
width: 100%;
height: 100%;
max-height: none;
padding: 1rem;
border: 0;
border-radius: 0;
}
}
.aufnahmen ul {
display: flex;
flex-direction: column;
gap: 0.4rem;
list-style: none;
}
.aufnahmen .zeile {
display: flex;
gap: 0.8rem;
align-items: center;
justify-content: space-between;
font-size: 0.9rem;
}
.aufnahmen .knoepfe {
display: flex;
flex-shrink: 0;
gap: 0.4rem;
}
.aufnahmen em {
color: var(--muted);
font-style: normal;
}
.klein.warnung {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.hostfilm .zeile {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.film-anzeige {
color: var(--muted);
font-size: 0.88rem;
}
</style>