This commit is contained in:
team3
2026-09-29 00:54:58 +02:00
parent f7c9017946
commit f80b8c5826
20 changed files with 997 additions and 77 deletions

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, provide, ref, watch } from 'vue'
import MovieCard from './components/MovieCard.vue'
import FilterSidebar from './components/FilterSidebar.vue'
import AehnlichTab from './components/AehnlichTab.vue'
@@ -41,6 +41,9 @@ const {
ladeWuensche,
} = useMeins({ host })
// Das Level zu einem Namen — für die Badges vor Namen überall auf der Seite
provide('levelVon', (name) => runde.value.find((u) => u.name === name)?.level ?? null)
// 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.
@@ -374,6 +377,7 @@ onMounted(load)
ref="rad"
:host="host"
:angemeldet="angemeldet"
:ich="ich"
:merkliste="merkliste"
:mine="meinVorschlag?.id ?? null"
:gesehen-ids="gesehenIds"
@@ -385,6 +389,8 @@ onMounted(load)
v-else-if="activeTab === 'watched'"
:host="host"
:angemeldet="angemeldet"
:runde="runde"
:ich="ich"
@open="selected = $event"
@changed="ladeGesehen"
/>

View File

@@ -9,10 +9,13 @@ import { posterUrl } from '../images'
import { rendern } from '../markdown'
import Kommentar from './Kommentar.vue'
import MarkdownFeld from './MarkdownFeld.vue'
import PersonName from './PersonName.vue'
const props = defineProps({
item: { type: Object, required: true },
host: Boolean,
// Die Runde — der Host hakt darin ab, wer dabei war
runde: { type: Array, default: () => [] },
angemeldet: Boolean,
})
const emit = defineEmits(['open'])
@@ -116,6 +119,16 @@ async function sichtbarkeit() {
})
}
async function dabeiUmschalten(userId) {
const ids = props.item.dabei.map((d) => d.id)
const neu = ids.includes(userId) ? ids.filter((i) => i !== userId) : [...ids, userId]
await fetch(`/api/watched/${props.item.id}/dabei`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_ids: neu }),
})
}
async function entfernen() {
await fetch(`/api/watched/${props.item.id}`, { method: 'DELETE' })
}
@@ -139,37 +152,42 @@ async function entfernen() {
<span v-if="item.runtime">{{ item.runtime }} Min.</span>
<span v-if="item.genres?.length">{{ item.genres.join(' · ') }}</span>
</p>
<!-- Eine Zeile: Schnitt, Stimmenzahl in Klammern, dann die
eigenen Sterne zum Klicken -->
<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>
<span class="von" :title="`${item.count} ${item.count === 1 ? 'Stimme' : 'Stimmen'}`">
({{ item.count }})
</span>
</template>
<span v-else class="von">Noch keine Wertung</span>
<span v-else-if="!angemeldet" class="von">Noch keine Wertung</span>
<span v-if="angemeldet" class="wertung" @mouseleave="schwebt = 0">
<button
v-for="s in STERNE"
:key="s"
type="button"
class="stern"
:class="{ an: gefuellt(s) }"
:aria-label="`${s} von 5`"
:title="item.mine ? 'Deine Wertung' : 'Bewerten'"
@mouseenter="schwebt = s"
@click="bewerten(s)"
>
★
</button>
</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">
<!-- Nur in der Hostansicht: in der Gastansicht bleibt die Wertung
anonym, auch wenn die Sitzung freigeschaltet ist -->
<ul v-if="host && 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>
<span class="wer" :class="{ eigen: r.mine }"><PersonName :name="r.name" /></span>
<button
v-if="host"
type="button"
@@ -182,6 +200,26 @@ async function entfernen() {
</li>
</ul>
<!-- Wer dabei war. Der Host tippt Namen an und aus, alle anderen
sehen nur die Liste. -->
<div v-if="host || item.dabei.length" class="dabei">
<template v-if="host">
<button
v-for="u in runde"
:key="u.id"
type="button"
class="chip"
:class="{ an: item.dabei.some((d) => d.id === u.id) }"
@click="dabeiUmschalten(u.id)"
>
<PersonName :name="u.name" :level="u.level" />
</button>
</template>
<span v-else class="dabei-namen">
<PersonName v-for="d in item.dabei" :key="d.id" :name="d.name" />
</span>
</div>
<div v-if="host" class="host-zeile">
<label>
Geschaut am
@@ -327,8 +365,8 @@ h2:hover {
.urteil {
display: flex;
gap: 0.5rem;
align-items: baseline;
gap: 0.4rem;
align-items: center;
margin-top: 0.3rem;
}
@@ -345,26 +383,16 @@ h2:hover {
}
.wertung {
display: flex;
gap: 0.1rem;
display: inline-flex;
gap: 0.05rem;
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);
margin-left: 0.6rem;
}
.stern {
padding: 0 0.05rem;
color: var(--line);
font-size: 1.6rem;
font-size: 1.35rem;
line-height: 1;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
}
@@ -425,6 +453,34 @@ h2:hover {
color: var(--accent);
}
.dabei {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.7rem;
align-items: center;
margin-top: 0.6rem;
}
.dabei-namen {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.7rem;
font-size: 0.88rem;
}
.chip {
padding: 0.15rem 0.55rem;
color: var(--muted);
font-size: 0.78rem;
border: 1px solid var(--line);
border-radius: 20px;
}
.chip.an {
color: var(--text);
border-color: var(--accent);
}
.host-zeile {
display: flex;
flex-wrap: wrap;

View File

@@ -1,11 +1,12 @@
<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
// Ein Kommentar in der Diskussion: der Text, darunter Herz, 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 { computed, ref } from 'vue'
import { rendern } from '../markdown'
import MarkdownFeld from './MarkdownFeld.vue'
import PersonName from './PersonName.vue'
const props = defineProps({
kommentar: { type: Object, required: true },
@@ -32,6 +33,22 @@ async function antworten() {
}
}
async function herz() {
if (!props.angemeldet) return
await fetch('/api/watched/hearts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note_id: props.kommentar.id }),
})
}
// Wer das Herz gegeben hat, sieht nur der Host — als Tooltip
const herzTitel = computed(() => {
const namen = props.host ? props.kommentar.hearted_by || [] : []
if (namen.length) return namen.join(', ')
return props.kommentar.hearted ? 'Herz zurücknehmen' : 'Sehe ich auch so'
})
async function loeschen() {
await fetch(`/api/watched-notes/${props.kommentar.id}`, { method: 'DELETE' })
}
@@ -50,7 +67,17 @@ function datum(iso) {
<div class="inhalt">
<div class="markdown" v-html="rendern(kommentar.text)"></div>
<p class="meta">
<span :class="{ eigen: kommentar.mine }">{{ kommentar.name || 'Anonym' }}</span>
<button
type="button"
class="pille"
:class="{ an: kommentar.hearted, hat: kommentar.hearts > 0 }"
:disabled="!angemeldet"
:title="herzTitel"
@click="herz"
>
♥ {{ kommentar.hearts }}
</button>
<span :class="{ eigen: kommentar.mine }"><PersonName :name="kommentar.name" /></span>
<span class="punkt">·</span>{{ datum(kommentar.created_at) }}
<template v-if="angemeldet">
<span class="punkt">·</span>
@@ -113,6 +140,37 @@ function datum(iso) {
background: rgba(255, 255, 255, 0.02);
}
/* Das Herz klein vor dem Namen, in der Zeile mit Datum und Antworten */
.pille {
margin-right: 0.5rem;
padding: 0 0.35rem;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
border: 1px solid var(--line);
border-radius: 20px;
}
.pille:hover:not(:disabled) {
color: var(--text);
border-color: var(--accent);
}
.pille.hat {
color: var(--text);
}
.pille.an {
color: var(--accent);
border-color: var(--accent);
}
.pille:disabled {
cursor: default;
}
.inhalt {
display: flex;
flex: 1;
@@ -121,7 +179,11 @@ function datum(iso) {
gap: 0.15rem;
}
/* Herz, Name, Datum und Antworten mittig auf einer Linie */
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
color: var(--muted);
font-size: 0.76rem;
line-height: 1.4;
@@ -204,6 +266,11 @@ function datum(iso) {
}
@media (max-width: 640px), (pointer: coarse) {
.pille {
padding: 0.25rem 0.55rem;
font-size: 0.82rem;
}
.link,
.weg {
padding: 0.3rem 0.4rem;

View File

@@ -1,6 +1,7 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import FilmLogin from './FilmLogin.vue'
import PersonName from './PersonName.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
@@ -19,6 +20,44 @@ const halter = ref(null)
// Wer einen Anmeldefilm hinterlegt hat, muss ihn erst finden
const anmeldung = ref(null)
// Der eigene Eintrag aus der Runde — der trägt Punkte und Level
const meins = computed(() => props.items.find((u) => u.id === props.mine?.id) || null)
// Wie weit der Balken zum nächsten Level gefüllt ist
function anteil(u) {
const weg = u.bis - u.von
return weg > 0 ? Math.round(((u.punkte - u.von) / weg) * 100) : 0
}
// Kommen Punkte dazu, schwebt die Zahl kurz unter dem Knopf davon.
// Steigt das Level, sprühen Funken und der Badge pulst.
const zuwachs = ref(null)
const feuerwerk = ref(false)
let vorher = null
let zuwachsTimer = null
let feuerTimer = null
watch(meins, (jetzt) => {
if (!jetzt) {
vorher = null
return
}
if (vorher && vorher.id === jetzt.id) {
const diff = jetzt.punkte - vorher.punkte
if (diff !== 0) {
zuwachs.value = { key: Date.now(), wert: diff }
clearTimeout(zuwachsTimer)
zuwachsTimer = setTimeout(() => (zuwachs.value = null), 1400)
}
if (jetzt.level > vorher.level) {
feuerwerk.value = true
clearTimeout(feuerTimer)
feuerTimer = setTimeout(() => (feuerwerk.value = false), 1200)
}
}
vorher = { id: jetzt.id, punkte: jetzt.punkte, level: jetzt.level }
})
function waehlen(benutzer) {
offen.value = false
if (!benutzer) return props.anmelden(null)
@@ -57,10 +96,39 @@ onUnmounted(() => {
<template>
<div ref="halter" class="wahl">
<button type="button" class="knopf" :class="{ offen }" @click="offen = !offen">
{{ mine ? mine.name : 'Anonym' }}
<button
type="button"
class="knopf"
:class="{ offen, schlicht: !meins }"
:title="meins ? `Erfahrung: ${meins.punkte - meins.von} / ${meins.bis - meins.von}` : ''"
@click="offen = !offen"
>
<!-- Wie ein Spiel-HUD: links der Levelkreis, rechts oben der Name,
darunter die Erfahrungsleiste, die am Kreis hängt -->
<template v-if="meins">
<span class="kreis" :class="{ puls: feuerwerk }">
{{ meins.level }}
<span v-if="feuerwerk" class="feuerwerk" aria-hidden="true">
<span v-for="i in 12" :key="i" class="funke" :style="{ '--winkel': `${i * 30}deg` }"></span>
</span>
</span>
<span class="rechts">
<span class="name">{{ mine.name }}</span>
<span class="leiste">
<span
class="balken"
:style="{ width: `calc(var(--kreisrand) + (100% - var(--kreisrand)) * ${anteil(meins) / 100})` }"
></span>
</span>
</span>
</template>
<template v-else>Anonym</template>
</button>
<span v-if="zuwachs" :key="zuwachs.key" class="zuwachs" :class="{ minus: zuwachs.wert < 0 }">
{{ zuwachs.wert > 0 ? '+' : '−' }}{{ Math.abs(zuwachs.wert) }}
</span>
<ul v-if="offen" class="menue">
<li>
<button type="button" :class="{ aktiv: !mine }" @click="waehlen(null)">
@@ -69,8 +137,10 @@ onUnmounted(() => {
</li>
<li v-for="u in items" :key="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>
<span class="zeile" :title="`Erfahrung: ${u.punkte - u.von} / ${u.bis - u.von}`">
<PersonName :name="u.name" :level="u.level" />
<span v-if="u.geschuetzt" class="schloss" title="Mit Anmeldefilm">&#9679;</span>
</span>
</button>
</li>
</ul>
@@ -85,6 +155,205 @@ onUnmounted(() => {
</template>
<style scoped>
/* Das Level als kleine Zahl im Badge */
.badge {
display: inline-block;
min-width: 1.3rem;
margin-left: 0.45rem;
padding: 0.05rem 0.35rem;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
line-height: 1.3;
text-align: center;
background: var(--accent);
border-radius: 20px;
}
.zeile {
display: flex;
align-items: center;
}
.zeile .badge {
margin-left: auto;
padding-left: 0.35rem;
}
/* Der Fortschritt zum nächsten Level, als Strich am unteren Rand */
/* Kein Kasten: Kreis und Leiste stehen frei in der Kopfzeile */
.knopf:not(.schlicht) {
display: flex;
align-items: center;
padding: 0;
background: none;
border: none;
}
/* Der Levelkreis, groß und vorn — liegt über dem Anfang der Leiste */
.kreis {
position: relative;
z-index: 1;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
padding-top: 0.1em;
color: #f3d9db;
font-size: 0.95rem;
font-weight: 700;
/* Wie die kleinen Badges: Weinrot mit hellem Rand. Der Ring in
Hintergrundfarbe hält die Leiste einen Pixel vom Kreis weg. */
background: #6e1418;
border: 1px solid rgba(255, 255, 255, 0.45);
border-radius: 50%;
box-shadow: 0 0 0 1px var(--bg);
}
/* Rechts vom Kreis: der Name oben, die Leiste darunter */
.rechts {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.name {
padding-left: 0.5rem;
color: var(--text);
font-size: 0.9rem;
font-weight: 600;
line-height: 1.1;
white-space: nowrap;
}
.knopf:hover .name,
.knopf.offen .name {
color: #fff;
}
/* Die Erfahrungsleiste schmiegt sich an den Kreis: sie beginnt unter
ihm, der Ring des Kreises schneidet seine Rundung hinein. Die Füllung
startet erst am Kreisrand — so ist jeder Punkt Fortschritt zu sehen. */
.leiste {
/* So weit reicht die Leiste unter den Kreis */
--unter-kreis: 0.9rem;
/* Wo der Kreisrand die Leistenmitte schneidet — ab hier zählt die
Füllung. Folgt aus Kreisradius 1.05rem und der Lage der Leiste. */
--kreisrand: 0.72rem;
position: relative;
width: calc(3.75rem + var(--unter-kreis));
height: 0.3rem;
margin-left: calc(-1 * var(--unter-kreis));
overflow: hidden;
background: rgba(255, 255, 255, 0.16);
border-radius: 0 999px 999px 0;
/* Die Kreisform samt einem Pixel Luft aus Leiste und Füllung schneiden */
mask: radial-gradient(
circle at -0.15rem -0.45rem,
transparent calc(1.05rem + 2px),
#000 calc(1.05rem + 3px)
);
}
/* Die Füllung beginnt ebenfalls unter dem Kreis — die Maske gibt ihr
dieselbe Rundung, sichtbar wird sie ab dem Kreisrand */
.balken {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: var(--accent);
transition: width 0.6s ease;
}
/* Die gewonnenen Punkte sinken unter dem Knopf davon */
.zuwachs {
position: absolute;
top: 100%;
left: 50%;
z-index: 41;
color: var(--accent);
font-size: 0.95rem;
font-weight: 700;
pointer-events: none;
animation: schweben 1.4s ease-out forwards;
}
.zuwachs.minus {
color: var(--muted);
}
@keyframes schweben {
0% {
opacity: 0;
transform: translate(-50%, -4px);
}
15% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(-50%, 26px);
}
}
/* Level-Up: Funken aus dem Kreis, der Kreis pulst */
.feuerwerk {
position: absolute;
top: 50%;
left: 50%;
z-index: 41;
pointer-events: none;
}
.funke {
position: absolute;
width: 5px;
height: 5px;
background: var(--accent);
border-radius: 50%;
transform: rotate(var(--winkel)) translateX(0);
animation: spruehen 1s ease-out forwards;
}
.funke:nth-child(3n) {
background: #f5c518;
}
.funke:nth-child(3n + 1) {
background: #fff;
}
@keyframes spruehen {
0% {
opacity: 1;
transform: rotate(var(--winkel)) translateX(0) scale(1);
}
100% {
opacity: 0;
transform: rotate(var(--winkel)) translateX(48px) scale(0.3);
}
}
.kreis.puls {
animation: puls 0.7s ease-out;
}
@keyframes puls {
0% {
transform: scale(1);
}
40% {
transform: scale(1.6);
box-shadow: 0 0 12px var(--accent);
}
100% {
transform: scale(1);
}
}
.wahl {
position: relative;
align-self: center;
@@ -92,9 +361,10 @@ onUnmounted(() => {
}
.knopf {
padding: 0.32rem 0.7rem;
padding: 0.3rem 0.8rem 0.3rem 0.4rem;
color: var(--text);
font-size: 0.82rem;
text-align: left;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 6px;

View File

@@ -0,0 +1,54 @@
<script setup>
// Ein Name aus der Runde, mit dem Level als kleinem Badge davor. Das
// Level kommt aus der Runde, die App stellt sie bereit — so muss keine
// Liste durch alle Ebenen gereicht werden. Unbekannte Namen (Anonym,
// gelöscht) stehen ohne Badge.
import { computed, inject } from 'vue'
const props = defineProps({
name: { type: String, default: '' },
level: { type: Number, default: null },
})
const levelVon = inject('levelVon', () => null)
const stufe = computed(() => props.level ?? levelVon(props.name))
</script>
<template>
<span class="person">
<span v-if="stufe" class="lvl">{{ stufe }}</span>{{ name || 'Anonym' }}
</span>
</template>
<style scoped>
/* Badge und Name mittig auf einer Linie, egal wie groß die Schrift ist */
.person {
display: inline-flex;
gap: 0.3em;
align-items: center;
white-space: nowrap;
}
/* Ein Kreis, auch bei zweistelligen Zahlen — dann etwas enger gesetzt */
.lvl {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.65em;
height: 1.65em;
/* Ziffern haben keine Unterlänge — rechnerisch mittig sitzen sie
optisch zu hoch. Ein Hauch Luft oben gleicht das aus. */
padding-top: 0.12em;
color: #f3d9db;
font-size: 0.68em;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1;
/* Weinrot statt Signalrot: in Listen mit vielen Namen bleibt es ruhig */
background: #6e1418;
/* Der helle Rand hebt den Kreis vom dunklen Grund ab (3:1), die
Fläche darf ruhig bleiben */
border: 1px solid rgba(255, 255, 255, 0.45);
border-radius: 50%;
}
</style>

View File

@@ -3,6 +3,7 @@
// 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'
import PersonName from './PersonName.vue'
const props = defineProps({
// [{ name, anzahl }] — wer überhaupt etwas gesammelt hat
@@ -104,7 +105,7 @@ function zuruecksetzen() {
:class="{ on: gewaehlt.includes(person.name) }"
@click="umschalten(person.name)"
>
{{ person.name }}
<PersonName :name="person.name" />
<span class="count">{{ person.anzahl }}</span>
</button>
</div>

View File

@@ -2,6 +2,7 @@
import { computed, reactive, ref } from 'vue'
import FilmSuche from './FilmSuche.vue'
import { posterUrl } from '../images'
import PersonName from './PersonName.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
@@ -87,7 +88,7 @@ async function entfernen(user) {
<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>
<span class="name"><PersonName :name="u.name" :level="u.level" /></span>
<button
v-if="u.geschuetzt"
type="button"

View File

@@ -2,6 +2,7 @@
import { computed, ref } from 'vue'
import { posterUrl } from '../images'
import { FARBEN } from '../composables/useZiehung'
import PersonName from './PersonName.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
@@ -12,6 +13,9 @@ const props = defineProps({
merkliste: { type: Array, default: () => [] },
mine: { type: Number, default: null },
gesehenIds: { type: Set, default: () => new Set() },
// Wer ohne Vorschlag dabei ist, und ob ich das bin
dabei: { type: Array, default: () => [] },
dabeiMine: Boolean,
})
const emit = defineEmits(['open', 'fehler'])
@@ -31,6 +35,11 @@ const auswahl = computed(() => nachGenre(nurHorror.value))
const anzahlHorror = computed(() => nachGenre(true).length)
const anzahlOhne = computed(() => nachGenre(false).length)
// Dabei sein zählt auch ohne Film — für die Anwesenheit beim Dreh
async function dabeiUmschalten() {
await fetch('/api/dabei', { method: 'POST' })
}
async function wuerfeln() {
const treffer = auswahl.value
if (!treffer.length || wuerfelt.value) return
@@ -72,6 +81,16 @@ async function wuerfeln() {
</button>
</div>
<button
v-if="angemeldet && mine === null"
type="button"
class="dabei-knopf"
:class="{ an: dabeiMine }"
@click="dabeiUmschalten"
>
{{ dabeiMine ? 'Ich bin dabei ✓' : 'Dabei ohne Vorschlag' }}
</button>
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
<ul>
@@ -98,15 +117,63 @@ async function wuerfeln() {
<span v-if="item.genres?.length" class="genres">
{{ item.genres.join(' · ') }}
</span>
<span class="von">{{ item.name || 'Anonym' }}</span>
<span class="von"><PersonName :name="item.name" /></span>
</span>
</button>
</li>
</ul>
<p v-if="dabei.length" class="dabei">
<span class="dabei-titel">Dabei ohne Vorschlag</span>
<span class="dabei-namen">
<PersonName v-for="n in dabei" :key="n" :name="n" />
</span>
</p>
</aside>
</template>
<style scoped>
.dabei-knopf {
width: 100%;
margin-bottom: 1.2rem;
padding: 0.5rem 0.8rem;
color: var(--muted);
font-size: 0.85rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.dabei-knopf:hover {
color: var(--text);
border-color: var(--accent);
}
.dabei-knopf.an {
color: var(--text);
border-color: #6bbf7a;
}
.dabei {
margin-top: 1rem;
font-size: 0.85rem;
}
.dabei-namen {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.7rem;
}
.dabei-titel {
display: block;
margin-bottom: 0.2rem;
color: var(--muted);
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.sammlung-wuerfeln {
display: flex;
flex-direction: column;

View File

@@ -12,6 +12,10 @@ const props = defineProps({
host: Boolean,
// Eine Wertung gehört einer Person — ohne Namen gäbe es keine
angemeldet: Boolean,
runde: { type: Array, default: () => [] },
// Wer gerade gewählt ist — wechselt der Name, gehören die "Du:"-Sterne
// einer anderen Person und müssen neu geladen werden
ich: { type: Object, default: null },
})
const emit = defineEmits(['open', 'changed'])
@@ -230,6 +234,11 @@ async function laden_() {
}
}
watch(
() => props.ich?.id,
() => laden_()
)
onMounted(() => {
laden_()
window.addEventListener('keydown', tastatur)
@@ -270,6 +279,7 @@ onUnmounted(() => {
:item="item"
:host="host"
:angemeldet="angemeldet"
:runde="runde"
@open="emit('open', $event)"
/>
</div>

View File

@@ -1,14 +1,17 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { posterUrl } from '../images'
import { abonnieren } from '../live'
import { klang } from '../klang'
import { useZiehung } from '../composables/useZiehung'
import VorschlagsListe from './VorschlagsListe.vue'
import PersonName from './PersonName.vue'
defineProps({
const props = defineProps({
host: Boolean,
angemeldet: Boolean,
// Wer gerade gewählt ist — "Dabei ohne Vorschlag" gehört zur Person
ich: { type: Object, default: null },
// Beides hält die App schon aktuell — auch nach einem Namenswechsel
merkliste: { type: Array, default: () => [] },
mine: { type: Number, default: null },
@@ -17,6 +20,9 @@ defineProps({
const emit = defineEmits(['open', 'reset'])
const items = ref([])
// Wer ohne Vorschlag dabei ist — zählt beim Dreh als anwesend
const dabei = ref([])
const dabeiMine = ref(false)
const laden = ref(true)
const fehler = ref('')
@@ -48,6 +54,8 @@ async function laden_() {
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
const daten = await resp.json()
items.value = daten.items
dabei.value = daten.dabei || []
dabeiMine.value = !!daten.dabei_mine
ruhen()
} catch (err) {
fehler.value = err.message
@@ -83,6 +91,11 @@ async function oeffnen(item) {
if (resp.ok) emit('open', await resp.json())
}
watch(
() => props.ich?.id,
() => laden_()
)
onMounted(async () => {
await laden_()
await laufendenDrehHolen()
@@ -162,7 +175,7 @@ defineExpose({ neu: laden_ })
<p class="label">Der Film des Abends</p>
<h2>{{ gewinner.title }}</h2>
<p class="jahr">
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
{{ gewinner.year }}<span v-if="gewinner.name"> · von <PersonName :name="gewinner.name" /></span>
</p>
</div>
</div>
@@ -176,6 +189,8 @@ defineExpose({ neu: laden_ })
:merkliste="merkliste"
:mine="mine"
:gesehen-ids="gesehenIds"
:dabei="dabei"
:dabei-mine="dabeiMine"
@open="oeffnen"
@fehler="fehler = $event"
/>

View File

@@ -1,5 +1,6 @@
<script setup>
import { computed, reactive, ref } from 'vue'
import PersonName from './PersonName.vue'
const props = defineProps({
items: { type: Array, default: () => [] },
@@ -180,7 +181,7 @@ function datum(iso) {
<template v-else>
<p class="text">{{ w.text }}</p>
<p class="meta">
<span :class="{ eigen: w.mine }">{{ w.name || 'Anonym' }}</span>
<span :class="{ eigen: w.mine }"><PersonName :name="w.name" /></span>
<span class="punkt">·</span>{{ datum(w.created_at) }}
<template v-if="w.done_at">
<span class="punkt">·</span>erledigt
@@ -200,7 +201,7 @@ function datum(iso) {
<div v-if="aufgeklappt.has(w.id)" class="notizen">
<p v-for="n in w.notes" :key="n.id" class="notiz">
<span class="wer" :class="{ eigen: n.mine }">{{ n.name || 'Anonym' }}</span>
<span class="wer" :class="{ eigen: n.mine }"><PersonName :name="n.name" /></span>
<span class="notiz-text">{{ n.text }}</span>
<button
v-if="host"

View File

@@ -104,6 +104,9 @@ export function useMeins({ host }) {
if (nachricht.type === 'suggestions' || nachricht.type === 'reset') ladeVorschlag()
else if (nachricht.type === 'users') ladeRunde()
else if (nachricht.type === 'features') ladeWuensche()
// Punkte und Level hängen an Wertungen, Kommentaren und Herzen —
// die Runde trägt sie, also mit nachladen
if (nachricht.type === 'watched') ladeRunde()
})
})