669 lines
17 KiB
Vue
669 lines
17 KiB
Vue
<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>
|