update
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">●</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">●</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;
|
||||
|
||||
54
frontend/src/components/PersonName.vue
Normal file
54
frontend/src/components/PersonName.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user