Files
horror/frontend/src/components/MovieDetail.vue
2026-08-31 11:54:20 +02:00

555 lines
12 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, 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 },
})
const emit = defineEmits(['close', 'open', 'suggested', 'watched'])
const vorschlagen = ref({ zustand: 'bereit', name: '' })
const schonVorgeschlagen = computed(
() => props.meinVorschlag?.movie_id === props.movie.id
)
async function abschicken() {
vorschlagen.value = { ...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,
name: vorschlagen.value.name || null,
}),
})
if (!resp.ok) throw new Error()
vorschlagen.value = { ...vorschlagen.value, zustand: 'fertig' }
emit('suggested')
} catch {
vorschlagen.value = { ...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'))
)
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') 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="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 class="vorschlag">
<template v-if="vorschlagen.zustand === 'fertig'">
<span class="ok">Für den Filmabend vorgeschlagen.</span>
</template>
<template v-else-if="schonVorgeschlagen">
<span class="ok">Dieser Film steht schon auf dem Rad.</span>
</template>
<template v-else>
<span v-if="meinVorschlag" class="hinweis">
Du hast „{{ meinVorschlag.title }}“ vorgeschlagen. Ein neuer
Vorschlag ersetzt ihn.
</span>
<input
v-model="vorschlagen.name"
type="text"
placeholder="Dein Name (optional)"
maxlength="40"
/>
<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>
<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>
<section v-if="trailer">
<h3>Trailer</h3>
<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>
<p v-else class="no-trailer">Für diesen Film liegt kein Trailer vor.</p>
<section v-if="shots.length">
<h3>Bilder</h3>
<div class="shots">
<img
v-for="(shot, index) in shots"
:key="shot"
:src="shot"
:alt="`${movie.title} – Szenenbild ${index + 1}`"
loading="lazy"
/>
</div>
</section>
</article>
</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;
width: min(880px, 100%);
height: fit-content;
padding: 1.75rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 14px;
}
.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;
width: 160px;
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 input {
width: 11rem;
padding: 0.45rem 0.7rem;
font-size: 0.85rem;
}
.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;
}
.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(2, 1fr);
gap: 0.7rem;
}
.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;
}
.no-trailer {
color: var(--muted);
font-size: 0.88rem;
}
@media (max-width: 620px) {
header {
flex-direction: column;
}
.poster {
width: 120px;
}
.shots {
grid-template-columns: 1fr;
}
}
</style>