This commit is contained in:
team3
2026-09-17 15:40:28 +02:00
parent a1684cf54f
commit ea54a86db4
30 changed files with 3680 additions and 989 deletions

View File

@@ -4,6 +4,7 @@ import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.vue'
import AehnlichTab from './components/AehnlichTab.vue'
import MerklisteTab from './components/MerklisteTab.vue'
import SammlungFilter from './components/SammlungFilter.vue'
import MovieDetail from './components/MovieDetail.vue'
import NamensWahl from './components/NamensWahl.vue'
import VerwaltungTab from './components/VerwaltungTab.vue'
@@ -29,6 +30,11 @@ const {
wuensche, ladeWuensche,
} = useMeins({ host })
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
// für Zuschauer, die noch nicht in der Runde stehen. Steht vor tabs: die
// Reiterliste fragt danach.
const angemeldet = computed(() => !!ich.value)
const {
movies, loading, error, load,
netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS,
@@ -84,16 +90,42 @@ const tabs = computed(() => [
{ id: 'stream', label: 'Stream', count: null },
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
...(host.value || angemeldet.value
? [{ id: 'verwaltung', label: 'Verwaltung', count: null }]
: []),
])
// Auf dem Handy passt die Filterleiste nicht neben die Karten — dort
// legt sie sich über einen Knopf über die Seite
const filterOffen = ref(false)
const mitFilter = computed(() =>
['netflix', 'canon', 'search', 'aehnlich'].includes(activeTab.value)
['netflix', 'canon', 'search', 'aehnlich', 'merkliste'].includes(activeTab.value)
)
// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler —
// dafür hat er eine eigene Leiste
const sammlungLeute = ref([])
const sammlungHorror = ref(null)
// Wer überhaupt etwas gesammelt hat, mit Anzahl. Die eigene zuerst.
const leuteMitSammlung = computed(() => {
const nach = new Map()
for (const eintrag of merkliste.value) {
const name = eintrag.name || 'Anonym'
if (!nach.has(name)) nach.set(name, { name, anzahl: 0, mine: eintrag.mine })
nach.get(name).anzahl += 1
}
return [...nach.values()].sort(
(a, b) => b.mine - a.mine || a.name.localeCompare(b.name, 'de')
)
})
// Fällt der offene Reiter weg — Hostmodus aus, Name abgewählt —, wäre
// sonst keiner markiert und die Seite bliebe leer
watch(tabs, (liste) => {
if (!liste.some((t) => t.id === activeTab.value)) activeTab.value = 'netflix'
})
// Passen die Reiter nicht in eine Zeile, soll der offene sichtbar sein
const reiter = ref(null)
@@ -105,10 +137,6 @@ watch(activeTab, async () => {
leiste.querySelector('.active')?.scrollIntoView({ inline: 'center', block: 'nearest' })
})
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
// für Zuschauer, die noch nicht in der Runde stehen
const angemeldet = computed(() => !!ich.value)
const selected = ref(null)
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
@@ -193,7 +221,12 @@ onMounted(load)
Filter
</button>
<span v-if="host" class="host">Host</span>
<NamensWahl :items="runde" :mine="ich" @waehlen="michSetzen" />
<NamensWahl
:items="runde"
:mine="ich"
:host="host"
:anmelden="michSetzen"
/>
</div>
</header>
@@ -256,15 +289,27 @@ onMounted(load)
@changed="ladeGesehen"
/>
<MerklisteTab
v-else-if="activeTab === 'merkliste'"
:angemeldet="angemeldet"
:gesehen-ids="gesehenIds"
:items="merkliste"
:ich="ich"
@open="selected = $event"
@changed="ladeMerkliste"
/>
<div v-else-if="activeTab === 'merkliste'" class="layout">
<MerklisteTab
:angemeldet="angemeldet"
:gesehen-ids="gesehenIds"
:items="merkliste"
:ich="ich"
:ansicht="ansicht"
:leute="sammlungLeute"
:horror="sammlungHorror"
@open="filmOeffnen"
@changed="ladeMerkliste"
/>
<SammlungFilter
v-model:gewaehlt="sammlungLeute"
v-model:horror="sammlungHorror"
v-model:ansicht="ansicht"
:class="{ offen: filterOffen }"
:leute="leuteMitSammlung"
/>
</div>
<WuenscheTab
v-else-if="activeTab === 'wuensche'"
@@ -275,7 +320,12 @@ onMounted(load)
@changed="ladeWuensche"
/>
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" />
<VerwaltungTab
v-else-if="activeTab === 'verwaltung'"
:items="runde"
:host="host"
:ich="ich"
/>
<StreamTab v-else-if="activeTab === 'stream'" />

View File

@@ -0,0 +1,631 @@
<script setup>
// Eine Doppelseite im Buch: ein Abend, ein Film. Links das Cover mit Titel,
// Datum, dem Urteil der Runde und den Sternen — die Momentaufnahme nach dem
// Abspann. Rechts das Gespräch danach: Rezensionen und Kommentare, mit
// eigenem Scrollen, das Cover bleibt stehen. Auf dem Handy liegen die
// Hälften untereinander.
import { computed, ref } from 'vue'
import { posterUrl } from '../images'
import { rendern } from '../markdown'
import Kommentar from './Kommentar.vue'
import MarkdownFeld from './MarkdownFeld.vue'
const props = defineProps({
item: { type: Object, required: true },
host: Boolean,
angemeldet: Boolean,
})
const emit = defineEmits(['open'])
const STERNE = [1, 2, 3, 4, 5]
const fehler = ref('')
const cover = computed(() =>
props.item.poster_path ? posterUrl(props.item.poster_path, 'w780') : null
)
function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
async function oeffnen() {
const resp = await fetch(`/api/search/${props.item.movie_id}`)
if (resp.ok) emit('open', await resp.json())
}
// ── Links: die Momentaufnahme ──────────────────────────────────────────
const schwebt = ref(0)
function gefuellt(stern) {
return stern <= (schwebt.value || props.item.mine || 0)
}
async function bewerten(sterne) {
try {
await fetch(`/api/watched/${props.item.id}/rating`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stars: sterne }),
})
} catch {
fehler.value = 'Bewerten hat nicht geklappt.'
}
}
// Die eigene zuerst
const wertungen = computed(() =>
[...props.item.ratings].sort((a, b) => b.mine - a.mine)
)
async function wertungEntfernen(wertung) {
await fetch(`/api/watched/rating/${wertung.id}`, { method: 'DELETE' })
}
// ── Rechts: Schreiben ──────────────────────────────────────────────────
// Unter der Diskussion klappt auf Klick ein Feld auf; Absenden klappt es zu
const schreibt = ref(false)
const kommentarEntwurf = ref('')
// ── Rechts: die Diskussion ─────────────────────────────────────────────
async function kommentieren() {
const text = kommentarEntwurf.value.trim()
if (!text) return
const resp = await fetch(`/api/watched/${props.item.id}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
if (resp.ok) {
kommentarEntwurf.value = ''
schreibt.value = false
} else fehler.value = 'Schreiben hat nicht geklappt.'
}
function zaehlen(liste) {
return liste.reduce((n, k) => n + 1 + zaehlen(k.replies), 0)
}
const anzahl = computed(() => zaehlen(props.item.notes))
// ── Host ───────────────────────────────────────────────────────────────
function alsFeld(iso) {
const d = new Date(iso)
const zwei = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`
}
async function datumSetzen(tag) {
if (!tag) return
await fetch(`/api/watched/${props.item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ watched_at: tag }),
})
}
async function sichtbarkeit() {
await fetch(`/api/watched/${props.item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden: !props.item.hidden }),
})
}
async function entfernen() {
await fetch(`/api/watched/${props.item.id}`, { method: 'DELETE' })
}
</script>
<template>
<article class="doppelseite" :class="{ versteckt: item.hidden }">
<!-- ── Linke Seite: das Cover ─────────────────────────────────── -->
<section class="links">
<div class="cover" :style="cover ? { backgroundImage: `url(${cover})` } : null"></div>
<div class="momentaufnahme">
<header>
<p class="wann">
Abend {{ item.nummer }} · {{ datum(item.watched_at) }}
<span v-if="item.hidden" class="marke">ausgeblendet</span>
</p>
<h2 @click="oeffnen">{{ item.title }}</h2>
<p class="meta">
<span v-if="item.year">{{ item.year }}</span>
<span v-if="item.runtime">{{ item.runtime }} Min.</span>
<span v-if="item.genres?.length">{{ item.genres.join(' · ') }}</span>
</p>
<p class="urteil">
<template v-if="item.count">
<span class="wert">{{ item.average.toFixed(1) }}</span>
<span class="von">von 5 · {{ item.count }} {{ item.count === 1 ? 'Stimme' : 'Stimmen' }}</span>
</template>
<span v-else class="von">Noch keine Wertung</span>
</p>
</header>
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<div v-if="angemeldet" class="wertung" @mouseleave="schwebt = 0">
<span class="deine">Du:</span>
<button
v-for="s in STERNE"
:key="s"
type="button"
class="stern"
:class="{ an: gefuellt(s) }"
:aria-label="`${s} von 5`"
@mouseenter="schwebt = s"
@click="bewerten(s)"
>
★
</button>
</div>
<ul v-if="wertungen.length" class="stimmen">
<li v-for="r in wertungen" :key="r.id">
<span class="punkte">{{ '★'.repeat(r.stars) }}</span>
<span class="wer" :class="{ eigen: r.mine }">{{ r.name || 'Anonym' }}</span>
<button
v-if="host"
type="button"
class="weg"
title="Wertung entfernen"
@click="wertungEntfernen(r)"
>
×
</button>
</li>
</ul>
<div v-if="host" class="host-zeile">
<label>
Geschaut am
<input type="date" :value="alsFeld(item.watched_at)" @change="datumSetzen($event.target.value)" />
</label>
<button type="button" class="klein" @click="sichtbarkeit">
{{ item.hidden ? 'Einblenden' : 'Ausblenden' }}
</button>
<button type="button" class="klein loeschen" @click="entfernen">Löschen</button>
</div>
</div>
</section>
<!-- ── Rechte Seite: das Gespräch ─────────────────────────────── -->
<section class="rechts">
<h3>
Diskussion
<span v-if="anzahl" class="anzahl">{{ anzahl }}</span>
</h3>
<p v-if="!anzahl" class="leer">Noch nichts gesagt.</p>
<ul v-else class="liste">
<Kommentar
v-for="k in item.notes"
:key="k.id"
:kommentar="k"
:watched-id="item.id"
:angemeldet="angemeldet"
:host="host"
/>
</ul>
<div v-if="angemeldet" class="schreiben">
<button v-if="!schreibt" type="button" class="schreib-knopf" @click="schreibt = true">
Schreiben
</button>
<MarkdownFeld
v-else
v-model="kommentarEntwurf"
platzhalter="Was sagst du dazu?"
knopf="Schreiben"
:maxlaenge="4000"
@senden="kommentieren"
@abbrechen="schreibt = false"
/>
</div>
</section>
</article>
</template>
<style scoped>
.doppelseite {
display: flex;
height: 100%;
background: var(--bg);
}
/* ── Links ─────────────────────────────────────────────────────────── */
.links {
position: relative;
display: flex;
flex: 0 0 50%;
flex-direction: column;
/* Die Momentaufnahme steht unten auf dem Cover */
justify-content: flex-end;
min-width: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.versteckt .cover {
opacity: 0.5;
}
.cover {
position: absolute;
top: 0;
right: 0;
left: 0;
aspect-ratio: 2 / 3;
background: var(--bg-soft) center / cover no-repeat;
}
/* Ab der Mitte wird das Cover dunkler, unten ist es der Hintergrund */
.cover::after {
position: absolute;
inset: 0;
content: '';
background: linear-gradient(
to bottom,
rgba(10, 10, 12, 0) 35%,
rgba(10, 10, 12, 0.75) 62%,
var(--bg) 100%
);
}
.momentaufnahme {
position: relative;
display: flex;
flex-direction: column;
gap: 0.9rem;
/* Der Text beginnt gut unterhalb der Covermitte */
padding: 70% 1.5rem 1.5rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.35rem;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
}
.wann {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.marke {
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 4px;
}
h2 {
font-size: 2rem;
line-height: 1.1;
cursor: pointer;
}
h2:hover {
color: var(--accent);
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: var(--muted);
font-size: 0.88rem;
}
.urteil {
display: flex;
gap: 0.5rem;
align-items: baseline;
margin-top: 0.3rem;
}
.wert {
color: #f5c518;
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.von {
color: var(--muted);
font-size: 0.88rem;
}
.wertung {
display: flex;
gap: 0.1rem;
align-items: center;
}
.wertung .link {
margin-left: 0.8rem;
}
.deine {
margin-right: 0.4rem;
color: var(--muted);
font-size: 0.85rem;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
}
.stern {
padding: 0 0.05rem;
color: var(--line);
font-size: 1.6rem;
line-height: 1;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
}
.stern.an {
color: #f5c518;
}
.link {
padding: 0;
color: var(--muted);
font-size: 0.82rem;
text-decoration: underline;
text-underline-offset: 2px;
}
.link:hover {
color: var(--text);
}
.stimmen {
display: flex;
flex-direction: column;
gap: 0.3rem;
list-style: none;
}
.stimmen li {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.punkte {
color: #f5c518;
letter-spacing: 0.05em;
}
.wer {
color: var(--muted);
font-size: 0.85rem;
}
.wer.eigen,
.eigen {
color: var(--accent);
font-weight: 600;
}
.weg {
padding: 0 0.3rem;
color: var(--muted);
font-size: 1rem;
line-height: 1;
}
.weg:hover {
color: var(--accent);
}
.host-zeile {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding-top: 0.6rem;
color: var(--muted);
font-size: 0.8rem;
border-top: 1px solid var(--line);
}
.host-zeile label {
display: flex;
gap: 0.4rem;
align-items: center;
}
.host-zeile input[type='date'] {
padding: 0.25rem 0.45rem;
font-size: 0.8rem;
}
.klein {
padding: 0.3rem 0.65rem;
color: var(--muted);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.klein:hover:not(:disabled) {
color: var(--text);
border-color: var(--accent);
}
.klein.loeschen:hover {
color: var(--accent);
}
.fehler,
.leer {
color: var(--muted);
font-size: 0.88rem;
}
.fehler {
color: var(--accent);
}
/* ── Rechts ────────────────────────────────────────────────────────── */
.rechts {
flex: 0 0 50%;
min-width: 0;
padding: 1.25rem 1.25rem 2rem;
overflow-y: auto;
scrollbar-width: thin;
background: var(--bg-soft);
/* Der Falz: ein Schatten, der aus der Mitte kommt */
border-left: 1px solid var(--line);
box-shadow: inset 14px 0 18px -14px rgba(0, 0, 0, 0.9);
}
.schreiben {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-top: 1.25rem;
}
/* Zurückhaltend — die Diskussion steht im Vordergrund, nicht der Knopf */
.schreib-knopf {
align-self: flex-start;
padding: 0.45rem 0.95rem;
color: var(--muted);
font-size: 0.84rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.schreib-knopf:hover {
color: var(--text);
border-color: var(--accent);
}
h3 {
display: flex;
gap: 0.45rem;
align-items: center;
margin-bottom: 0.4rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.anzahl {
padding: 0.1rem 0.45rem;
letter-spacing: 0;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 20px;
}
/* Zeilen mit Trennlinie, wie bei den Wünschen */
.liste {
list-style: none;
border-top: 1px solid var(--line);
}
.zeile {
display: flex;
gap: 0.8rem;
align-items: flex-start;
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--line);
}
.pille {
flex-shrink: 0;
min-width: 3rem;
margin-top: 0.1rem;
padding: 0.18rem 0.55rem;
font-size: 0.8rem;
font-weight: 600;
text-align: center;
white-space: nowrap;
border: 1px solid var(--line);
border-radius: 20px;
}
.inhalt {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
}
.text {
font-size: 0.95rem;
line-height: 1.45;
white-space: pre-wrap;
}
.zeile .meta {
display: block;
color: var(--muted);
font-size: 0.76rem;
line-height: 1.4;
}
.punkt {
margin: 0 0.35rem;
}
/* ── Handy: beide Hälften untereinander ────────────────────────────── */
@media (max-width: 640px), (pointer: coarse) {
.stern {
padding: 0.1rem 0.2rem;
font-size: 1.8rem;
}
}
@media (max-width: 640px) {
.doppelseite {
flex-direction: column;
height: auto;
}
.links,
.rechts {
flex: none;
overflow: visible;
}
.links {
/* Posterformat, der Inhalt kann darüber hinauswachsen */
min-height: calc(100vw * 1.5);
}
.momentaufnahme {
padding: 80% 1rem 1.25rem;
}
.rechts {
padding: 1rem 1rem 1.5rem;
box-shadow: none;
border-top: 1px solid var(--line);
border-left: 0;
}
h2 {
font-size: 1.6rem;
}
}
</style>

View File

@@ -0,0 +1,151 @@
<script setup>
// Anmelden per Film: Wer einen Anmeldefilm hinterlegt hat, muss ihn erst
// in der Suche finden. Welcher es ist, verrät der Server nicht — hier
// steht nur, ob der Klick gesessen hat.
import { onMounted, onUnmounted, ref } from 'vue'
import FilmSuche from './FilmSuche.vue'
const props = defineProps({
name: { type: String, required: true },
// Gibt zurück, ob der geklickte Film der richtige war
pruefen: { type: Function, required: true },
})
const emit = defineEmits(['abbrechen'])
const fehler = ref('')
const laeuft = ref(false)
// Nach zwei Fehlversuchen steht da, dass es der Host auch richten kann
const versuche = ref(0)
async function probieren(film) {
if (laeuft.value) return
laeuft.value = true
fehler.value = ''
const ok = await props.pruefen(film.id)
laeuft.value = false
if (ok) return
versuche.value += 1
fehler.value = 'Das ist nicht der Film.'
}
function beiTaste(e) {
if (e.key === 'Escape') emit('abbrechen')
}
onMounted(() => document.addEventListener('keydown', beiTaste))
onUnmounted(() => document.removeEventListener('keydown', beiTaste))
</script>
<template>
<Teleport to="body">
<div class="hintergrund" @click.self="emit('abbrechen')">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Anmelden als</p>
<h2>{{ name }}</h2>
<p class="frage">Welchen Film hast du hinterlegt?</p>
<FilmSuche platzhalter="Dein Anmeldefilm …" @waehlen="probieren" />
<p v-if="fehler" class="fehler">
{{ fehler }}
<span v-if="versuche >= 2" class="tipp">
Der Host kann dir in der Verwaltung einen neuen setzen.
</span>
</p>
<div class="fuss">
<button type="button" class="abbrechen" @click="emit('abbrechen')">
Abbrechen
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.hintergrund {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
}
.fenster {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(38rem, 100%);
max-height: min(42rem, 90vh);
padding: 1.5rem 1.75rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 14px;
}
.label {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
h2 {
font-size: 1.35rem;
line-height: 1.2;
}
.frage {
color: var(--muted);
font-size: 0.9rem;
}
.fehler {
color: var(--accent);
font-size: 0.88rem;
}
.tipp {
display: block;
margin-top: 0.15rem;
color: var(--muted);
font-size: 0.82rem;
}
.fuss {
display: flex;
justify-content: flex-end;
}
.abbrechen {
padding: 0.55rem 1.1rem;
color: var(--muted);
font-size: 0.88rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.abbrechen:hover {
color: var(--text);
}
@media (max-width: 640px) {
.hintergrund {
padding: 0;
}
.fenster {
width: 100%;
height: 100%;
max-height: none;
padding: 1rem;
border: 0;
border-radius: 0;
}
}
</style>

View File

@@ -0,0 +1,178 @@
<script setup>
// Suchfeld plus Posterraster — dieselbe Suche wie im Reiter, nur klein.
// Wird zweimal gebraucht: beim Anmelden und beim Hinterlegen des Films.
import { onMounted, ref, watch } from 'vue'
import { posterUrl } from '../images'
defineProps({
platzhalter: { type: String, default: 'Film suchen …' },
})
const emit = defineEmits(['waehlen'])
const feld = ref(null)
const query = ref('')
const treffer = ref([])
const laden = ref(false)
let timer = null
let laufende = 0
async function suchen(begriff) {
const wort = begriff.trim()
if (wort.length < 2) {
treffer.value = []
return
}
const meine = ++laufende
laden.value = true
try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(wort)}`)
const daten = resp.ok ? await resp.json() : []
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
if (meine !== laufende) return
treffer.value = daten
} catch {
if (meine === laufende) treffer.value = []
} finally {
if (meine === laufende) laden.value = false
}
}
// Beim Tippen bremsen, bei Enter sofort
watch(query, (wort) => {
clearTimeout(timer)
timer = setTimeout(() => suchen(wort), 350)
})
function jetzt() {
clearTimeout(timer)
suchen(query.value)
}
onMounted(() => feld.value?.focus())
</script>
<template>
<div class="film-suche">
<input
ref="feld"
v-model="query"
type="search"
:placeholder="platzhalter"
@keyup.enter="jetzt"
/>
<p v-if="laden" class="note">Suche läuft…</p>
<p v-else-if="query.trim().length < 2" class="note">
Tipp den Titel — dann klick den richtigen Film an.
</p>
<p v-else-if="!treffer.length" class="note">Nichts gefunden.</p>
<div v-else class="raster">
<button
v-for="film in treffer"
:key="film.id"
type="button"
class="kachel"
@click="emit('waehlen', film)"
>
<img
v-if="posterUrl(film.poster_path, 'w185')"
:src="posterUrl(film.poster_path, 'w185')"
:alt="film.title"
loading="lazy"
/>
<span v-else class="kein-cover">Kein Cover</span>
<span class="titel">{{ film.title }}</span>
<span v-if="film.year" class="jahr">{{ film.year }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.film-suche {
display: flex;
flex-direction: column;
gap: 0.8rem;
min-height: 0;
}
input {
width: 100%;
padding: 0.7rem 0.9rem;
font-size: 0.95rem;
}
.note {
padding: 1.5rem 0;
color: var(--muted);
font-size: 0.88rem;
text-align: center;
}
.raster {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 0.6rem;
overflow-y: auto;
padding-right: 0.2rem;
}
.kachel {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0;
text-align: left;
}
.kachel img,
.kein-cover {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.kein-cover {
display: grid;
place-items: center;
padding: 0.4rem;
color: var(--muted);
font-size: 0.7rem;
text-align: center;
}
.kachel:hover img,
.kachel:hover .kein-cover,
.kachel:focus-visible img {
border-color: var(--accent);
}
.titel {
overflow: hidden;
font-size: 0.78rem;
line-height: 1.25;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.jahr {
color: var(--muted);
font-size: 0.72rem;
}
@media (max-width: 640px), (pointer: coarse) {
input {
font-size: 16px;
}
.raster {
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
}
}
</style>

View File

@@ -0,0 +1,212 @@
<script setup>
// Ein Kommentar in der Diskussion: der Text mit Name, Datum und
// „Kommentieren“. Antworten rücken darunter ein — die Komponente ruft sich dafür selbst auf, so tief
// es geht. Ab der vierten Ebene nicht weiter, sonst bleibt auf dem Handy
// kein Platz für den Text.
import { ref } from 'vue'
import { rendern } from '../markdown'
import MarkdownFeld from './MarkdownFeld.vue'
const props = defineProps({
kommentar: { type: Object, required: true },
watchedId: { type: Number, required: true },
angemeldet: Boolean,
host: Boolean,
tiefe: { type: Number, default: 0 },
})
const antwortet = ref(false)
const entwurf = ref('')
async function antworten() {
const text = entwurf.value.trim()
if (!text) return
const resp = await fetch(`/api/watched/${props.watchedId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, parent_id: props.kommentar.id }),
})
if (resp.ok) {
entwurf.value = ''
antwortet.value = false
}
}
async function loeschen() {
await fetch(`/api/watched-notes/${props.kommentar.id}`, { method: 'DELETE' })
}
function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
</script>
<template>
<li class="zeile">
<div class="inhalt">
<div class="markdown" v-html="rendern(kommentar.text)"></div>
<p class="meta">
<span :class="{ eigen: kommentar.mine }">{{ kommentar.name || 'Anonym' }}</span>
<span class="punkt">·</span>{{ datum(kommentar.created_at) }}
<template v-if="angemeldet">
<span class="punkt">·</span>
<button type="button" class="link" :class="{ offen: antwortet }" @click="antwortet = !antwortet">
Kommentieren
</button>
</template>
</p>
<MarkdownFeld
v-if="antwortet"
v-model="entwurf"
kompakt
class="neu"
:platzhalter="`Antwort an ${kommentar.name || 'Anonym'} …`"
knopf="Antworten"
@senden="antworten"
@abbrechen="antwortet = false"
/>
<ul v-if="kommentar.replies.length" class="antworten" :class="{ flach: tiefe >= 3 }">
<Kommentar
v-for="k in kommentar.replies"
:key="k.id"
:kommentar="k"
:watched-id="watchedId"
:angemeldet="angemeldet"
:host="host"
:tiefe="tiefe + 1"
/>
</ul>
</div>
<!-- Erst beim Überfahren sichtbar, sonst ist jede Zeile voller Knöpfe -->
<div class="aktionen">
<button
v-if="kommentar.mine || host"
type="button"
class="weg"
:title="kommentar.replies.length ? 'Samt allen Antworten darunter' : 'Kommentar entfernen'"
@click="loeschen"
>
×
</button>
</div>
</li>
</template>
<style scoped>
.zeile {
display: flex;
gap: 0.8rem;
align-items: flex-start;
padding: 0.55rem 0.5rem;
border-bottom: 1px solid var(--line);
border-radius: 6px;
}
.zeile:hover {
background: rgba(255, 255, 255, 0.02);
}
.inhalt {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.15rem;
}
.meta {
color: var(--muted);
font-size: 0.76rem;
line-height: 1.4;
}
.punkt {
margin: 0 0.35rem;
}
.eigen {
color: var(--accent);
font-weight: 600;
}
.link {
padding: 0;
color: var(--muted);
font-size: inherit;
text-decoration: underline;
text-decoration-color: transparent;
text-underline-offset: 2px;
}
.link:hover,
.link.offen {
color: var(--text);
text-decoration-color: currentColor;
}
.neu {
margin-top: 0.4rem;
}
/* Antworten hängen eingerückt unter dem Kommentar */
.antworten {
margin-top: 0.4rem;
padding-left: 0.4rem;
list-style: none;
border-left: 2px solid var(--line);
}
.antworten .zeile {
padding-right: 0;
border-bottom: 0;
}
/* Ab der vierten Ebene nicht weiter einrücken */
.antworten.flach {
padding-left: 0;
border-left: 0;
}
.aktionen {
display: flex;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s;
}
.zeile:hover > .aktionen,
.zeile:focus-within > .aktionen {
opacity: 1;
}
.weg {
padding: 0 0.25rem;
color: var(--muted);
font-size: 1.05rem;
line-height: 1.2;
}
.weg:hover {
color: var(--accent);
}
@media (hover: none) {
.aktionen {
opacity: 1;
}
}
@media (max-width: 640px), (pointer: coarse) {
.link,
.weg {
padding: 0.3rem 0.4rem;
}
}
</style>

View File

@@ -0,0 +1,282 @@
<script setup>
// Ein Textfeld mit den paar Handgriffen, die man beim Schreiben braucht:
// fett, kursiv, Überschrift, Liste, Zitat — als Markdown, mit Vorschau.
// Klappt dort auf, wo man schreibt, statt über der Seite zu schweben.
// Dient für Rezensionen, Kommentare und Antworten; „kompakt“ macht es
// für Antworten eine Nummer kleiner.
import { computed, nextTick, onMounted, ref } from 'vue'
import { rendern } from '../markdown'
const props = defineProps({
platzhalter: { type: String, default: 'Schreib etwas …' },
knopf: { type: String, default: 'Senden' },
maxlaenge: { type: Number, default: 1000 },
kompakt: Boolean,
// Senden geht nur, wenn außerdem das hier stimmt — etwa Sterne gesetzt
bereit: { type: Boolean, default: true },
hinweis: { type: String, default: '' },
})
const emit = defineEmits(['senden', 'abbrechen'])
const text = defineModel({ type: String, default: '' })
const vorschau = ref(false)
const feld = ref(null)
const gerendert = computed(() => rendern(text.value))
const kannSenden = computed(() => props.bereit && text.value.trim().length > 0)
// ── Handgriffe: Markdown an der Cursorstelle einfügen ─────────────────
async function umschliessen(vor, nach = vor, platzhalter = 'Text') {
const el = feld.value
const a = el.selectionStart
const b = el.selectionEnd
const gewaehlt = text.value.slice(a, b) || platzhalter
text.value = text.value.slice(0, a) + vor + gewaehlt + nach + text.value.slice(b)
await nextTick()
el.focus()
el.setSelectionRange(a + vor.length, a + vor.length + gewaehlt.length)
}
// Etwas an den Anfang jeder gewählten Zeile setzen — oder wieder weg
async function zeilenanfang(praefix) {
const el = feld.value
const a = el.selectionStart
const b = el.selectionEnd
const t = text.value
const start = t.lastIndexOf('\n', a - 1) + 1
let ende = t.indexOf('\n', b)
if (ende === -1) ende = t.length
const neu = t
.slice(start, ende)
.split('\n')
.map((z) => (z.startsWith(praefix) ? z.slice(praefix.length) : praefix + z))
.join('\n')
text.value = t.slice(0, start) + neu + t.slice(ende)
await nextTick()
el.focus()
el.setSelectionRange(start, start + neu.length)
}
function senden() {
if (kannSenden.value) emit('senden')
}
function beiTaste(e) {
if (e.key === 'Escape') emit('abbrechen')
else if (e.ctrlKey && e.key === 'Enter') senden()
else if (e.ctrlKey && e.key === 'b') {
e.preventDefault()
umschliessen('**')
} else if (e.ctrlKey && e.key === 'i') {
e.preventDefault()
umschliessen('*')
}
}
// Das Feld wächst mit dem Text. Hat man es selbst größer gezogen, bleibt
// das so — es wird nie kleiner als es gerade ist.
function wachsen() {
const el = feld.value
if (!el) return
const gewuenscht = Math.min(el.scrollHeight + 2, props.kompakt ? 320 : 640)
if (gewuenscht > el.offsetHeight) el.style.height = `${gewuenscht}px`
}
onMounted(async () => {
await nextTick()
wachsen()
feld.value?.focus()
})
</script>
<template>
<div class="markdown-feld" :class="{ kompakt }">
<div class="leiste">
<button type="button" title="Fett (Strg+B)" @click="umschliessen('**')"><b>F</b></button>
<button type="button" title="Kursiv (Strg+I)" @click="umschliessen('*')"><i>K</i></button>
<span class="trenner"></span>
<button v-if="!kompakt" type="button" title="Überschrift" @click="zeilenanfang('## ')">Ü</button>
<button type="button" title="Aufzählung" @click="zeilenanfang('- ')">•</button>
<button type="button" title="Nummerierung" @click="zeilenanfang('1. ')">1.</button>
<button type="button" title="Zitat" @click="zeilenanfang('> ')">„</button>
<span class="rest"></span>
<button type="button" class="umschalter" :class="{ an: vorschau }" @click="vorschau = !vorschau">
{{ vorschau ? 'Schreiben' : 'Vorschau' }}
</button>
</div>
<textarea
v-show="!vorschau"
ref="feld"
v-model="text"
:maxlength="maxlaenge"
:placeholder="platzhalter"
spellcheck="true"
@input="wachsen"
@keydown="beiTaste"
></textarea>
<div
v-show="vorschau"
class="vorschau markdown"
v-html="gerendert || '<p><em>Noch nichts geschrieben.</em></p>'"
></div>
<div class="fuss">
<span class="zaehler">{{ text.length }} / {{ maxlaenge }}</span>
<span v-if="hinweis && !bereit" class="hinweis">{{ hinweis }}</span>
<button type="button" class="abbrechen" @click="emit('abbrechen')">Abbrechen</button>
<button type="button" class="senden" :disabled="!kannSenden" @click="senden">
{{ knopf }}
</button>
</div>
</div>
</template>
<style scoped>
.markdown-feld {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.6rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 10px;
}
.leiste {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
align-items: center;
}
.leiste button {
min-width: 2rem;
height: 2rem;
padding: 0 0.45rem;
color: var(--muted);
font-size: 0.92rem;
border-radius: 6px;
}
.leiste button:hover {
color: var(--text);
background: var(--bg-soft);
}
.trenner {
width: 1px;
height: 1.2rem;
margin: 0 0.25rem;
background: var(--line);
}
.rest {
flex: 1;
}
.umschalter {
font-size: 0.8rem;
border: 1px solid var(--line);
}
.umschalter.an {
color: var(--text);
border-color: var(--accent);
}
textarea,
.vorschau {
width: 100%;
min-height: 14rem;
padding: 0.7rem 0.85rem;
color: var(--text);
font-family: inherit;
font-size: 0.95rem;
line-height: 1.55;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
/* Am Griff unten rechts zieht man es so groß, wie man will */
textarea {
overflow-y: auto;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: var(--accent);
}
.vorschau {
max-height: 30rem;
overflow-y: auto;
}
.kompakt textarea,
.kompakt .vorschau {
min-height: 6rem;
font-size: 0.9rem;
}
.fuss {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.zaehler,
.hinweis {
color: var(--muted);
font-size: 0.76rem;
white-space: nowrap;
}
.zaehler {
flex: 1;
}
.hinweis {
color: var(--accent);
}
.abbrechen,
.senden {
padding: 0.45rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
border-radius: 8px;
}
.abbrechen {
color: var(--muted);
border: 1px solid var(--line);
}
.abbrechen:hover {
color: var(--text);
}
.senden {
color: #fff;
background: var(--accent);
}
.senden:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 640px), (pointer: coarse) {
textarea {
font-size: 16px;
}
.leiste button {
min-width: 2.4rem;
height: 2.4rem;
}
}
</style>

View File

@@ -1,62 +1,59 @@
<script setup>
import { computed, ref } from 'vue'
import { posterUrl } from '../images'
import { computed } from 'vue'
import MovieCard from './MovieCard.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
ich: { type: Object, default: null },
// Eine eigene Sammlung hat nur, wer in der Runde steht
angemeldet: Boolean,
ich: { type: Object, default: null },
gesehenIds: { type: Set, default: () => new Set() },
ansicht: { type: String, default: 'kompakt' },
// Leer heißt: alle Sammlungen zeigen
leute: { type: Array, default: () => [] },
// null = alles, true = nur Horror, false = alles außer Horror
horror: { default: null },
})
const emit = defineEmits(['open', 'changed'])
function passt(item) {
if (props.horror === null) return true
return (item.genres || []).includes('Horror') === props.horror
}
// Nach Person gruppiert, die eigene Sammlung zuerst
const gruppen = computed(() => {
const nach = new Map()
for (const item of props.items) {
const schluessel = item.mine ? '\u0000eigene' : item.name || 'Anonym'
if (!nach.has(schluessel)) {
nach.set(schluessel, {
titel: item.mine
? `${props.ich?.name || 'Anonym'} (Deine)`
: item.name || 'Anonym',
if (!passt(item)) continue
const name = item.name || 'Anonym'
if (props.leute.length && !props.leute.includes(name)) continue
if (!nach.has(name)) {
nach.set(name, {
name,
titel: item.mine ? `${name} (Deine)` : name,
mine: item.mine,
filme: [],
})
}
nach.get(schluessel).filme.push(item)
nach.get(name).filme.push(item)
}
for (const gruppe of nach.values()) gruppe.schluessel = gruppe.titel
return [...nach.values()].sort((a, b) => b.mine - a.mine)
})
// Je Sammlung ein eigener Filter. Ohne Auswahl steht alles da.
const filter = ref({})
function umschalten(gruppe, wert) {
filter.value = {
...filter.value,
[gruppe.schluessel]: filter.value[gruppe.schluessel] === wert ? null : wert,
}
// Die Karte braucht die Kennung des Films, nicht die des Eintrags
function alsFilm(item) {
return { ...item, id: item.movie_id }
}
function gefiltert(gruppe) {
const wahl = filter.value[gruppe.schluessel]
if (!wahl) return gruppe.filme
return gruppe.filme.filter(
(f) => (f.genres || []).includes('Horror') === (wahl === 'horror')
)
}
const emit = defineEmits(['open', 'changed'])
async function vergessen(item) {
await fetch(`/api/wishlist/${item.movie_id}`, { method: 'DELETE' })
emit('changed')
}
async function oeffnen(item) {
const resp = await fetch(`/api/search/${item.movie_id}`)
async function oeffnen(film) {
const resp = await fetch(`/api/search/${film.id}`)
if (resp.ok) emit('open', await resp.json())
}
</script>
@@ -66,126 +63,48 @@ async function oeffnen(item) {
<p v-if="!items.length" class="note">
Noch nichts gemerkt. Öffne einen Film und klicke auf „Zur Sammlung“.
</p>
<p v-else-if="!gruppen.length" class="note">
Nichts gefunden. Stell die Filter weiter.
</p>
<section v-for="gruppe in gruppen" :key="gruppe.titel">
<section v-for="gruppe in gruppen" :key="gruppe.name">
<h3>
{{ gruppe.titel }}
<span class="anzahl">{{ gefiltert(gruppe).length }}</span>
<span class="schalter">
<button
type="button"
:class="{ an: filter[gruppe.schluessel] === 'horror' }"
@click="umschalten(gruppe, 'horror')"
>
Horror
</button>
<button
type="button"
:class="{ an: filter[gruppe.schluessel] === 'kein' }"
@click="umschalten(gruppe, 'kein')"
>
Kein Horror
</button>
</span>
<span class="anzahl">{{ gruppe.filme.length }}</span>
</h3>
<div class="grid">
<article
v-for="item in gefiltert(gruppe)"
:key="item.id"
class="karte"
:class="{ gesehen: gesehenIds.has(item.movie_id) }"
>
<button type="button" class="cover" @click="oeffnen(item)">
<img
v-if="posterUrl(item.poster_path, 'w342')"
:src="posterUrl(item.poster_path, 'w342')"
:alt="item.title"
loading="lazy"
/>
<span v-else class="kein-cover">Kein Cover</span>
<div class="grid" :class="ansicht">
<!-- Der Entfernen-Knopf liegt über der Karte statt darin: eine
Karte ist selbst ein Knopf, und Knöpfe schachtelt man nicht -->
<div v-for="item in gruppe.filme" :key="item.id" class="halter">
<MovieCard
:movie="alsFilm(item)"
:ansicht="ansicht"
:gesehen="gesehenIds.has(item.movie_id)"
@open="oeffnen"
/>
<button
v-if="item.mine"
type="button"
class="weg"
title="Aus deiner Sammlung nehmen"
@click="vergessen(item)"
>
×
</button>
<div class="text">
<h5 @click="oeffnen(item)">{{ item.title }}</h5>
<p v-if="item.year" class="jahr">{{ item.year }}</p>
<p v-if="item.genres?.length" class="genres">
{{ item.genres.join(' · ') }}
</p>
<button
v-if="item.mine"
type="button"
class="weg"
@click="vergessen(item)"
>
Entfernen
</button>
</div>
</article>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* Schon gelaufen — die Karte tritt zurück, kommt beim Überfahren wieder */
.karte.gesehen {
opacity: 0.45;
filter: grayscale(1);
}
.karte.gesehen:hover {
opacity: 1;
filter: none;
}
.karte {
display: flex;
gap: 0.9rem;
align-items: flex-start;
padding: 0.8rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
.cover {
flex-shrink: 0;
width: 82px;
padding: 0;
}
.cover img,
.kein-cover {
width: 82px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
}
.kein-cover {
display: grid;
place-items: center;
color: var(--muted);
font-size: 0.7rem;
background: var(--bg);
}
.text {
display: flex;
flex-direction: column;
gap: 0.3rem;
.merkliste-tab {
min-width: 0;
}
section {
margin-bottom: 1.75rem;
margin-bottom: 2rem;
}
h3 {
@@ -208,62 +127,58 @@ h3 {
border-radius: 20px;
}
.schalter {
display: flex;
margin-left: 0.4rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 7px;
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 1.1rem;
}
.schalter button {
padding: 0.2rem 0.6rem;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
background: var(--bg);
/* Eine Detailkarte ist so breit wie vier kompakte Karten nebeneinander —
oder die halbe Reihe, falls die schmaler ist. */
.grid.details {
grid-template-columns: repeat(
auto-fill,
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
);
}
.schalter button.an {
color: var(--text);
background: var(--bg-soft);
}
.genres {
color: var(--muted);
font-size: 0.78rem;
}
h5 {
font-size: 0.98rem;
line-height: 1.25;
cursor: pointer;
}
h5:hover {
color: var(--accent);
}
.jahr {
color: var(--muted);
font-size: 0.8rem;
.halter {
position: relative;
min-width: 0;
}
.weg {
align-self: flex-start;
margin-top: 0.35rem;
padding: 0.28rem 0.6rem;
color: var(--muted);
font-size: 0.75rem;
border: 1px solid var(--line);
border-radius: 6px;
position: absolute;
top: 0.4rem;
left: 0.4rem;
z-index: 2;
display: grid;
place-items: center;
width: 1.7rem;
height: 1.7rem;
color: #fff;
font-size: 1.2rem;
line-height: 1;
opacity: 0;
transition: opacity 0.12s;
background: rgba(0, 0, 0, 0.75);
border-radius: 50%;
}
.halter:hover .weg,
.weg:focus-visible {
opacity: 1;
}
.weg:hover {
color: var(--accent);
border-color: var(--accent);
}
/* Ohne Maus gibt es kein Überfahren — dann steht der Knopf einfach da */
@media (hover: none) {
.weg {
opacity: 1;
}
}
.note {
@@ -271,4 +186,15 @@ h5:hover {
color: var(--muted);
text-align: center;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.6rem;
}
.grid.details {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,21 +1,39 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import FilmLogin from './FilmLogin.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
mine: { type: Object, default: null },
// Der Host wechselt frei, ohne den Anmeldefilm zu suchen
host: Boolean,
// (user_id, movie_id) => Promise<ok>. Bei falschem Film kommt false.
anmelden: { type: Function, required: true },
})
const emit = defineEmits(['waehlen'])
// Firefox zeichnet die Optionsliste eines <select> unter Linux im
// System-Theme, hell und blau. Deshalb ein eigenes Menü.
const offen = ref(false)
const halter = ref(null)
function waehlen(id) {
// Wer einen Anmeldefilm hinterlegt hat, muss ihn erst finden
const anmeldung = ref(null)
function waehlen(benutzer) {
offen.value = false
emit('waehlen', id)
if (!benutzer) return props.anmelden(null)
// Beim eigenen Namen fragt der Server nicht noch einmal nach
if (benutzer.geschuetzt && !props.host && props.mine?.id !== benutzer.id) {
anmeldung.value = benutzer
return
}
props.anmelden(benutzer.id)
}
async function pruefen(movieId) {
const ok = await props.anmelden(anmeldung.value.id, movieId)
if (ok) anmeldung.value = null
return ok
}
function daneben(e) {
@@ -50,11 +68,19 @@ onUnmounted(() => {
</button>
</li>
<li v-for="u in items" :key="u.id">
<button type="button" :class="{ aktiv: mine?.id === u.id }" @click="waehlen(u.id)">
<button type="button" :class="{ aktiv: mine?.id === u.id }" @click="waehlen(u)">
{{ u.name }}
<span v-if="u.geschuetzt" class="schloss" title="Mit Anmeldefilm">&#9679;</span>
</button>
</li>
</ul>
<FilmLogin
v-if="anmeldung"
:name="anmeldung.name"
:pruefen="pruefen"
@abbrechen="anmeldung = null"
/>
</div>
</template>
@@ -114,6 +140,14 @@ onUnmounted(() => {
font-weight: 600;
}
/* Der Punkt zeigt: hier fragt die Seite nach dem Anmeldefilm */
.schloss {
margin-left: 0.3rem;
color: var(--muted);
font-size: 0.6rem;
vertical-align: middle;
}
@media (max-width: 640px), (pointer: coarse) {
.knopf {
padding: 0.45rem 0.85rem;

View File

@@ -0,0 +1,254 @@
<script setup>
// Die Leiste rechts im Sammlungen-Reiter. Gleiche Bauart wie die
// Filterleiste im Katalog, nur mit dem, was hier zählt: wessen Sammlung,
// Horror oder nicht, und wie groß die Karten sind.
import { computed } from 'vue'
const props = defineProps({
// [{ name, anzahl }] — wer überhaupt etwas gesammelt hat
leute: { type: Array, default: () => [] },
})
const gewaehlt = defineModel('gewaehlt', { type: Array, default: () => [] })
// null = alles, true = nur Horror, false = alles außer Horror
const horror = defineModel('horror', { default: null })
const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' })
const alleGewaehlt = computed(() => gewaehlt.value.length === 0)
function umschalten(name) {
gewaehlt.value = gewaehlt.value.includes(name)
? gewaehlt.value.filter((n) => n !== name)
: [...gewaehlt.value, name]
}
// Ein zweiter Klick auf denselben Knopf hebt die Auswahl wieder auf
function horrorSetzen(wert) {
horror.value = horror.value === wert ? null : wert
}
const etwasAktiv = computed(() => !alleGewaehlt.value || horror.value !== null)
function zuruecksetzen() {
gewaehlt.value = []
horror.value = null
}
</script>
<template>
<aside class="sidebar">
<div class="controls">
<div class="ansicht">
<button
type="button"
:class="{ an: ansicht === 'kompakt' }"
@click="ansicht = 'kompakt'"
>
Kompakt
</button>
<button
type="button"
:class="{ an: ansicht === 'details' }"
@click="ansicht = 'details'"
>
Details
</button>
</div>
</div>
<div class="head">
<h3>Filter</h3>
<button v-if="etwasAktiv" type="button" class="reset" @click="zuruecksetzen">
zurücksetzen
</button>
</div>
<section>
<h4>Genre</h4>
<div class="badges">
<button
type="button"
class="badge"
:class="{ on: horror === true }"
@click="horrorSetzen(true)"
>
Horror
</button>
<button
type="button"
class="badge"
:class="{ on: horror === false }"
@click="horrorSetzen(false)"
>
Kein Horror
</button>
</div>
</section>
<section>
<h4>Sammlungen</h4>
<div class="badges">
<button
type="button"
class="badge alle"
:class="{ on: alleGewaehlt }"
@click="gewaehlt = []"
>
alle
</button>
<button
v-for="person in leute"
:key="person.name"
type="button"
class="badge"
:class="{ on: gewaehlt.includes(person.name) }"
@click="umschalten(person.name)"
>
{{ person.name }}
<span class="count">{{ person.anzahl }}</span>
</button>
</div>
</section>
</aside>
</template>
<style scoped>
.sidebar {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: 1rem 0.9rem;
}
.controls {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding-bottom: 1.1rem;
border-bottom: 1px solid var(--line);
}
.head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
h3 {
color: var(--text);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
h4 {
margin-bottom: 0.5rem;
color: var(--muted);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.reset {
color: var(--muted);
font-size: 0.75rem;
text-decoration: underline;
}
.reset:hover {
color: var(--text);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.32rem;
}
.badge {
display: flex;
align-items: center;
gap: 0.32rem;
padding: 0.28rem 0.6rem;
color: var(--muted);
font-size: 0.8rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 20px;
}
.badge:hover {
color: var(--text);
border-color: var(--muted);
}
.count {
font-size: 0.7rem;
opacity: 0.6;
}
.badge.alle {
font-style: italic;
}
.badge.on {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.ansicht {
display: flex;
gap: 0.3rem;
}
.ansicht button {
flex: 1;
padding: 0.4rem 0.5rem;
color: var(--muted);
font-size: 0.8rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 6px;
}
.ansicht button:hover {
color: var(--text);
}
.ansicht button.an {
color: var(--text);
font-weight: 600;
border-color: var(--accent);
}
/* Finger statt Maus: alles etwas größer */
@media (max-width: 640px), (pointer: coarse) {
.sidebar {
gap: 1.4rem;
padding: 1rem;
}
.ansicht button {
padding: 0.65rem 0.5rem;
font-size: 0.9rem;
}
.badge {
padding: 0.5rem 0.85rem;
font-size: 0.9rem;
}
.badges {
gap: 0.45rem;
}
.reset {
padding: 0.4rem 0;
font-size: 0.85rem;
}
}
</style>

View File

@@ -1,13 +1,51 @@
<script setup>
import { ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import FilmSuche from './FilmSuche.vue'
import { posterUrl } from '../images'
defineProps({
const props = defineProps({
items: { type: Array, default: () => [] },
// Der Host pflegt die ganze Runde, alle anderen nur den eigenen Zugang
host: Boolean,
ich: { type: Object, default: null },
})
const liste = computed(() =>
props.host ? props.items : props.items.filter((u) => u.id === props.ich?.id)
)
const neuerName = ref('')
const fehler = ref('')
// Der Anmeldefilm ist das Passwort: wer den Namen wählt, muss ihn in der
// Suche finden. Welcher es ist, gibt der Server nicht heraus — hier steht
// nur, ob überhaupt einer gesetzt ist.
const setzt = ref(null)
async function schutzSetzen(user, movieId) {
await fetch(`/api/users/${user.id}/schutz`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: movieId }),
})
delete gezeigt[user.id]
setzt.value = null
}
// Wer den Film ändern darf, darf ihn auch nachschlagen — sonst weiß man
// nicht, was man gerade überschreibt. Sichtbar wird er erst auf Klick.
const gezeigt = reactive({})
async function schutzZeigen(user) {
if (gezeigt[user.id]) {
delete gezeigt[user.id]
return
}
gezeigt[user.id] = { laden: true }
const resp = await fetch(`/api/users/${user.id}/schutz`)
gezeigt[user.id] = resp.ok ? await resp.json() : { fehler: true }
}
async function anlegen() {
fehler.value = ''
const resp = await fetch('/api/users', {
@@ -27,9 +65,14 @@ async function entfernen(user) {
<template>
<div class="verwaltung-tab">
<section>
<h3>Benutzer</h3>
<h3>{{ host ? 'Benutzer' : 'Dein Zugang' }}</h3>
<div class="anlegen">
<p v-if="!host" class="erklaerung">
Hinterleg einen Film, dann fragt die Seite beim Wechsel auf deinen
Namen danach. Ohne Film bleibt dein Name für alle offen.
</p>
<div v-if="host" class="anlegen">
<input
v-model="neuerName"
type="text"
@@ -41,27 +84,101 @@ async function entfernen(user) {
</div>
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<ul v-if="items.length" class="liste">
<li v-for="u in items" :key="u.id">
<span class="name">{{ u.name }}</span>
<button
type="button"
class="weg"
title="Aus der Runde nehmen"
@click="entfernen(u)"
>
×
</button>
<ul v-if="liste.length" class="liste">
<li v-for="u in liste" :key="u.id">
<div class="zeile">
<span class="name">{{ u.name }}</span>
<button
v-if="u.geschuetzt"
type="button"
class="auge"
:class="{ an: gezeigt[u.id] }"
:title="gezeigt[u.id] ? 'Film verbergen' : 'Hinterlegten Film zeigen'"
@click="schutzZeigen(u)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M1 12s4-7.5 11-7.5S23 12 23 12s-4 7.5-11 7.5S1 12 1 12z" />
<circle cx="12" cy="12" r="3.2" />
<line v-if="!gezeigt[u.id]" x1="3" y1="21" x2="21" y2="3" />
</svg>
</button>
<button type="button" class="klein" @click="setzt = u">
{{ u.geschuetzt ? 'Ändern' : 'Anmeldefilm' }}
</button>
<button
v-if="u.geschuetzt"
type="button"
class="klein"
title="Namen wieder für alle öffnen"
@click="schutzSetzen(u, null)"
>
Aufheben
</button>
<button
v-if="host"
type="button"
class="weg"
title="Aus der Runde nehmen"
@click="entfernen(u)"
>
×
</button>
</div>
<p v-if="gezeigt[u.id]" class="verraten">
<template v-if="gezeigt[u.id].laden">Moment…</template>
<template v-else-if="gezeigt[u.id].film">
<img
v-if="posterUrl(gezeigt[u.id].film.poster_path, 'w92')"
:src="posterUrl(gezeigt[u.id].film.poster_path, 'w92')"
:alt="gezeigt[u.id].film.title"
/>
<span>
{{ gezeigt[u.id].film.title }}
<span v-if="gezeigt[u.id].film.year" class="jahr">
{{ gezeigt[u.id].film.year }}
</span>
</span>
</template>
<template v-else>Der Film ließ sich nicht laden.</template>
</p>
</li>
</ul>
<p v-else class="leer">Noch niemand in der Runde.</p>
</section>
<Teleport to="body">
<div v-if="setzt" class="hintergrund" @click.self="setzt = null">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Anmeldefilm für</p>
<h2>{{ setzt.name }}</h2>
<p class="frage">
{{
setzt.id === ich?.id
? 'Diesen Film musst du beim Wechsel auf deinen Namen anklicken.'
: `Diesen Film muss ${setzt.name} beim Wechsel anklicken.`
}}
</p>
<FilmSuche
platzhalter="Film als Passwort …"
@waehlen="schutzSetzen(setzt, $event.id)"
/>
<div class="fuss">
<button type="button" class="abbrechen" @click="setzt = null">
Abbrechen
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.verwaltung-tab {
max-width: 26rem;
max-width: 34rem;
}
h3 {
@@ -107,14 +224,66 @@ h3 {
.liste li {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
padding: 0.55rem 0.85rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.zeile {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Der hinterlegte Film, aufgeklappt */
.verraten {
display: flex;
gap: 0.6rem;
align-items: center;
padding-top: 0.5rem;
color: var(--text);
font-size: 0.9rem;
border-top: 1px solid var(--line);
}
.verraten img {
width: 40px;
border-radius: 4px;
}
.jahr {
color: var(--muted);
font-size: 0.82rem;
}
.auge {
display: grid;
place-items: center;
width: 1.8rem;
height: 1.8rem;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 6px;
}
.auge svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
}
.auge:hover,
.auge.an {
color: var(--text);
border-color: var(--accent);
}
.name {
flex: 1;
font-size: 0.95rem;
@@ -132,13 +301,117 @@ h3 {
}
.leer,
.fehler {
.fehler,
.erklaerung {
color: var(--muted);
font-size: 0.88rem;
}
.erklaerung {
margin-bottom: 1rem;
line-height: 1.5;
}
.fehler {
margin-bottom: 0.8rem;
color: var(--accent);
}
.marke {
padding: 0.1rem 0.4rem;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
border: 1px solid var(--line);
border-radius: 4px;
}
.klein {
padding: 0.25rem 0.55rem;
color: var(--muted);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 6px;
}
.klein:hover {
color: var(--text);
border-color: var(--accent);
}
/* ── Fenster zum Setzen ──────────────────────────────────────────── */
.hintergrund {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
}
.fenster {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(38rem, 100%);
max-height: min(42rem, 90vh);
padding: 1.5rem 1.75rem;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 14px;
}
.label {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.fenster h2 {
font-size: 1.35rem;
line-height: 1.2;
}
.frage {
color: var(--muted);
font-size: 0.9rem;
}
.fuss {
display: flex;
justify-content: flex-end;
}
.abbrechen {
padding: 0.55rem 1.1rem;
color: var(--muted);
font-size: 0.88rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.abbrechen:hover {
color: var(--text);
}
@media (max-width: 640px) {
.hintergrund {
padding: 0;
}
.fenster {
width: 100%;
height: 100%;
max-height: none;
padding: 1rem;
border: 0;
border-radius: 0;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,32 @@
// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht,
// der Schalter für gesehene Filme und der Hostmodus. Alles im localStorage,
// alles je Browser.
import { onMounted, onUnmounted, ref, watch } from 'vue'
// Was die Seite sich über den Neustart hinaus merkt: Reiter, Ansicht und
// der Schalter für gesehene Filme — alles im localStorage, alles je
// Browser. Der Hostmodus nicht: der hängt an der Sitzung auf dem Server.
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
// Freigeschaltet wird die Sitzung auf dem Server, und zwar nur mit dem
// Schlüssel aus der .env. Sie bleibt es, bis jemand sie abgibt.
async function hostSetzen(an, schluessel = null) {
try {
const resp = await fetch('/api/host', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ an, schluessel }),
})
if (!resp.ok) return false
return (await resp.json()).host
} catch {
return false
}
}
async function hostStand() {
try {
const resp = await fetch('/api/host')
return resp.ok ? (await resp.json()).host : false
} catch {
return false
}
}
const TAB_IDS = [
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
@@ -30,24 +55,45 @@ export function useEinstellungen() {
const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'netflix')
watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id))
// Wer den Abend leitet, darf ziehen und die Runde leeren. Kein Login —
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
const host = ref(localStorage.getItem('filmabend_host') === '1')
// Wer den Abend leitet, darf ziehen und die Runde leeren. Zwei Dinge
// hängen daran: ob die Sitzung überhaupt freigeschaltet ist — das weiß
// der Server — und ob die Knöpfe gerade sichtbar sind. Letzteres schaltet
// die Tastenkombination um, damit der Host die Gastansicht sehen kann.
const freigeschaltet = ref(false)
const anzeige = ref(localStorage.getItem('filmabend_host') !== '0')
const host = computed(() => freigeschaltet.value && anzeige.value)
// Die Verwaltung verschwindet mit dem Hostmodus. Bleibt sie offen, sieht
// ein Gast sie weiter, ohne dass ein Reiter markiert wäre.
watch(host, (an) => {
const weg = an ? 'merkliste' : 'verwaltung'
if (activeTab.value === weg) activeTab.value = 'netflix'
// Der Host-Link: /?host=<schlüssel>. Nach dem Einlösen fliegt er aus der
// Adresszeile, damit er nicht im Verlauf oder in einem Screenshot landet.
async function linkEinloesen() {
const adresse = new URL(window.location.href)
const schluessel = adresse.searchParams.get('host')
if (!schluessel) return false
const an = await hostSetzen(true, schluessel)
adresse.searchParams.delete('host')
window.history.replaceState({}, '', adresse)
return an
}
onMounted(async () => {
// Der Link kommt zuerst: er schaltet frei und zeigt die Knöpfe gleich
if (await linkEinloesen()) {
freigeschaltet.value = true
anzeige.value = true
return
}
freigeschaltet.value = await hostStand()
})
watch(anzeige, (an) => localStorage.setItem('filmabend_host', an ? '1' : '0'))
function tastatur(e) {
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
// je nach System ein anderes Zeichen
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
e.preventDefault()
host.value = !host.value
localStorage.setItem('filmabend_host', host.value ? '1' : '0')
// Ohne freigeschaltete Sitzung gibt es nichts umzuschalten
if (freigeschaltet.value) anzeige.value = !anzeige.value
}
}

View File

@@ -26,12 +26,15 @@ export function useMeins({ host }) {
ich.value = daten.mine
}
async function michSetzen(id) {
await fetch('/api/users/waehlen', {
// Gibt zurück, ob der Wechsel durchging — bei hinterlegtem Anmeldefilm
// lehnt der Server ohne die richtige movie_id ab
async function michSetzen(id, movieId = null) {
const resp = await fetch('/api/users/waehlen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id ?? null }),
body: JSON.stringify({ user_id: id ?? null, movie_id: movieId }),
})
if (!resp.ok) return false
// Sammlung, Vorschlag, Wertungen und Wünsche gehören zum Namen. Ohne
// Nachladen zeigt die Seite weiter die des vorigen Benutzers.
await ladeRunde()
@@ -39,6 +42,7 @@ export function useMeins({ host }) {
ladeMerkliste()
ladeGesehen()
ladeWuensche()
return true
}
// Die geschauten Filme — die Karten und die Detailansicht zeigen damit an,

78
frontend/src/markdown.js Normal file
View File

@@ -0,0 +1,78 @@
// Ein kleiner Markdown-Renderer für Rezensionen. Kann, was man beim
// Schreiben über einen Film braucht: Absätze, fett, kursiv, Überschriften,
// Listen, Zitate. Mehr nicht — und keine Abhängigkeit dafür.
//
// Der Text wird zuerst entschärft, dann erst kommen die Tags dazu. So
// landet nie fremdes HTML in der Seite.
function entschaerfen(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function inline(s) {
return entschaerfen(s)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+?)\*/g, '$1<em>$2</em>')
.replace(/`([^`\n]+?)`/g, '<code>$1</code>')
}
export function rendern(text) {
const out = []
let absatz = []
let liste = null
let zitat = []
const absatzZu = () => {
if (absatz.length) out.push(`<p>${absatz.map(inline).join('<br>')}</p>`)
absatz = []
}
const listeZu = () => {
if (liste) out.push(`</${liste}>`)
liste = null
}
const zitatZu = () => {
if (zitat.length) out.push(`<blockquote>${zitat.map(inline).join('<br>')}</blockquote>`)
zitat = []
}
const allesZu = () => {
absatzZu()
listeZu()
zitatZu()
}
for (const zeile of (text || '').split('\n')) {
const ueberschrift = zeile.match(/^(#{1,3})\s+(.*)$/)
const punkt = zeile.match(/^\s*[-*•]\s+(.*)$/)
const nummer = zeile.match(/^\s*\d+[.)]\s+(.*)$/)
const zit = zeile.match(/^>\s?(.*)$/)
if (ueberschrift) {
allesZu()
// Eine Stufe kleiner als die Seitentitel
const stufe = ueberschrift[1].length + 3
out.push(`<h${stufe}>${inline(ueberschrift[2])}</h${stufe}>`)
} else if (punkt || nummer) {
absatzZu()
zitatZu()
const art = punkt ? 'ul' : 'ol'
if (liste !== art) {
listeZu()
out.push(`<${art}>`)
liste = art
}
out.push(`<li>${inline((punkt || nummer)[1])}</li>`)
} else if (zit) {
absatzZu()
listeZu()
zitat.push(zit[1])
} else if (!zeile.trim()) {
allesZu()
} else {
listeZu()
zitatZu()
absatz.push(zeile)
}
}
allesZu()
return out.join('')
}

View File

@@ -53,6 +53,55 @@ a {
color: inherit;
}
/* Gerenderter Text aus Rezensionen — Editor-Vorschau und Seite gleich */
.markdown {
font-size: 0.95rem;
line-height: 1.55;
}
.markdown > * + * {
margin-top: 0.6em;
}
.markdown h4,
.markdown h5,
.markdown h6 {
font-size: 1.05rem;
line-height: 1.3;
}
.markdown h5 {
font-size: 1rem;
}
.markdown h6 {
font-size: 0.95rem;
color: var(--muted);
}
.markdown ul,
.markdown ol {
padding-left: 1.3em;
}
.markdown li + li {
margin-top: 0.2em;
}
.markdown blockquote {
padding-left: 0.8em;
color: var(--muted);
border-left: 3px solid var(--line);
}
.markdown code {
padding: 0.05em 0.35em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
}
/* Unter 16 px zoomt iOS beim Tippen in jedes Feld */
@media (pointer: coarse) {
input,