This commit is contained in:
team3
2026-08-12 19:30:41 +02:00
commit 38d5a8cfae
30 changed files with 4157 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
<script setup>
import { computed } from 'vue'
import { posterUrl } from '../images'
const props = defineProps({
movie: { type: Object, required: true },
})
defineEmits(['open'])
const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
const rating = computed(() =>
props.movie.vote_average ? props.movie.vote_average.toFixed(1) : null
)
</script>
<template>
<button class="card" type="button" @click="$emit('open', movie)">
<div class="poster">
<img v-if="poster" :src="poster" :alt="movie.title" loading="lazy" />
<span v-else class="placeholder">Kein Cover</span>
<span v-if="rating" class="rating">{{ rating }}</span>
</div>
<div class="body">
<h2>{{ movie.title }}</h2>
<p class="meta">{{ movie.year || 'Jahr unbekannt' }}</p>
<p class="teaser">{{ movie.teaser || movie.overview }}</p>
</div>
</button>
</template>
<style scoped>
.card {
display: flex;
flex-direction: column;
overflow: hidden;
text-align: left;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
transition: transform 0.15s ease, border-color 0.15s ease;
}
.card:hover,
.card:focus-visible {
transform: translateY(-4px);
border-color: var(--accent);
}
.poster {
position: relative;
aspect-ratio: 2 / 3;
background: #1c1c22;
}
.poster img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
display: grid;
place-items: center;
height: 100%;
color: var(--muted);
font-size: 0.85rem;
}
.rating {
position: absolute;
right: 0.5rem;
bottom: 0.5rem;
padding: 0.15rem 0.45rem;
font-size: 0.8rem;
font-weight: 600;
background: rgba(0, 0, 0, 0.75);
border-radius: 6px;
}
.body {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.85rem;
}
h2 {
font-size: 1rem;
line-height: 1.3;
}
.meta {
color: var(--muted);
font-size: 0.8rem;
}
.teaser {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
line-clamp: 4;
overflow: hidden;
color: #b9b9c6;
font-size: 0.85rem;
line-height: 1.45;
}
</style>

View File

@@ -0,0 +1,383 @@
<script setup>
import { computed, onMounted, onUnmounted } from 'vue'
import { backdropUrl, posterUrl } from '../images'
const props = defineProps({
movie: { type: Object, required: true },
series: { type: Array, default: () => [] },
})
const emit = defineEmits(['close', 'open'])
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>
</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;
}
.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>

View File

@@ -0,0 +1,200 @@
<script setup>
import { computed, ref } from 'vue'
import { posterUrl } from '../images'
const props = defineProps({
parts: { type: Array, required: true },
// Die Karte öffnet mit dem stärksten Teil, nicht mit dem ersten
start: { type: Number, default: 0 },
})
defineEmits(['open'])
const index = ref(props.start)
const current = computed(() => props.parts[index.value])
const poster = computed(() => posterUrl(current.value.poster_path, 'w342'))
const rating = computed(() =>
current.value.vote_average ? current.value.vote_average.toFixed(1) : null
)
function step(delta) {
const count = props.parts.length
index.value = (index.value + delta + count) % count
}
</script>
<template>
<article class="card">
<button class="poster" type="button" @click="$emit('open', current)">
<img v-if="poster" :src="poster" :alt="current.title" loading="lazy" />
<span v-else class="placeholder">Kein Cover</span>
<span v-if="rating" class="rating">{{ rating }}</span>
</button>
<div class="nav">
<button type="button" aria-label="Vorheriger Teil" @click="step(-1)">‹</button>
<div class="dots">
<button
v-for="(part, i) in parts"
:key="part.id"
type="button"
class="dot"
:class="{ on: i === index }"
:title="part.title"
@click="index = i"
></button>
</div>
<button type="button" aria-label="Nächster Teil" @click="step(1)">›</button>
</div>
<div class="body">
<p class="counter">Teil {{ index + 1 }} von {{ parts.length }}</p>
<h2 @click="$emit('open', current)">{{ current.title }}</h2>
<p class="meta">{{ current.year || 'Jahr unbekannt' }}</p>
<p class="teaser">{{ current.teaser || current.overview }}</p>
</div>
</article>
</template>
<style scoped>
.card {
display: flex;
flex-direction: column;
overflow: hidden;
text-align: left;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
transition: border-color 0.15s ease;
}
.card:hover {
border-color: var(--accent);
}
.poster {
position: relative;
display: block;
padding: 0;
aspect-ratio: 2 / 3;
background: #1c1c22;
}
.poster img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
display: grid;
place-items: center;
height: 100%;
color: var(--muted);
font-size: 0.85rem;
}
.rating {
position: absolute;
right: 0.5rem;
bottom: 0.5rem;
padding: 0.15rem 0.45rem;
font-size: 0.8rem;
font-weight: 600;
background: rgba(0, 0, 0, 0.75);
border-radius: 6px;
}
.body {
display: flex;
flex-direction: column;
flex: 1;
gap: 0.35rem;
padding: 0.85rem;
}
.counter {
color: var(--accent);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
h2 {
font-size: 1rem;
line-height: 1.3;
cursor: pointer;
}
h2:hover {
color: var(--accent);
}
.meta {
color: var(--muted);
font-size: 0.8rem;
}
.teaser {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
line-clamp: 4;
overflow: hidden;
flex: 1;
color: #b9b9c6;
font-size: 0.85rem;
line-height: 1.45;
}
.nav {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--line);
}
.nav > button {
width: 1.7rem;
height: 1.7rem;
flex-shrink: 0;
color: var(--muted);
font-size: 1.2rem;
line-height: 1;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 6px;
}
.nav > button:hover {
color: var(--text);
border-color: var(--accent);
}
.dots {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.28rem;
flex: 1;
}
.dot {
width: 0.42rem;
height: 0.42rem;
padding: 0;
background: var(--line);
border-radius: 50%;
}
.dot.on {
background: var(--accent);
}
.dot:hover {
background: var(--muted);
}
</style>