update
This commit is contained in:
@@ -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'" />
|
||||
|
||||
|
||||
631
frontend/src/components/Abend.vue
Normal file
631
frontend/src/components/Abend.vue
Normal 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>
|
||||
151
frontend/src/components/FilmLogin.vue
Normal file
151
frontend/src/components/FilmLogin.vue
Normal 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>
|
||||
178
frontend/src/components/FilmSuche.vue
Normal file
178
frontend/src/components/FilmSuche.vue
Normal 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>
|
||||
212
frontend/src/components/Kommentar.vue
Normal file
212
frontend/src/components/Kommentar.vue
Normal 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>
|
||||
282
frontend/src/components/MarkdownFeld.vue
Normal file
282
frontend/src/components/MarkdownFeld.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">●</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;
|
||||
|
||||
254
frontend/src/components/SammlungFilter.vue
Normal file
254
frontend/src/components/SammlungFilter.vue
Normal 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>
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
78
frontend/src/markdown.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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('')
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user