This commit is contained in:
team3
2026-08-31 11:54:20 +02:00
parent f29a4106bb
commit e7216138c7
21 changed files with 1517 additions and 87 deletions

View File

@@ -6,6 +6,7 @@
"": {
"name": "horror-frontend",
"dependencies": {
"hls.js": "^1.7.1",
"vue": "^3.5.13"
},
"devDependencies": {
@@ -1134,6 +1135,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/hls.js": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.1.tgz",
"integrity": "sha512-DlzIkeBAS9IIQ432k3BUf3HlwbsR0+trB1i2lDdN2gUkNkrehFurh0/48M5c1/EjlDkdGng1gwZIpwyPxvdZ/g==",
"license": "Apache-2.0"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@@ -8,6 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"hls.js": "^1.7.1",
"vue": "^3.5.13"
},
"devDependencies": {

View File

@@ -1,9 +1,11 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.vue'
import MovieDetail from './components/MovieDetail.vue'
import SearchTab from './components/SearchTab.vue'
import StreamTab from './components/StreamTab.vue'
import WatchedTab from './components/WatchedTab.vue'
import WheelTab from './components/WheelTab.vue'
import SeriesCard from './components/SeriesCard.vue'
@@ -14,7 +16,13 @@ const error = ref('')
const search = ref('')
const sort = ref('rating')
const selected = ref(null)
const activeTab = ref('netflix')
// Nach dem Neuladen soll derselbe Reiter offen sein
const TAB_SCHLUESSEL = 'filmabend_tab'
const TAB_IDS = ['netflix', 'canon', 'search', 'wheel', 'watched', 'stream']
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
watch(activeTab, (id) => localStorage.setItem(TAB_SCHLUESSEL, id))
const include = ref([])
const exclude = ref([])
@@ -187,13 +195,48 @@ const netflixEntries = computed(() => {
const tabs = computed(() => [
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
{ id: 'canon', label: 'Kanon', count: canonGroups.value.length },
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length },
{ id: 'search', label: 'Suche', count: null },
{ id: 'wheel', label: 'Rad', count: null },
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{ id: 'stream', label: 'Stream', count: null },
])
const rad = ref(null)
// Die movie_ids der geschauten Filme — die Detailansicht zeigt damit an,
// ob der Film schon lief
const gesehen = ref([])
async function ladeGesehen() {
try {
const resp = await fetch('/api/watched')
if (resp.ok) gesehen.value = await resp.json()
} catch {
/* dann eben ohne */
}
}
// Ausgeblendete Filme zählen für Gäste nicht mit
const gesehenZahl = computed(
() => gesehen.value.filter((e) => host.value || !e.hidden).length
)
// Wer den Abend leitet, darf drehen und die Runde leeren. Kein Login —
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
const HOST_SCHLUESSEL = 'filmabend_host'
const host = ref(localStorage.getItem(HOST_SCHLUESSEL) === '1')
function tastatur(e) {
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
// je nach System ein anderes Zeichen
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
e.preventDefault()
host.value = !host.value
localStorage.setItem(HOST_SCHLUESSEL, host.value ? '1' : '0')
}
}
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
const meinVorschlag = ref(null)
@@ -326,11 +369,15 @@ async function load() {
onMounted(() => {
load()
ladeVorschlag()
ladeGesehen()
window.addEventListener('keydown', tastatur)
})
onUnmounted(() => window.removeEventListener('keydown', tastatur))
</script>
<template>
<div class="page" :class="{ breit: activeTab === 'search' || activeTab === 'wheel' }">
<div class="page" :class="{ breit: ['search', 'wheel', 'stream', 'watched'].includes(activeTab) }">
<nav class="tabs">
<button
v-for="tab in tabs"
@@ -342,11 +389,27 @@ onMounted(() => {
{{ tab.label }}
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
</button>
<span v-if="host" class="host">Host</span>
</nav>
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
<WheelTab v-else-if="activeTab === 'wheel'" ref="rad" @open="selected = $event" />
<WheelTab
v-else-if="activeTab === 'wheel'"
ref="rad"
:host="host"
@open="selected = $event"
@reset="meinVorschlag = null"
/>
<WatchedTab
v-else-if="activeTab === 'watched'"
:host="host"
@open="selected = $event"
@changed="ladeGesehen"
/>
<StreamTab v-else-if="activeTab === 'stream'" />
<div v-else class="layout">
<div class="results">
@@ -402,7 +465,10 @@ onMounted(() => {
@close="selected = null"
@open="selected = $event"
:mein-vorschlag="meinVorschlag"
:host="host"
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
@suggested="nachVorschlag"
@watched="ladeGesehen"
/>
</div>
@@ -446,6 +512,19 @@ onMounted(() => {
border-bottom-color: var(--accent);
}
.host {
align-self: center;
margin-left: auto;
padding: 0.15rem 0.5rem;
color: var(--accent);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid var(--accent);
border-radius: 4px;
}
.tabs .count {
padding: 0.1rem 0.45rem;
font-size: 0.75rem;

View File

@@ -6,9 +6,11 @@ const props = defineProps({
movie: { type: Object, required: true },
series: { type: Array, default: () => [] },
meinVorschlag: { type: Object, default: null },
host: { type: Boolean, default: false },
gesehen: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'open', 'suggested'])
const emit = defineEmits(['close', 'open', 'suggested', 'watched'])
const vorschlagen = ref({ zustand: 'bereit', name: '' })
@@ -35,6 +37,24 @@ async function abschicken() {
}
}
const merken = ref({ zustand: 'bereit' })
async function alsGesehen() {
merken.value = { zustand: 'laeuft' }
try {
const resp = await fetch('/api/watched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: props.movie.id }),
})
if (!resp.ok) throw new Error()
merken.value = { zustand: 'fertig' }
emit('watched')
} catch {
merken.value = { zustand: 'fehler' }
}
}
const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
const shots = computed(() =>
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
@@ -143,6 +163,23 @@ onUnmounted(() => {
</span>
</template>
</div>
<div v-if="host || gesehen" class="gesehen">
<span v-if="gesehen || merken.zustand === 'fertig'" class="ok">
Steht unter „Gesehen“.
</span>
<button
v-else-if="host"
type="button"
:disabled="merken.zustand === 'laeuft'"
@click="alsGesehen"
>
Als gesehen markieren
</button>
<span v-if="merken.zustand === 'fehler'" class="fehler">
Hat nicht geklappt.
</span>
</div>
</div>
</header>
@@ -348,6 +385,32 @@ h2 {
cursor: default;
}
.gesehen {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
margin-top: 0.6rem;
}
.gesehen button {
padding: 0.45rem 1rem;
font-size: 0.85rem;
font-weight: 600;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 7px;
}
.gesehen button:hover:not(:disabled) {
border-color: var(--accent);
}
.gesehen button:disabled {
color: var(--muted);
cursor: default;
}
.hinweis {
flex-basis: 100%;
color: var(--muted);

View File

@@ -63,11 +63,6 @@ async function open(movie) {
<p v-else-if="searched && !results.length" class="note">
Nichts gefunden für „{{ query }}“.
</p>
<p v-else-if="!searched" class="note">
Tippe einen Titel ein. Gesucht wird der ganze TMDB-Bestand, nicht nur
der Katalog.
</p>
<div class="grid">
<MovieCard
v-for="movie in results"

View File

@@ -0,0 +1,180 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import Hls from 'hls.js'
// Alle Zuschauer regeln auf dieselbe Distanz zur Live-Kante. Damit laufen
// sie synchron, ohne dass ein Server sie taktet. hls.js passt dafür die
// Abspielgeschwindigkeit an (maxLiveSyncPlaybackRate).
const ZIEL_LATENZ = 4
const video = ref(null)
const zustand = ref('lade')
const fehler = ref('')
let hls = null
function verbinden() {
const quelle = '/stream/filmabend/index.m3u8'
if (!Hls.isSupported()) {
// Safari spielt HLS selbst ab, dann übernimmt der Browser die Regelung
if (video.value.canPlayType('application/vnd.apple.mpegurl')) {
video.value.src = quelle
zustand.value = 'laeuft'
return
}
fehler.value = 'Dieser Browser kann den Stream nicht abspielen.'
zustand.value = 'fehler'
return
}
hls = new Hls({
lowLatencyMode: true,
liveSyncDuration: ZIEL_LATENZ,
// Ab hier springt hls.js selbst auf die Zielposition zurück, statt
// ewig mit 1,5-fachem Tempo aufzuholen. Zu dicht an ZIEL_LATENZ
// führt laut Doku zu Stockern.
liveMaxLatencyDuration: ZIEL_LATENZ + 3,
// Über 1 erlaubt hls.js, die Geschwindigkeit anzuheben, um die
// Ziel-Latenz zu halten. Standard ist 1, also aus.
maxLiveSyncPlaybackRate: 1.5,
backBufferLength: 30,
})
hls.on(Hls.Events.ERROR, (_, daten) => {
if (!daten.fatal) return
if (daten.type === Hls.ErrorTypes.NETWORK_ERROR) {
zustand.value = 'wartet'
// Nicht sofort neu anfragen — sonst hämmert der Browser den Server
window.setTimeout(() => hls?.startLoad(), 3000)
} else if (daten.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls.recoverMediaError()
} else {
fehler.value = 'Stream nicht erreichbar.'
zustand.value = 'fehler'
}
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
zustand.value = 'laeuft'
video.value.play().catch(() => {
// Autoplay ohne Ton ist erlaubt, mit Ton nicht — dann wartet es
// auf einen Klick
zustand.value = 'bereit'
})
})
hls.loadSource(quelle)
hls.attachMedia(video.value)
// Für Diagnose im Betrieb: Latenzwerte in der Konsole nachsehen
window.__stream = hls
}
function starten() {
video.value?.play()
zustand.value = 'laeuft'
}
onMounted(verbinden)
onUnmounted(() => {
hls?.destroy()
hls = null
})
</script>
<template>
<div class="stream-tab">
<div class="buehne">
<!-- Ohne Stream zeigt der Browser nur seinen eigenen Ladekringel und
verrät nicht, warum nichts kommt -->
<video
v-show="zustand === 'laeuft' || zustand === 'bereit'"
ref="video"
class="player"
controls
playsinline
muted
></video>
<div v-if="zustand === 'wartet' || zustand === 'lade'" class="platzhalter">
<p class="gross">
{{ zustand === 'lade' ? 'Verbinde…' : 'Es wird gerade nichts gesendet.' }}
</p>
</div>
<button v-if="zustand === 'bereit'" class="start" type="button" @click="starten">
Wiedergabe starten
</button>
<p v-if="fehler" class="note error">{{ fehler }}</p>
</div>
</div>
</template>
<style scoped>
.stream-tab {
min-width: 0;
}
.buehne {
position: relative;
min-width: 0;
}
.player {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border: 1px solid var(--line);
border-radius: 12px;
}
.platzhalter {
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: center;
align-items: center;
aspect-ratio: 16 / 9;
padding: 2rem;
text-align: center;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
.gross {
color: var(--text);
font-size: 1.05rem;
}
.start {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 0.8rem 1.8rem;
color: #fff;
font-size: 1rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.note {
padding: 1rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
</style>

View File

@@ -0,0 +1,425 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { posterUrl } from '../images'
import { abonnieren } from '../live'
const props = defineProps({ host: Boolean })
const emit = defineEmits(['open', 'changed'])
const items = ref([])
// Ausgeblendete Filme bleiben für den Host sichtbar, damit er sie
// zurückholen kann
const sichtbar = computed(() =>
props.host ? items.value : items.value.filter((e) => !e.hidden)
)
const fehler = ref('')
const laden = ref(true)
// Der Name gilt für alle Bewertungen und überlebt das Neuladen
const NAME_SCHLUESSEL = 'filmabend_name'
const name = ref(localStorage.getItem(NAME_SCHLUESSEL) || '')
const STERNE = [1, 2, 3, 4, 5]
// Beim Schweben füllen sich alle Sterne bis zum Zeiger, wie gewohnt
const schwebt = ref({ id: null, wert: 0 })
function gefuellt(item, stern) {
if (schwebt.value.id === item.id) return stern <= schwebt.value.wert
return item.mine >= stern
}
let abbestellen = null
async function laden_() {
try {
const resp = await fetch('/api/watched')
if (!resp.ok) throw new Error('Liste konnte nicht geladen werden')
items.value = await resp.json()
} catch (err) {
fehler.value = err.message
} finally {
laden.value = false
}
}
async function bewerten(item, sterne) {
localStorage.setItem(NAME_SCHLUESSEL, name.value)
// Sofort anzeigen, der Live-Kanal liefert gleich die richtigen Zahlen
item.mine = sterne
try {
await fetch(`/api/watched/${item.id}/rating`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stars: sterne, name: name.value || null }),
})
} catch {
fehler.value = 'Bewerten hat nicht geklappt.'
}
}
async function sichtbarkeit(item) {
await fetch(`/api/watched/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !item.hidden }),
})
}
async function entfernen(item) {
await fetch(`/api/watched/${item.id}`, { method: 'DELETE' })
}
async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`)
if (resp.ok) emit('open', await resp.json())
}
// Für das Datumsfeld zählt der lokale Tag, nicht der in UTC
function alsFeld(iso) {
const d = new Date(iso)
const zwei = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`
}
async function datumSetzen(item, tag) {
if (!tag) return
await fetch(`/api/watched/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched_at: tag }),
})
}
function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
onMounted(() => {
laden_()
abbestellen = abonnieren((nachricht) => {
if (nachricht.type !== 'watched') return
laden_()
emit('changed')
})
})
onUnmounted(() => abbestellen?.())
</script>
<template>
<div class="watched-tab">
<div class="kopf">
<input
v-model="name"
type="text"
class="name"
placeholder="Dein Name (optional)"
maxlength="40"
/>
</div>
<p v-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!laden && !sichtbar.length" class="note">
Noch kein Film geschaut.
</p>
<div class="grid">
<article
v-for="item in sichtbar"
:key="item.id"
class="karte"
:class="{ versteckt: item.hidden }"
>
<button type="button" class="cover" @click="oeffnen(item)">
<img
v-if="posterUrl(item.poster_path, 'w342')"
:src="posterUrl(item.poster_path, 'w342')"
:alt="item.title"
loading="lazy"
/>
<span v-else class="kein-cover">Kein Cover</span>
</button>
<div class="text">
<h3 @click="oeffnen(item)">{{ item.title }}</h3>
<p class="meta">
<span v-if="item.year">{{ item.year }}</span>
<span v-if="host" class="datum-feld">
Geschaut am
<input
type="date"
:value="alsFeld(item.watched_at)"
@change="datumSetzen(item, $event.target.value)"
/>
</span>
<span v-else>Geschaut am {{ datum(item.watched_at) }}</span>
</p>
<p class="schnitt">
<template v-if="item.count">
<span class="wert">{{ item.average.toFixed(1) }}</span>
<span class="von">von 5 ({{ item.count }})</span>
</template>
<span v-else class="von">Noch keine Wertung</span>
</p>
<div class="wertung" @mouseleave="schwebt = { id: null, wert: 0 }">
<button
v-for="s in STERNE"
:key="s"
type="button"
class="stern"
:class="{ an: gefuellt(item, s) }"
:aria-label="`${s} von 5`"
@mouseenter="schwebt = { id: item.id, wert: s }"
@click="bewerten(item, s)"
>
★
</button>
</div>
<ul v-if="item.ratings.length" class="stimmen">
<li v-for="(r, i) in item.ratings" :key="i">
<span class="wer">{{ r.name || 'Anonym' }}</span>
<span class="punkte">{{ '★'.repeat(r.stars) }}</span>
</li>
</ul>
<div v-if="host" class="aktionen">
<button
type="button"
class="icon"
:title="item.hidden ? 'Wieder einblenden' : 'Ausblenden'"
@click="sichtbarkeit(item)"
>
<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="item.hidden" x1="3" y1="21" x2="21" y2="3" />
</svg>
</button>
<button
type="button"
class="icon loeschen"
title="Samt Bewertungen löschen"
@click="entfernen(item)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="18" y1="6" x2="6" y2="18" />
</svg>
</button>
</div>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.kopf {
display: flex;
gap: 0.7rem;
align-items: center;
margin-bottom: 1.5rem;
}
.name {
width: 14rem;
padding: 0.55rem 0.8rem;
font-size: 0.9rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.1rem;
}
.karte {
display: flex;
gap: 0.9rem;
padding: 0.8rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
.cover {
flex-shrink: 0;
width: 96px;
padding: 0;
}
.cover img,
.kein-cover {
width: 96px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
}
.kein-cover {
display: grid;
place-items: center;
color: var(--muted);
font-size: 0.72rem;
background: var(--bg);
}
.text {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
h3 {
font-size: 1rem;
line-height: 1.25;
cursor: pointer;
}
h3:hover {
color: var(--accent);
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
color: var(--muted);
font-size: 0.78rem;
}
.datum-feld {
display: flex;
gap: 0.35rem;
align-items: center;
}
.datum-feld input {
padding: 0.15rem 0.35rem;
color: var(--text);
font-size: 0.78rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 5px;
}
.schnitt {
display: flex;
gap: 0.35rem;
align-items: baseline;
margin-top: 0.2rem;
}
.wert {
color: #f5c518;
font-size: 1.05rem;
font-weight: 700;
}
.von {
color: var(--muted);
font-size: 0.78rem;
}
.wertung {
display: flex;
gap: 0.1rem;
align-items: center;
}
.stern {
padding: 0 0.05rem;
color: var(--line);
font-size: 1.25rem;
line-height: 1;
}
.stern.an {
color: #f5c518;
}
.stimmen {
display: flex;
flex-direction: column;
gap: 0.1rem;
margin-top: 0.25rem;
list-style: none;
}
.stimmen li {
display: flex;
gap: 0.5rem;
font-size: 0.76rem;
}
.wer {
color: var(--muted);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.punkte {
color: #f5c518;
}
.aktionen {
display: flex;
gap: 0.35rem;
margin-top: 0.5rem;
}
.icon {
display: grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 6px;
}
.icon svg {
width: 1.05rem;
height: 1.05rem;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
}
.icon:hover {
color: var(--text);
border-color: var(--muted);
}
.icon.loeschen:hover {
color: var(--accent);
border-color: var(--accent);
}
/* Der Host sieht sofort, was für die anderen weg ist */
.karte.versteckt {
opacity: 0.55;
}
.note {
padding: 2.5rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
</style>

View File

@@ -1,8 +1,10 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { posterUrl } from '../images'
import { abonnieren } from '../live'
const emit = defineEmits(['open'])
defineProps({ host: Boolean })
const emit = defineEmits(['open', 'reset'])
const items = ref([])
const mine = ref(null)
@@ -26,16 +28,14 @@ const FARBEN = [
]
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
let quelle = null
let abbestellen = null
function verbinden() {
quelle = new EventSource('/api/events')
quelle.onmessage = (ereignis) => {
const nachricht = JSON.parse(ereignis.data)
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'suggestions') laden_()
else if (nachricht.type === 'spin') abspielen(nachricht.data)
}
// Der Browser verbindet nach einem Abbruch von selbst neu
else if (nachricht.type === 'reset') zurueckgesetzt()
})
}
function abspielen(ergebnis, uebersprungen = 0) {
@@ -101,7 +101,7 @@ onMounted(async () => {
verbinden()
})
onUnmounted(() => quelle?.close())
onUnmounted(() => abbestellen?.())
defineExpose({ neu: laden_ })
@@ -165,6 +165,25 @@ async function drehen() {
}
}
function zurueckgesetzt() {
// Ohne Dauer 0 würde das Rad die Strecke zurück sichtbar abfahren
dauer.value = 0
winkel.value = 0
gewinner.value = null
dreht.value = false
emit('reset')
laden_()
}
async function zuruecksetzen() {
// Der Server meldet den Reset an alle zurück, auch an uns
try {
await fetch('/api/suggestions/alle', { method: 'DELETE' })
} catch {
fehler.value = 'Zurücksetzen hat nicht geklappt.'
}
}
async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`)
if (resp.ok) emit('open', await resp.json())
@@ -174,64 +193,70 @@ async function oeffnen(item) {
<template>
<div class="wheel-tab">
<div class="buehne">
<p v-if="laden" class="note">Lade Vorschläge…</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!items.length" class="note">
Noch keine Vorschläge. Öffne einen Film und klicke auf „Vorschlagen“.
</p>
<p v-if="fehler" class="note error">{{ fehler }}</p>
<template v-else>
<div class="rad-halter">
<div class="zeiger"></div>
<svg
class="rad"
:class="{ dreht }"
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
:style="{
transform: `rotate(${winkel}deg)`,
transitionDuration: `${dauer}s`,
}"
>
<g v-for="(seg, i) in segmente" :key="seg.item.id">
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
<text
v-if="seg.breit"
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
:y="MITTE + 4"
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
class="beschriftung"
>
{{ seg.kurz }}
</text>
</g>
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
</svg>
</div>
<div class="rad-halter">
<div class="zeiger"></div>
<svg
class="rad"
:class="{ dreht }"
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
:style="{
transform: `rotate(${winkel}deg)`,
transitionDuration: `${dauer}s`,
}"
>
<circle :cx="MITTE" :cy="MITTE" :r="RADIUS" fill="var(--bg-soft)" />
<g v-for="(seg, i) in segmente" :key="seg.item.id">
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
<text
v-if="seg.breit"
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
:y="MITTE + 4"
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
class="beschriftung"
>
{{ seg.kurz }}
</text>
</g>
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
</svg>
</div>
<button class="dreh" type="button" :disabled="dreht" @click="drehen">
<div v-if="host" class="knoepfe">
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
{{ dreht ? 'dreht…' : 'Drehen' }}
</button>
<button
class="reset"
type="button"
:disabled="dreht || !items.length"
@click="zuruecksetzen"
>
Zurücksetzen
</button>
</div>
<div v-if="gewinner" class="gewinner" @click="oeffnen(gewinner)">
<img
v-if="posterUrl(gewinner.poster_path, 'w154')"
:src="posterUrl(gewinner.poster_path, 'w154')"
:alt="gewinner.title"
/>
<div>
<p class="label">Der Film des Abends</p>
<h2>{{ gewinner.title }}</h2>
<p class="jahr">
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
</p>
</div>
<div v-if="gewinner" class="gewinner" @click="oeffnen(gewinner)">
<img
v-if="posterUrl(gewinner.poster_path, 'w154')"
:src="posterUrl(gewinner.poster_path, 'w154')"
:alt="gewinner.title"
/>
<div>
<p class="label">Der Film des Abends</p>
<h2>{{ gewinner.title }}</h2>
<p class="jahr">
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
</p>
</div>
</template>
</div>
</div>
<aside v-if="items.length" class="liste">
<aside class="liste">
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
<ul>
<li v-for="(item, i) in items" :key="item.id">
<button
@@ -300,6 +325,35 @@ async function oeffnen(item) {
border-left: 11px solid transparent;
}
.knoepfe {
display: flex;
gap: 0.7rem;
}
.reset {
padding: 0.7rem 1.4rem;
color: var(--muted);
font-size: 0.95rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.reset:hover:not(:disabled) {
color: var(--text);
border-color: var(--muted);
}
.reset:disabled {
opacity: 0.4;
cursor: default;
}
.leer {
color: var(--muted);
font-size: 0.85rem;
}
.dreh {
padding: 0.7rem 2.2rem;
color: #fff;

18
frontend/src/live.js Normal file
View File

@@ -0,0 +1,18 @@
// Ein Kanal für die ganze Seite. Mehrere Komponenten lauschen mit, ohne
// dass jede eine eigene Verbindung aufmacht.
let quelle = null
const hoerer = new Set()
export function abonnieren(fn) {
hoerer.add(fn)
if (!quelle) {
quelle = new EventSource('/api/events')
quelle.onmessage = (ereignis) => {
const nachricht = JSON.parse(ereignis.data)
hoerer.forEach((h) => h(nachricht))
}
// Der Browser verbindet nach einem Abbruch von selbst neu
}
return () => hoerer.delete(fn)
}

View File

@@ -10,6 +10,10 @@ export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:8000',
// Nicht direkt zu MediaMTX: das antwortet mit einem Redirect auf
// seinen eigenen Port, und der Browser landet damit außerhalb des
// Proxys. Das Backend folgt dem Redirect selbst.
'/stream': 'http://localhost:8000',
},
},
})