Benutzer, Sammlungen, Wünsche und zwei Kartenansichten

Die Runde ist jetzt benannt. Der Host pflegt die Namensliste im neuen Reiter
"Verwaltung", alle anderen wählen sich oben rechts einen aus. Der Name landet
serverseitig an Vorschlägen und Wertungen, statt aus einem Textfeld zu
kommen, in das jeder schreiben konnte, was er wollte.

Dazu zwei Sammelstellen, beide nach Person gruppiert und für alle sichtbar,
aber nur selbst bearbeitbar:
- "Sammlungen": Filme, die man sehen möchte. Getrennt vom Rad-Vorschlag,
  von dem es weiterhin einen je Person gibt.
- "Wünsche": was der Seite noch fehlt. Der Host darf hier alle Einträge
  ändern und löschen.

Die Filmliste hat jetzt zwei Ansichten. Kompakt ist die gewohnte Karte,
Details stellt Cover, Text und vier Szenenbilder nebeneinander — eine Karte
so breit wie vier kompakte oder die halbe Reihe, je nachdem was zuerst
zutrifft. Reihen und Einzelfilme sind dabei exakt gleich hoch; die
Blätterleiste liegt dafür über dem Cover statt darunter.

Die Filmansicht nutzt die Breite: links Cover, Text und Knöpfe, rechts der
Trailer, darunter die Bilder. Ein Klick zeigt ein Bild in voller Größe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XVnRop5c3or3B8xE2hpgZ
This commit is contained in:
team3
2026-09-03 19:30:48 +02:00
parent 8bd6a4e6b1
commit 9840230250
15 changed files with 1446 additions and 117 deletions

View File

@@ -7,11 +7,11 @@ const props = defineProps({
series: { type: Array, default: () => [] },
meinVorschlag: { type: Object, default: null },
host: { type: Boolean, default: false },
name: { type: String, default: '' },
gesehen: { type: Boolean, default: false },
gemerkt: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'open', 'suggested', 'watched'])
const emit = defineEmits(['close', 'open', 'suggested', 'watched', 'gemerkt'])
const vorschlagen = ref({ zustand: 'bereit' })
@@ -19,6 +19,16 @@ const schonVorgeschlagen = computed(
() => props.meinVorschlag?.movie_id === props.movie.id
)
async function merkenUmschalten() {
const weg = props.gemerkt
await fetch(weg ? `/api/wishlist/${props.movie.id}` : '/api/wishlist', {
method: weg ? 'DELETE' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: weg ? undefined : JSON.stringify({ movie_id: props.movie.id }),
})
emit('gemerkt')
}
async function zuruecknehmen() {
vorschlagen.value = { zustand: 'laeuft' }
try {
@@ -37,11 +47,7 @@ async function abschicken() {
const resp = await fetch('/api/suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
movie_id: props.movie.id,
// Der Name steht oben rechts, einmal für alles
name: props.name || null,
}),
body: JSON.stringify({ movie_id: props.movie.id }),
})
if (!resp.ok) throw new Error()
vorschlagen.value = { zustand: 'fertig' }
@@ -73,6 +79,13 @@ const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
const shots = computed(() =>
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
)
// Ein angeklicktes Bild legt sich in voller Größe über die Ansicht
const grossesBild = ref(null)
function bildZeigen(index) {
grossesBild.value = backdropUrl((props.movie.images || [])[index], 'original')
}
const trailer = computed(() =>
props.movie.trailer_key
? `https://www.youtube-nocookie.com/embed/${props.movie.trailer_key}`
@@ -83,7 +96,9 @@ const runtime = computed(() =>
)
function onKey(event) {
if (event.key === 'Escape') emit('close')
if (event.key !== 'Escape') return
if (grossesBild.value) grossesBild.value = null
else emit('close')
}
function openPart(part) {
@@ -126,7 +141,7 @@ onUnmounted(() => {
</p>
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
<div class="links">
<div class="spalte-links">
<a
v-if="movie.on_netflix"
class="netflix"
@@ -147,7 +162,10 @@ onUnmounted(() => {
</a>
</div>
<div class="vorschlag">
<div v-if="!host" class="vorschlag">
<button type="button" class="merken" :class="{ an: gemerkt }" @click="merkenUmschalten">
{{ gemerkt ? 'Gemerkt' : 'Merken' }}
</button>
<template v-if="schonVorgeschlagen || vorschlagen.zustand === 'fertig'">
<span class="ok">Steht auf dem Rad.</span>
<button
@@ -195,6 +213,7 @@ onUnmounted(() => {
</div>
</header>
<div class="links">
<section v-if="series.length">
<h3>{{ movie.collection_name || 'Die Reihe' }}</h3>
<div class="series">
@@ -223,9 +242,9 @@ onUnmounted(() => {
<h3>Handlung</h3>
<p class="overview">{{ movie.overview }}</p>
</section>
</div>
<section v-if="trailer">
<h3>Trailer</h3>
<section v-if="trailer" class="rechts">
<div class="video">
<iframe
:src="trailer"
@@ -235,21 +254,29 @@ onUnmounted(() => {
></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>
<section v-if="shots.length" class="shots-section">
<div class="shots">
<img
<button
v-for="(shot, index) in shots"
:key="shot"
:src="shot"
:alt="`${movie.title} – Szenenbild ${index + 1}`"
loading="lazy"
/>
type="button"
class="shot"
@click="bildZeigen(index)"
>
<img
:src="shot"
:alt="`${movie.title} – Szenenbild ${index + 1}`"
loading="lazy"
/>
</button>
</div>
</section>
</article>
<div v-if="grossesBild" class="lupe" @click="grossesBild = null">
<img :src="grossesBild" :alt="movie.title" />
</div>
</div>
</template>
@@ -268,7 +295,11 @@ onUnmounted(() => {
.sheet {
position: relative;
width: min(880px, 100%);
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0 1.75rem;
align-content: start;
width: min(1700px, 100%);
height: fit-content;
padding: 1.75rem;
background: var(--bg-soft);
@@ -276,6 +307,35 @@ onUnmounted(() => {
border-radius: 14px;
}
/* Links Cover, Text und Knöpfe, rechts der Trailer, darunter die Bilder */
.sheet > header,
.sheet > .spalte-links {
grid-column: 1;
}
.sheet > .rechts {
grid-column: 2;
grid-row: 1 / span 2;
}
.sheet > .shots-section {
grid-column: 1 / -1;
}
@media (max-width: 1100px) {
.sheet {
grid-template-columns: 1fr;
}
.sheet > .rechts,
.sheet > .shots-section,
.sheet > header,
.sheet > .spalte-links {
grid-column: 1;
grid-row: auto;
}
}
.close {
position: absolute;
top: 0.5rem;
@@ -297,7 +357,8 @@ header {
.poster {
flex-shrink: 0;
width: 160px;
align-self: flex-start;
width: 130px;
border-radius: 10px;
}
@@ -391,6 +452,24 @@ h2 {
cursor: default;
}
.merken {
padding: 0.45rem 1rem;
font-size: 0.85rem;
font-weight: 600;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 7px;
}
.merken:hover {
border-color: var(--accent);
}
.merken.an {
color: #6bbf7a;
border-color: #6bbf7a;
}
.gesehen {
display: flex;
flex-wrap: wrap;
@@ -516,8 +595,8 @@ h3 {
.shots {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.7rem;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.6rem;
}
.shots img {
@@ -539,11 +618,6 @@ h3 {
border-radius: 8px;
}
.no-trailer {
color: var(--muted);
font-size: 0.88rem;
}
@media (max-width: 620px) {
header {
flex-direction: column;
@@ -557,4 +631,31 @@ h3 {
grid-template-columns: 1fr;
}
}
.shot {
display: block;
padding: 0;
cursor: zoom-in;
}
.shot:hover img {
outline: 2px solid var(--accent);
}
.lupe {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 2rem;
cursor: zoom-out;
background: rgba(0, 0, 0, 0.9);
}
.lupe img {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
}
</style>