Die Runde ist jetzt benannt. Der Host pflegt die Namensliste im neuen Reiter "Verwaltung", alle anderen wählen sich oben rechts einen aus. Der Name landet serverseitig an Vorschlägen und Wertungen, statt aus einem Textfeld zu kommen, in das jeder schreiben konnte, was er wollte. Dazu zwei Sammelstellen, beide nach Person gruppiert und für alle sichtbar, aber nur selbst bearbeitbar: - "Sammlungen": Filme, die man sehen möchte. Getrennt vom Rad-Vorschlag, von dem es weiterhin einen je Person gibt. - "Wünsche": was der Seite noch fehlt. Der Host darf hier alle Einträge ändern und löschen. Die Filmliste hat jetzt zwei Ansichten. Kompakt ist die gewohnte Karte, Details stellt Cover, Text und vier Szenenbilder nebeneinander — eine Karte so breit wie vier kompakte oder die halbe Reihe, je nachdem was zuerst zutrifft. Reihen und Einzelfilme sind dabei exakt gleich hoch; die Blätterleiste liegt dafür über dem Cover statt darunter. Die Filmansicht nutzt die Breite: links Cover, Text und Knöpfe, rechts der Trailer, darunter die Bilder. Ein Klick zeigt ein Bild in voller Größe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XVnRop5c3or3B8xE2hpgZ
662 lines
14 KiB
Vue
662 lines
14 KiB
Vue
<script setup>
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import { backdropUrl, posterUrl } from '../images'
|
||
|
||
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 },
|
||
gemerkt: { type: Boolean, default: false },
|
||
})
|
||
|
||
const emit = defineEmits(['close', 'open', 'suggested', 'watched', 'gemerkt'])
|
||
|
||
const vorschlagen = ref({ zustand: 'bereit' })
|
||
|
||
const schonVorgeschlagen = computed(
|
||
() => props.meinVorschlag?.movie_id === props.movie.id
|
||
)
|
||
|
||
async function merkenUmschalten() {
|
||
const weg = props.gemerkt
|
||
await fetch(weg ? `/api/wishlist/${props.movie.id}` : '/api/wishlist', {
|
||
method: weg ? 'DELETE' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: weg ? undefined : JSON.stringify({ movie_id: props.movie.id }),
|
||
})
|
||
emit('gemerkt')
|
||
}
|
||
|
||
async function zuruecknehmen() {
|
||
vorschlagen.value = { zustand: 'laeuft' }
|
||
try {
|
||
const resp = await fetch('/api/suggestions', { method: 'DELETE' })
|
||
if (!resp.ok) throw new Error()
|
||
vorschlagen.value = { zustand: 'bereit' }
|
||
emit('suggested')
|
||
} catch {
|
||
vorschlagen.value = { zustand: 'fehler' }
|
||
}
|
||
}
|
||
|
||
async function abschicken() {
|
||
vorschlagen.value = { zustand: 'laeuft' }
|
||
try {
|
||
const resp = await fetch('/api/suggestions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ movie_id: props.movie.id }),
|
||
})
|
||
if (!resp.ok) throw new Error()
|
||
vorschlagen.value = { zustand: 'fertig' }
|
||
emit('suggested')
|
||
} catch {
|
||
vorschlagen.value = { zustand: 'fehler' }
|
||
}
|
||
}
|
||
|
||
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'))
|
||
)
|
||
|
||
// Ein angeklicktes Bild legt sich in voller Größe über die Ansicht
|
||
const grossesBild = ref(null)
|
||
|
||
function bildZeigen(index) {
|
||
grossesBild.value = backdropUrl((props.movie.images || [])[index], 'original')
|
||
}
|
||
const trailer = computed(() =>
|
||
props.movie.trailer_key
|
||
? `https://www.youtube-nocookie.com/embed/${props.movie.trailer_key}`
|
||
: null
|
||
)
|
||
const runtime = computed(() =>
|
||
props.movie.runtime ? `${props.movie.runtime} Min.` : null
|
||
)
|
||
|
||
function onKey(event) {
|
||
if (event.key !== 'Escape') return
|
||
if (grossesBild.value) grossesBild.value = null
|
||
else emit('close')
|
||
}
|
||
|
||
function openPart(part) {
|
||
emit('open', part)
|
||
// sonst startet der nächste Teil dort, wo man gerade gescrollt hat
|
||
document.querySelector('.backdrop')?.scrollTo({ top: 0 })
|
||
}
|
||
|
||
onMounted(() => {
|
||
document.addEventListener('keydown', onKey)
|
||
document.body.style.overflow = 'hidden'
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('keydown', onKey)
|
||
document.body.style.overflow = ''
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="backdrop" @click.self="$emit('close')">
|
||
<article class="sheet">
|
||
<button class="close" type="button" aria-label="Schließen" @click="$emit('close')">
|
||
×
|
||
</button>
|
||
|
||
<header>
|
||
<img v-if="poster" class="poster" :src="poster" :alt="movie.title" />
|
||
<div class="head-text">
|
||
<h2>{{ movie.title }}</h2>
|
||
<p v-if="movie.original_title !== movie.title" class="original">
|
||
{{ movie.original_title }}
|
||
</p>
|
||
<p class="meta">
|
||
<span v-if="movie.year">{{ movie.year }}</span>
|
||
<span v-if="runtime">{{ runtime }}</span>
|
||
<span v-if="movie.vote_average">
|
||
★ {{ movie.vote_average.toFixed(1) }} ({{ movie.vote_count }})
|
||
</span>
|
||
</p>
|
||
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
|
||
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
|
||
<div class="spalte-links">
|
||
<a
|
||
v-if="movie.on_netflix"
|
||
class="netflix"
|
||
:href="movie.netflix_url"
|
||
target="_blank"
|
||
rel="noopener"
|
||
>
|
||
Zu Netflix
|
||
</a>
|
||
<a
|
||
v-if="movie.imdb_id"
|
||
class="imdb"
|
||
:href="`https://www.imdb.com/title/${movie.imdb_id}/`"
|
||
target="_blank"
|
||
rel="noopener"
|
||
>
|
||
IMDb
|
||
</a>
|
||
</div>
|
||
|
||
<div v-if="!host" class="vorschlag">
|
||
<button type="button" class="merken" :class="{ an: gemerkt }" @click="merkenUmschalten">
|
||
{{ gemerkt ? 'Gemerkt' : 'Merken' }}
|
||
</button>
|
||
<template v-if="schonVorgeschlagen || vorschlagen.zustand === 'fertig'">
|
||
<span class="ok">Steht auf dem Rad.</span>
|
||
<button
|
||
type="button"
|
||
:disabled="vorschlagen.zustand === 'laeuft'"
|
||
@click="zuruecknehmen"
|
||
>
|
||
Wieder runternehmen
|
||
</button>
|
||
</template>
|
||
<template v-else>
|
||
<span v-if="meinVorschlag" class="hinweis">
|
||
Du hast „{{ meinVorschlag.title }}“ vorgeschlagen. Ein neuer
|
||
Vorschlag ersetzt ihn.
|
||
</span>
|
||
<button
|
||
type="button"
|
||
:disabled="vorschlagen.zustand === 'laeuft'"
|
||
@click="abschicken"
|
||
>
|
||
Vorschlagen
|
||
</button>
|
||
<span v-if="vorschlagen.zustand === 'fehler'" class="fehler">
|
||
Hat nicht geklappt.
|
||
</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>
|
||
|
||
<div class="links">
|
||
<section v-if="series.length">
|
||
<h3>{{ movie.collection_name || 'Die Reihe' }}</h3>
|
||
<div class="series">
|
||
<button
|
||
v-for="part in series"
|
||
:key="part.id"
|
||
class="part"
|
||
:class="{ current: part.id === movie.id }"
|
||
type="button"
|
||
@click="openPart(part)"
|
||
>
|
||
<img
|
||
v-if="posterUrl(part.poster_path, 'w154')"
|
||
:src="posterUrl(part.poster_path, 'w154')"
|
||
:alt="part.title"
|
||
loading="lazy"
|
||
/>
|
||
<span v-else class="no-poster">Kein Cover</span>
|
||
<span class="part-title">{{ part.title }}</span>
|
||
<span class="part-year">{{ part.year }}</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="movie.overview">
|
||
<h3>Handlung</h3>
|
||
<p class="overview">{{ movie.overview }}</p>
|
||
</section>
|
||
</div>
|
||
|
||
<section v-if="trailer" class="rechts">
|
||
<div class="video">
|
||
<iframe
|
||
:src="trailer"
|
||
:title="`Trailer zu ${movie.title}`"
|
||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture"
|
||
allowfullscreen
|
||
></iframe>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="shots.length" class="shots-section">
|
||
<div class="shots">
|
||
<button
|
||
v-for="(shot, index) in shots"
|
||
:key="shot"
|
||
type="button"
|
||
class="shot"
|
||
@click="bildZeigen(index)"
|
||
>
|
||
<img
|
||
:src="shot"
|
||
:alt="`${movie.title} – Szenenbild ${index + 1}`"
|
||
loading="lazy"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</article>
|
||
|
||
<div v-if="grossesBild" class="lupe" @click="grossesBild = null">
|
||
<img :src="grossesBild" :alt="movie.title" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 20;
|
||
display: flex;
|
||
justify-content: center;
|
||
overflow-y: auto;
|
||
padding: 2rem 1rem;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
.sheet {
|
||
position: relative;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||
gap: 0 1.75rem;
|
||
align-content: start;
|
||
width: min(1700px, 100%);
|
||
height: fit-content;
|
||
padding: 1.75rem;
|
||
background: var(--bg-soft);
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
}
|
||
|
||
/* Links Cover, Text und Knöpfe, rechts der Trailer, darunter die Bilder */
|
||
.sheet > header,
|
||
.sheet > .spalte-links {
|
||
grid-column: 1;
|
||
}
|
||
|
||
.sheet > .rechts {
|
||
grid-column: 2;
|
||
grid-row: 1 / span 2;
|
||
}
|
||
|
||
.sheet > .shots-section {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.sheet {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.sheet > .rechts,
|
||
.sheet > .shots-section,
|
||
.sheet > header,
|
||
.sheet > .spalte-links {
|
||
grid-column: 1;
|
||
grid-row: auto;
|
||
}
|
||
}
|
||
|
||
.close {
|
||
position: absolute;
|
||
top: 0.5rem;
|
||
right: 0.85rem;
|
||
color: var(--muted);
|
||
font-size: 2rem;
|
||
line-height: 1;
|
||
}
|
||
|
||
.close:hover {
|
||
color: var(--text);
|
||
}
|
||
|
||
header {
|
||
display: flex;
|
||
gap: 1.25rem;
|
||
margin-bottom: 1.75rem;
|
||
}
|
||
|
||
.poster {
|
||
flex-shrink: 0;
|
||
align-self: flex-start;
|
||
width: 130px;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.head-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
h2 {
|
||
padding-right: 2rem;
|
||
font-size: 1.6rem;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.original {
|
||
color: var(--muted);
|
||
font-size: 0.9rem;
|
||
font-style: italic;
|
||
}
|
||
|
||
.meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.85rem;
|
||
color: var(--muted);
|
||
font-size: 0.88rem;
|
||
}
|
||
|
||
.genres {
|
||
color: var(--muted);
|
||
font-size: 0.82rem;
|
||
}
|
||
|
||
.teaser {
|
||
color: #cfcfda;
|
||
font-size: 0.98rem;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.links {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-top: 0.35rem;
|
||
}
|
||
|
||
.netflix,
|
||
.imdb {
|
||
padding: 0.55rem 1.1rem;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
text-decoration: none;
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.netflix {
|
||
background: var(--accent);
|
||
}
|
||
|
||
.imdb {
|
||
color: #111;
|
||
background: #f5c518;
|
||
}
|
||
|
||
.vorschlag {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.4rem;
|
||
align-items: center;
|
||
margin-top: 0.75rem;
|
||
padding-top: 0.75rem;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
|
||
.vorschlag button {
|
||
padding: 0.45rem 1rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
background: var(--bg);
|
||
border: 1px solid var(--line);
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.vorschlag button:hover:not(:disabled) {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.vorschlag button:disabled {
|
||
color: var(--muted);
|
||
cursor: default;
|
||
}
|
||
|
||
.merken {
|
||
padding: 0.45rem 1rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
background: var(--bg);
|
||
border: 1px solid var(--line);
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.merken:hover {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.merken.an {
|
||
color: #6bbf7a;
|
||
border-color: #6bbf7a;
|
||
}
|
||
|
||
.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);
|
||
font-size: 0.8rem;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.ok {
|
||
color: #6bbf7a;
|
||
font-size: 0.88rem;
|
||
}
|
||
|
||
.fehler {
|
||
color: var(--accent);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.netflix:hover,
|
||
.imdb:hover {
|
||
filter: brightness(1.15);
|
||
}
|
||
|
||
section {
|
||
margin-bottom: 1.75rem;
|
||
}
|
||
|
||
h3 {
|
||
margin-bottom: 0.7rem;
|
||
color: var(--muted);
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.09em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.overview {
|
||
color: #c4c4d0;
|
||
font-size: 0.95rem;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.series {
|
||
display: flex;
|
||
gap: 0.8rem;
|
||
overflow-x: auto;
|
||
padding-bottom: 0.4rem;
|
||
}
|
||
|
||
.part {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
gap: 0.3rem;
|
||
width: 110px;
|
||
text-align: left;
|
||
}
|
||
|
||
.part img,
|
||
.part .no-poster {
|
||
width: 110px;
|
||
aspect-ratio: 2 / 3;
|
||
object-fit: cover;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.part .no-poster {
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--muted);
|
||
font-size: 0.72rem;
|
||
}
|
||
|
||
.part:hover img,
|
||
.part:focus-visible img {
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.part.current img {
|
||
border-color: var(--accent);
|
||
border-width: 2px;
|
||
}
|
||
|
||
.part.current .part-title {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.part-title {
|
||
font-size: 0.82rem;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.part-year {
|
||
color: var(--muted);
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.shots {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||
gap: 0.6rem;
|
||
}
|
||
|
||
.shots img {
|
||
width: 100%;
|
||
aspect-ratio: 16 / 9;
|
||
object-fit: cover;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.video {
|
||
position: relative;
|
||
aspect-ratio: 16 / 9;
|
||
}
|
||
|
||
.video iframe {
|
||
width: 100%;
|
||
height: 100%;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
@media (max-width: 620px) {
|
||
header {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.poster {
|
||
width: 120px;
|
||
}
|
||
|
||
.shots {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.shot {
|
||
display: block;
|
||
padding: 0;
|
||
cursor: zoom-in;
|
||
}
|
||
|
||
.shot:hover img {
|
||
outline: 2px solid var(--accent);
|
||
}
|
||
|
||
.lupe {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 40;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 2rem;
|
||
cursor: zoom-out;
|
||
background: rgba(0, 0, 0, 0.9);
|
||
}
|
||
|
||
.lupe img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
border-radius: 8px;
|
||
}
|
||
</style>
|