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

12
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

17
frontend/package.json Normal file
View 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
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 AZ</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>

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>

9
frontend/src/images.js Normal file
View 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
View 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
View 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
View 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',
},
},
})