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

337
frontend/src/App.vue Normal file
View 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>