init
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Horror auf Netflix</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1380
frontend/package-lock.json
generated
Normal file
1380
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
frontend/package.json
Normal file
17
frontend/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "horror-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
337
frontend/src/App.vue
Normal file
337
frontend/src/App.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import MovieCard from './components/MovieCard.vue'
|
||||
import MovieDetail from './components/MovieDetail.vue'
|
||||
import SeriesCard from './components/SeriesCard.vue'
|
||||
|
||||
const movies = ref([])
|
||||
const status = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const search = ref('')
|
||||
const sort = ref('rating')
|
||||
const selected = ref(null)
|
||||
const activeTab = ref('netflix')
|
||||
|
||||
const netflixMovies = computed(() => movies.value.filter((m) => m.on_netflix))
|
||||
const canonMovies = computed(() =>
|
||||
movies.value.filter((m) => m.canon_group !== null)
|
||||
)
|
||||
|
||||
// Ein Kanon-Eintrag ist ein Film oder eine ganze Reihe. Reihen bleiben
|
||||
// zusammen, damit die Karte durch ihre Teile blättern kann.
|
||||
const canonGroups = computed(() => {
|
||||
const byGroup = new Map()
|
||||
for (const movie of canonMovies.value) {
|
||||
if (!byGroup.has(movie.canon_group)) byGroup.set(movie.canon_group, [])
|
||||
byGroup.get(movie.canon_group).push(movie)
|
||||
}
|
||||
return [...byGroup.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([group, parts]) => {
|
||||
// Erscheinungsreihenfolge, nicht die der Vorlage: die gruppiert das
|
||||
// Conjuring-Universum nach Sub-Reihen statt nach Jahr
|
||||
const sorted = parts.sort(
|
||||
(a, b) =>
|
||||
(a.release_date || '').localeCompare(b.release_date || '') ||
|
||||
a.canon_pos - b.canon_pos
|
||||
)
|
||||
// Die Karte startet beim stärksten Teil, und danach sortiert auch die
|
||||
// Kachelreihenfolge — sonst entscheidet ein schwacher Auftakt über
|
||||
// den Platz der ganzen Reihe
|
||||
let best = 0
|
||||
sorted.forEach((part, i) => {
|
||||
if (score(part) > score(sorted[best])) best = i
|
||||
})
|
||||
return { group, parts: sorted, best }
|
||||
})
|
||||
})
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
||||
{ id: 'canon', label: 'Kanon', count: canonGroups.value.length },
|
||||
])
|
||||
|
||||
// Gewichtete Note: ohne sie stehen Filme mit 10.0 aus zwei Stimmen ganz oben.
|
||||
// Je weniger Stimmen, desto stärker zieht der Katalogschnitt die Note zu sich.
|
||||
const MIN_VOTES = 150
|
||||
|
||||
const average = computed(() => {
|
||||
const rated = movies.value.filter((m) => m.vote_count)
|
||||
if (!rated.length) return 0
|
||||
return rated.reduce((sum, m) => sum + m.vote_average, 0) / rated.length
|
||||
})
|
||||
|
||||
function score(movie) {
|
||||
const votes = movie.vote_count || 0
|
||||
return (
|
||||
(votes / (votes + MIN_VOTES)) * (movie.vote_average || 0) +
|
||||
(MIN_VOTES / (votes + MIN_VOTES)) * average.value
|
||||
)
|
||||
}
|
||||
|
||||
const SORTS = {
|
||||
rating: (a, b) => score(b) - score(a),
|
||||
newest: (a, b) => (b.year || 0) - (a.year || 0),
|
||||
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
|
||||
title: (a, b) => a.title.localeCompare(b.title, 'de'),
|
||||
}
|
||||
|
||||
function matches(movie, term) {
|
||||
return (
|
||||
movie.title.toLowerCase().includes(term) ||
|
||||
(movie.original_title || '').toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
|
||||
const visible = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
const list = term
|
||||
? netflixMovies.value.filter((m) => matches(m, term))
|
||||
: [...netflixMovies.value]
|
||||
return list.sort(SORTS[sort.value])
|
||||
})
|
||||
|
||||
// Im Kanon greift die Suche auf die ganze Reihe: wer "Saw" sucht, will den
|
||||
// Eintrag sehen, egal welcher Teil gerade vorne liegt.
|
||||
const visibleCanon = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
const list = term
|
||||
? canonGroups.value.filter((g) => g.parts.some((m) => matches(m, term)))
|
||||
: [...canonGroups.value]
|
||||
return list.sort((a, b) =>
|
||||
SORTS[sort.value](a.parts[a.best], b.parts[b.best])
|
||||
)
|
||||
})
|
||||
|
||||
// Die Reihe zum geöffneten Film. Im Kanon ist das die Gruppe der Vorlage,
|
||||
// sonst TMDBs Collection — dort aber nur, was auch auf Netflix liegt.
|
||||
const series = computed(() => {
|
||||
const movie = selected.value
|
||||
if (!movie) return []
|
||||
|
||||
const group =
|
||||
movie.canon_group !== null
|
||||
? canonMovies.value.filter((m) => m.canon_group === movie.canon_group)
|
||||
: movie.collection_id
|
||||
? netflixMovies.value.filter((m) => m.collection_id === movie.collection_id)
|
||||
: []
|
||||
|
||||
if (group.length < 2) return []
|
||||
return [...group].sort((a, b) =>
|
||||
(a.release_date || '').localeCompare(b.release_date || '')
|
||||
)
|
||||
})
|
||||
|
||||
const currentCount = computed(() =>
|
||||
activeTab.value === 'netflix' ? visible.value.length : visibleCanon.value.length
|
||||
)
|
||||
|
||||
const lastSync = computed(() => {
|
||||
if (!status.value?.last_sync) return null
|
||||
return new Date(status.value.last_sync).toLocaleString('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [moviesRes, statusRes] = await Promise.all([
|
||||
fetch('/api/movies'),
|
||||
fetch('/api/status'),
|
||||
])
|
||||
if (!moviesRes.ok) throw new Error('Filme konnten nicht geladen werden')
|
||||
movies.value = await moviesRes.json()
|
||||
status.value = await statusRes.json()
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<h1>Horrorfilme</h1>
|
||||
<p v-if="lastSync" class="sync">Stand {{ lastSync }}</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input v-model="search" type="search" placeholder="Titel suchen…" />
|
||||
<select v-model="sort">
|
||||
<option value="rating">Beste Bewertung</option>
|
||||
<option value="newest">Neueste zuerst</option>
|
||||
<option value="oldest">Älteste zuerst</option>
|
||||
<option value="title">Titel A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
:class="{ active: tab.id === activeTab }"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="count">{{ tab.count }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p v-if="loading" class="note">Lade Katalog…</p>
|
||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||
<p v-else-if="!movies.length" class="note">
|
||||
Noch keine Filme in der Datenbank. Der erste Sync läuft eventuell noch.
|
||||
</p>
|
||||
<p v-else-if="!currentCount" class="note">Nichts gefunden für „{{ search }}“.</p>
|
||||
|
||||
<div v-if="activeTab === 'netflix'" class="grid">
|
||||
<MovieCard
|
||||
v-for="movie in visible"
|
||||
:key="movie.id"
|
||||
:movie="movie"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid">
|
||||
<template v-for="entry in visibleCanon" :key="entry.group">
|
||||
<MovieCard
|
||||
v-if="entry.parts.length === 1"
|
||||
:movie="entry.parts[0]"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
<SeriesCard
|
||||
v-else
|
||||
:parts="entry.parts"
|
||||
:start="entry.best"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<MovieDetail
|
||||
v-if="selected"
|
||||
:movie="selected"
|
||||
:series="series"
|
||||
@close="selected = null"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
|
||||
<footer>
|
||||
Daten von
|
||||
<a href="https://www.themoviedb.org" target="_blank" rel="noopener">TMDB</a>
|
||||
und JustWatch. This product uses the TMDB API but is not endorsed or certified
|
||||
by TMDB.
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.25rem 3rem;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.6rem 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tabs .count {
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.sync {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Ohne min-width schiebt das Suchfeld auf schmalen Fenstern die Seite breit */
|
||||
.controls input {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 2.5rem 0;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note.error {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.25rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
9
frontend/src/images.js
Normal file
9
frontend/src/images.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const BASE = 'https://image.tmdb.org/t/p'
|
||||
|
||||
export function posterUrl(path, size = 'w500') {
|
||||
return path ? `${BASE}/${size}${path}` : null
|
||||
}
|
||||
|
||||
export function backdropUrl(path, size = 'w780') {
|
||||
return path ? `${BASE}/${size}${path}` : null
|
||||
}
|
||||
5
frontend/src/main.js
Normal file
5
frontend/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
50
frontend/src/style.css
Normal file
50
frontend/src/style.css
Normal file
@@ -0,0 +1,50 @@
|
||||
:root {
|
||||
--bg: #0a0a0c;
|
||||
--bg-soft: #141419;
|
||||
--line: #26262e;
|
||||
--text: #ececf1;
|
||||
--muted: #8b8b99;
|
||||
--accent: #e50914;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.85rem;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: '../backend/static',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user