704 lines
17 KiB
Vue
704 lines
17 KiB
Vue
<script setup>
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
import { posterUrl } from '../images'
|
|
import { abonnieren } from '../live'
|
|
|
|
const props = defineProps({
|
|
host: Boolean,
|
|
// Beides hält die App schon aktuell — auch nach einem Namenswechsel
|
|
merkliste: { type: Array, default: () => [] },
|
|
mine: { type: Number, default: null },
|
|
gesehenIds: { type: Set, default: () => new Set() },
|
|
})
|
|
const emit = defineEmits(['open', 'reset'])
|
|
|
|
const items = ref([])
|
|
const laden = ref(true)
|
|
const fehler = ref('')
|
|
|
|
const winkel = ref(0)
|
|
const dreht = ref(false)
|
|
const gewinner = ref(null)
|
|
const dauer = ref(4.2)
|
|
|
|
const GROESSE = 420
|
|
const MITTE = GROESSE / 2
|
|
const RADIUS = MITTE - 6
|
|
|
|
// Kräftige, gut unterscheidbare Töne — nicht die Akzentfarbe, sonst
|
|
// verschwimmt der Zeiger mit dem Rad
|
|
const FARBEN = [
|
|
'#b3202a', '#1f6f8b', '#c9772b', '#4c6b2f', '#6d3f8f',
|
|
'#9c2d5c', '#2b7a6f', '#8a6a1f', '#3a4a8f', '#7a3520',
|
|
]
|
|
|
|
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
|
let abbestellen = null
|
|
|
|
function verbinden() {
|
|
abbestellen = abonnieren((nachricht) => {
|
|
if (nachricht.type === 'suggestions') laden_()
|
|
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
|
else if (nachricht.type === 'reset') zurueckgesetzt()
|
|
})
|
|
}
|
|
|
|
function abspielen(ergebnis, uebersprungen = 0) {
|
|
gewinner.value = null
|
|
dreht.value = true
|
|
|
|
const rest = Math.max(0, ergebnis.dauer - uebersprungen)
|
|
if (uebersprungen > 0) {
|
|
// Wer mitten im Dreh dazukommt, springt an die passende Stelle
|
|
dauer.value = rest
|
|
} else {
|
|
dauer.value = ergebnis.dauer
|
|
}
|
|
|
|
// Immer vorwärts drehen, egal wie oft schon gedreht wurde
|
|
winkel.value = winkel.value - (winkel.value % 360) + ergebnis.winkel
|
|
|
|
window.setTimeout(() => {
|
|
dreht.value = false
|
|
gewinner.value = ergebnis.gewinner
|
|
}, rest * 1000)
|
|
}
|
|
|
|
async function laufendenDrehHolen() {
|
|
try {
|
|
const resp = await fetch('/api/spin')
|
|
if (!resp.ok) return
|
|
const daten = await resp.json()
|
|
if (!daten.result) return
|
|
if (daten.running) {
|
|
abspielen(daten.result, daten.elapsed)
|
|
} else {
|
|
// Dreh ist durch: Rad ohne Animation auf die Endstellung setzen,
|
|
// sonst zeigt der Zeiger woanders hin als die Gewinnerkarte
|
|
dauer.value = 0
|
|
winkel.value = daten.result.winkel
|
|
gewinner.value = daten.result.gewinner
|
|
}
|
|
} catch {
|
|
/* dann eben ohne */
|
|
}
|
|
}
|
|
|
|
async function laden_() {
|
|
laden.value = true
|
|
fehler.value = ''
|
|
try {
|
|
const resp = await fetch('/api/suggestions')
|
|
if (!resp.ok) throw new Error('Vorschläge konnten nicht geladen werden')
|
|
const daten = await resp.json()
|
|
items.value = daten.items
|
|
} catch (err) {
|
|
fehler.value = err.message
|
|
} finally {
|
|
laden.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await laden_()
|
|
await laufendenDrehHolen()
|
|
verbinden()
|
|
})
|
|
|
|
onUnmounted(() => abbestellen?.())
|
|
|
|
defineExpose({ neu: laden_ })
|
|
|
|
const segmente = computed(() => {
|
|
const n = items.value.length
|
|
if (!n) return []
|
|
const schritt = 360 / n
|
|
return items.value.map((item, i) => {
|
|
const start = i * schritt
|
|
const ende = start + schritt
|
|
const mitte = start + schritt / 2
|
|
// Auf der linken Hälfte stünde die Schrift kopfüber — dort läuft sie
|
|
// von außen nach innen
|
|
const gespiegelt = mitte > 180
|
|
// Das Cover liegt als Rechteck radial im Stück: von der Nabe nach
|
|
// außen, so breit wie die Sehne am Rand. So landet die Postermitte
|
|
// auf halbem Radius — dort, wo das Stück am besten zu sehen ist.
|
|
// Ein einziger Vorschlag ist ein Vollkreis — da gibt es keine Sehne,
|
|
// das Bild deckt dann den ganzen Kreis als Quadrat ab.
|
|
const voll = n === 1
|
|
const sehne = 2 * RADIUS * Math.sin(((schritt / 2) * Math.PI) / 180)
|
|
const bildBreite = voll ? 2 * RADIUS : Math.max(sehne + 12, 60)
|
|
return {
|
|
item,
|
|
farbe: FARBEN[i % FARBEN.length],
|
|
pfad: kreisstueck(start, ende),
|
|
bild: posterUrl(item.poster_path, 'w342'),
|
|
bildX: MITTE - bildBreite / 2,
|
|
bildY: MITTE - RADIUS - (voll ? 0 : 6),
|
|
bildBreite,
|
|
bildHoehe: voll ? 2 * RADIUS : RADIUS + 6,
|
|
// Im Vollkreis läge die Segmentmitte bei 180° — das Cover stünde
|
|
// kopfüber. Ohne Drehung bleibt es aufrecht.
|
|
bildDreh: voll ? 0 : mitte,
|
|
mitte,
|
|
textDreh: gespiegelt ? mitte + 90 : mitte - 90,
|
|
gespiegelt,
|
|
// Unter dieser Segmentbreite passt kein lesbares Wort mehr hinein
|
|
breit: schritt > 9,
|
|
kurz: kuerzen(item.title, schritt),
|
|
}
|
|
})
|
|
})
|
|
|
|
function kuerzen(titel, schritt) {
|
|
// Je schmaler das Segment, desto weniger Zeichen bleiben lesbar
|
|
const max = schritt > 30 ? 24 : schritt > 18 ? 18 : 13
|
|
return titel.length > max ? titel.slice(0, max - 1) + '…' : titel
|
|
}
|
|
|
|
function punkt(grad) {
|
|
const rad = ((grad - 90) * Math.PI) / 180
|
|
return [MITTE + RADIUS * Math.cos(rad), MITTE + RADIUS * Math.sin(rad)]
|
|
}
|
|
|
|
function kreisstueck(von, bis) {
|
|
// Ein einzelner Vorschlag ergäbe einen Vollkreis, den ein Pfad nicht
|
|
// zeichnen kann — dann bleibt es bei zwei Halbkreisen
|
|
if (bis - von >= 360) {
|
|
return `M ${MITTE} ${MITTE - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${
|
|
MITTE + RADIUS
|
|
} A ${RADIUS} ${RADIUS} 0 1 1 ${MITTE} ${MITTE - RADIUS} Z`
|
|
}
|
|
const [x1, y1] = punkt(von)
|
|
const [x2, y2] = punkt(bis)
|
|
const gross = bis - von > 180 ? 1 : 0
|
|
return `M ${MITTE} ${MITTE} L ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${gross} 1 ${x2} ${y2} Z`
|
|
}
|
|
|
|
async function drehen() {
|
|
if (dreht.value || !items.value.length) return
|
|
// Der Server lost aus und schickt das Ergebnis an alle — auch an uns
|
|
try {
|
|
const resp = await fetch('/api/spin', { method: 'POST' })
|
|
if (!resp.ok) fehler.value = 'Drehen hat nicht geklappt.'
|
|
} catch {
|
|
fehler.value = 'Drehen hat nicht geklappt.'
|
|
}
|
|
}
|
|
|
|
function zurueckgesetzt() {
|
|
// Ohne Dauer 0 würde das Rad die Strecke zurück sichtbar abfahren
|
|
dauer.value = 0
|
|
winkel.value = 0
|
|
gewinner.value = null
|
|
dreht.value = false
|
|
emit('reset')
|
|
laden_()
|
|
}
|
|
|
|
async function zuruecksetzen() {
|
|
// Der Server meldet den Reset an alle zurück, auch an uns
|
|
try {
|
|
await fetch('/api/suggestions/alle', { method: 'DELETE' })
|
|
} catch {
|
|
fehler.value = 'Zurücksetzen hat nicht geklappt.'
|
|
}
|
|
}
|
|
|
|
// Aus der eigenen Sammlung würfeln. Der Schalter trennt Horror von allem
|
|
// anderen — für Abende, an denen es keiner gruselig mag.
|
|
const nurHorror = ref(true)
|
|
const sammlung = computed(() => props.merkliste.filter((e) => e.mine))
|
|
const wuerfelt = ref(false)
|
|
|
|
function nachGenre(horror) {
|
|
return sammlung.value.filter(
|
|
(e) => (e.genres || []).includes('Horror') === horror
|
|
)
|
|
}
|
|
|
|
const auswahl = computed(() => nachGenre(nurHorror.value))
|
|
const anzahlHorror = computed(() => nachGenre(true).length)
|
|
const anzahlOhne = computed(() => nachGenre(false).length)
|
|
|
|
async function wuerfeln() {
|
|
const treffer = auswahl.value
|
|
if (!treffer.length || wuerfelt.value) return
|
|
wuerfelt.value = true
|
|
const film = treffer[Math.floor(Math.random() * treffer.length)]
|
|
try {
|
|
const resp = await fetch('/api/suggestions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ movie_id: film.movie_id }),
|
|
})
|
|
if (!resp.ok) throw new Error()
|
|
} catch {
|
|
fehler.value = 'Eintragen hat nicht geklappt.'
|
|
} finally {
|
|
wuerfelt.value = false
|
|
}
|
|
}
|
|
|
|
async function oeffnen(item) {
|
|
const resp = await fetch(`/api/search/${item.movie_id}`)
|
|
if (resp.ok) emit('open', await resp.json())
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="wheel-tab">
|
|
<div class="buehne">
|
|
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
|
|
|
<div class="rad-halter">
|
|
<div class="zeiger"></div>
|
|
<svg
|
|
class="rad"
|
|
:class="{ dreht }"
|
|
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
|
|
:style="{
|
|
transform: `rotate(${winkel}deg)`,
|
|
transitionDuration: `${dauer}s`,
|
|
}"
|
|
>
|
|
<defs>
|
|
<!-- Zur Nabe hin wird es dunkel, damit der Titel lesbar bleibt -->
|
|
<radialGradient
|
|
id="rad-schatten"
|
|
gradientUnits="userSpaceOnUse"
|
|
:cx="MITTE"
|
|
:cy="MITTE"
|
|
:r="RADIUS"
|
|
>
|
|
<stop offset="0" stop-color="#0a0a0c" stop-opacity="0.92" />
|
|
<stop offset="0.45" stop-color="#0a0a0c" stop-opacity="0.55" />
|
|
<stop offset="1" stop-color="#0a0a0c" stop-opacity="0.1" />
|
|
</radialGradient>
|
|
<clipPath v-for="seg in segmente" :id="`seg-${seg.item.id}`" :key="seg.item.id">
|
|
<path :d="seg.pfad" />
|
|
</clipPath>
|
|
</defs>
|
|
<circle :cx="MITTE" :cy="MITTE" :r="RADIUS" fill="var(--bg-soft)" />
|
|
<g v-for="(seg, i) in segmente" :key="seg.item.id">
|
|
<g :clip-path="`url(#seg-${seg.item.id})`">
|
|
<path :d="seg.pfad" :fill="seg.farbe" />
|
|
<image
|
|
v-if="seg.bild"
|
|
:href="seg.bild"
|
|
:x="seg.bildX"
|
|
:y="seg.bildY"
|
|
:width="seg.bildBreite"
|
|
:height="seg.bildHoehe"
|
|
preserveAspectRatio="xMidYMid slice"
|
|
:transform="`rotate(${seg.bildDreh} ${MITTE} ${MITTE})`"
|
|
/>
|
|
<path :d="seg.pfad" fill="url(#rad-schatten)" />
|
|
</g>
|
|
<!-- Der farbige Rand ist das Gegenstück zum Punkt in der Liste -->
|
|
<path :d="seg.pfad" fill="none" :stroke="seg.farbe" stroke-width="3" />
|
|
<text
|
|
v-if="seg.breit"
|
|
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
|
|
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
|
|
:y="MITTE + 4"
|
|
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
|
|
class="beschriftung"
|
|
>
|
|
{{ seg.kurz }}
|
|
</text>
|
|
</g>
|
|
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
|
|
</svg>
|
|
</div>
|
|
|
|
<div v-if="host" class="knoepfe">
|
|
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
|
|
{{ dreht ? 'dreht…' : 'Drehen' }}
|
|
</button>
|
|
<button
|
|
class="reset"
|
|
type="button"
|
|
:disabled="dreht || !items.length"
|
|
@click="zuruecksetzen"
|
|
>
|
|
Zurücksetzen
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="gewinner" class="gewinner" @click="oeffnen(gewinner)">
|
|
<img
|
|
v-if="posterUrl(gewinner.poster_path, 'w154')"
|
|
:src="posterUrl(gewinner.poster_path, 'w154')"
|
|
:alt="gewinner.title"
|
|
/>
|
|
<div>
|
|
<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>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="liste">
|
|
<div v-if="!host" class="sammlung-wuerfeln">
|
|
<div class="schalter">
|
|
<button type="button" :class="{ an: nurHorror }" @click="nurHorror = true">
|
|
Horror ({{ anzahlHorror }})
|
|
</button>
|
|
<button type="button" :class="{ an: !nurHorror }" @click="nurHorror = false">
|
|
Kein Horror ({{ anzahlOhne }})
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="wuerfeln"
|
|
:disabled="!auswahl.length || wuerfelt"
|
|
@click="wuerfeln"
|
|
>
|
|
Zufällig aus Sammlung
|
|
</button>
|
|
</div>
|
|
|
|
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
|
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
|
<ul>
|
|
<li v-for="(item, i) in items" :key="item.id">
|
|
<button
|
|
type="button"
|
|
class="eintrag"
|
|
:class="{ eigen: item.id === mine, gesehen: gesehenIds.has(item.movie_id) }"
|
|
@click="oeffnen(item)"
|
|
>
|
|
<span
|
|
class="cover"
|
|
:style="{ borderColor: FARBEN[i % FARBEN.length] }"
|
|
>
|
|
<img
|
|
v-if="posterUrl(item.poster_path, 'w154')"
|
|
:src="posterUrl(item.poster_path, 'w154')"
|
|
:alt="item.title"
|
|
loading="lazy"
|
|
/>
|
|
</span>
|
|
<span class="daten">
|
|
<span class="titel">{{ item.title }}</span>
|
|
<span class="meta">
|
|
{{ item.year }}<template v-if="item.runtime"> · {{ item.runtime }} Min.</template>
|
|
</span>
|
|
<span v-if="item.genres?.length" class="genres">
|
|
{{ item.genres.join(' · ') }}
|
|
</span>
|
|
<span class="von">{{ item.name || 'Anonym' }}</span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</aside>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.wheel-tab {
|
|
display: grid;
|
|
grid-template-columns: 1fr 280px;
|
|
gap: 2rem;
|
|
align-items: start;
|
|
}
|
|
|
|
.buehne {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
.rad-halter {
|
|
position: relative;
|
|
width: min(420px, 80vw);
|
|
}
|
|
|
|
.rad {
|
|
display: block;
|
|
width: 100%;
|
|
/* Ohne Übergang springt das Rad einfach zum Ergebnis */
|
|
transition-property: transform;
|
|
transition-timing-function: cubic-bezier(0.17, 0.67, 0.12, 1);
|
|
}
|
|
|
|
.beschriftung {
|
|
fill: #fff;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
/* Unter der Schrift liegt jetzt ein Cover statt einer Fläche — die
|
|
Kontur muss auch auf hellen Bildstellen tragen */
|
|
paint-order: stroke;
|
|
stroke: rgba(0, 0, 0, 0.75);
|
|
stroke-width: 3.5px;
|
|
}
|
|
|
|
.zeiger {
|
|
position: absolute;
|
|
top: -6px;
|
|
left: 50%;
|
|
z-index: 2;
|
|
width: 0;
|
|
height: 0;
|
|
margin-left: -11px;
|
|
border-top: 20px solid var(--text);
|
|
border-right: 11px solid transparent;
|
|
border-left: 11px solid transparent;
|
|
}
|
|
|
|
.knoepfe {
|
|
display: flex;
|
|
gap: 0.7rem;
|
|
}
|
|
|
|
.reset {
|
|
padding: 0.7rem 1.4rem;
|
|
color: var(--muted);
|
|
font-size: 0.95rem;
|
|
background: var(--bg-soft);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.reset:hover:not(:disabled) {
|
|
color: var(--text);
|
|
border-color: var(--muted);
|
|
}
|
|
|
|
.reset:disabled {
|
|
opacity: 0.4;
|
|
cursor: default;
|
|
}
|
|
|
|
.leer {
|
|
color: var(--muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.dreh {
|
|
padding: 0.7rem 2.2rem;
|
|
color: #fff;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
background: var(--accent);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.dreh:disabled {
|
|
background: var(--line);
|
|
color: var(--muted);
|
|
cursor: default;
|
|
}
|
|
|
|
.sammlung-wuerfeln {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1.6rem;
|
|
}
|
|
|
|
.schalter {
|
|
display: flex;
|
|
overflow: hidden;
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.schalter button {
|
|
flex: 1;
|
|
padding: 0.45rem 0.9rem;
|
|
color: var(--muted);
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.schalter button.an {
|
|
color: var(--text);
|
|
background: var(--bg-soft);
|
|
}
|
|
|
|
.wuerfeln {
|
|
padding: 0.5rem 1.1rem;
|
|
font-size: 0.88rem;
|
|
font-weight: 600;
|
|
background: var(--bg);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.wuerfeln:hover:not(:disabled) {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.wuerfeln:disabled {
|
|
color: var(--muted);
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.gewinner {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
max-width: 420px;
|
|
padding: 0.9rem;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
background: var(--bg-soft);
|
|
border: 1px solid var(--accent);
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.gewinner img {
|
|
width: 74px;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.label {
|
|
color: var(--accent);
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.gewinner h2 {
|
|
margin: 0.2rem 0;
|
|
font-size: 1.15rem;
|
|
line-height: 1.25;
|
|
}
|
|
|
|
.jahr {
|
|
color: var(--muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.liste h3 {
|
|
margin-bottom: 0.7rem;
|
|
color: var(--muted);
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.09em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.anzahl {
|
|
padding: 0.1rem 0.45rem;
|
|
background: var(--bg-soft);
|
|
border: 1px solid var(--line);
|
|
border-radius: 20px;
|
|
}
|
|
|
|
.liste ul {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
list-style: none;
|
|
}
|
|
|
|
.eintrag {
|
|
display: flex;
|
|
gap: 0.7rem;
|
|
align-items: flex-start;
|
|
width: 100%;
|
|
padding: 0.55rem;
|
|
color: var(--text);
|
|
font-size: 0.86rem;
|
|
text-align: left;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
/* Schon gelaufen — die Ziehung überspringt diesen Film */
|
|
.eintrag.gesehen {
|
|
opacity: 0.45;
|
|
filter: grayscale(1);
|
|
}
|
|
|
|
.eintrag.gesehen:hover {
|
|
opacity: 1;
|
|
filter: none;
|
|
}
|
|
|
|
.eintrag:hover {
|
|
background: var(--bg-soft);
|
|
}
|
|
|
|
/* Der farbige Rand verbindet den Eintrag mit seinem Stück auf dem Rad */
|
|
.cover {
|
|
display: block;
|
|
width: 46px;
|
|
flex-shrink: 0;
|
|
overflow: hidden;
|
|
background: var(--bg-soft);
|
|
border-left: 3px solid;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.cover img {
|
|
display: block;
|
|
width: 100%;
|
|
aspect-ratio: 2 / 3;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.daten {
|
|
display: flex;
|
|
min-width: 0;
|
|
flex-direction: column;
|
|
gap: 0.15rem;
|
|
padding-top: 0.1rem;
|
|
}
|
|
|
|
.titel {
|
|
overflow: hidden;
|
|
font-weight: 600;
|
|
line-height: 1.25;
|
|
display: -webkit-box;
|
|
-webkit-box-orient: vertical;
|
|
-webkit-line-clamp: 2;
|
|
}
|
|
|
|
.eintrag.eigen .titel {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.meta,
|
|
.genres,
|
|
.von {
|
|
overflow: hidden;
|
|
color: var(--muted);
|
|
font-size: 0.74rem;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.von {
|
|
margin-top: 0.15rem;
|
|
font-size: 0.72rem;
|
|
opacity: 0.75;
|
|
}
|
|
|
|
.note {
|
|
padding: 3rem 0;
|
|
color: var(--muted);
|
|
text-align: center;
|
|
}
|
|
|
|
.note.error {
|
|
color: var(--accent);
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.wheel-tab {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|