init
This commit is contained in:
109
frontend/src/components/MovieCard.vue
Normal file
109
frontend/src/components/MovieCard.vue
Normal 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>
|
||||
383
frontend/src/components/MovieDetail.vue
Normal file
383
frontend/src/components/MovieDetail.vue
Normal 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>
|
||||
200
frontend/src/components/SeriesCard.vue
Normal file
200
frontend/src/components/SeriesCard.vue
Normal 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>
|
||||
Reference in New Issue
Block a user