init
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user