555 lines
12 KiB
Vue
555 lines
12 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 },
|
||
})
|
||
|
||
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>
|