update
This commit is contained in:
@@ -31,6 +31,13 @@ const ANSICHT_SCHLUESSEL = 'filmabend_ansicht'
|
||||
const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt')
|
||||
|
||||
watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert))
|
||||
|
||||
// Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus.
|
||||
// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten.
|
||||
const GESEHEN_SCHLUESSEL = 'filmabend_ohne_gesehene'
|
||||
const ohneGesehene = ref(localStorage.getItem(GESEHEN_SCHLUESSEL) !== 'aus')
|
||||
|
||||
watch(ohneGesehene, (an) => localStorage.setItem(GESEHEN_SCHLUESSEL, an ? 'an' : 'aus'))
|
||||
const selected = ref(null)
|
||||
|
||||
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
|
||||
@@ -252,8 +259,12 @@ const netflixEntries = computed(() => {
|
||||
})
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
||||
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length },
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
|
||||
{
|
||||
id: 'canon',
|
||||
label: 'Horror-Kanon',
|
||||
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
|
||||
},
|
||||
{ id: 'search', label: 'Suche', count: null },
|
||||
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
||||
...(host.value
|
||||
@@ -262,7 +273,7 @@ const tabs = computed(() => [
|
||||
{ id: 'wheel', label: 'Rad', count: null },
|
||||
{ id: 'stream', label: 'Stream', count: null },
|
||||
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
||||
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.length },
|
||||
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
|
||||
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
|
||||
])
|
||||
|
||||
@@ -489,8 +500,12 @@ function matches(movie, term) {
|
||||
// Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will
|
||||
// beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm
|
||||
// mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
|
||||
function nichtGesehen(movie) {
|
||||
return !ohneGesehene.value || !gesehenIds.value.has(movie.id)
|
||||
}
|
||||
|
||||
function passt(movie) {
|
||||
return hasGenre(movie) && imBereich(movie)
|
||||
return nichtGesehen(movie) && hasGenre(movie) && imBereich(movie)
|
||||
}
|
||||
|
||||
function hasGenre(movie) {
|
||||
@@ -665,6 +680,7 @@ onUnmounted(() => {
|
||||
<SearchTab
|
||||
v-if="activeTab === 'search'"
|
||||
:gesehen-ids="gesehenIds"
|
||||
:ohne-gesehene="ohneGesehene"
|
||||
:filter="suchFilter"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
@@ -683,6 +699,7 @@ onUnmounted(() => {
|
||||
<FilterSidebar
|
||||
v-model:sort="suchSort"
|
||||
v-model:ansicht="ansicht"
|
||||
v-model:ohne-gesehene="ohneGesehene"
|
||||
v-model:ranges="ranges"
|
||||
:regler="REGLER"
|
||||
:grenzen="grenzen"
|
||||
@@ -779,6 +796,7 @@ onUnmounted(() => {
|
||||
v-model:search="search"
|
||||
v-model:sort="sort"
|
||||
v-model:ansicht="ansicht"
|
||||
v-model:ohne-gesehene="ohneGesehene"
|
||||
v-model:ranges="ranges"
|
||||
:regler="REGLER"
|
||||
:grenzen="grenzen"
|
||||
|
||||
@@ -14,6 +14,7 @@ const props = defineProps({
|
||||
filter: { type: Object, default: () => ({}) },
|
||||
ansicht: { type: String, default: 'kompakt' },
|
||||
gesehenIds: { type: Set, default: () => new Set() },
|
||||
ohneGesehene: { type: Boolean, default: true },
|
||||
// Kommt aus der Filmansicht: von diesem Film soll es losgehen
|
||||
start: { type: Object, default: null },
|
||||
})
|
||||
@@ -106,6 +107,7 @@ async function nachladen() {
|
||||
// Genres, Jahr, Stimmen und Note filtert schon das Backend. Übrig bleibt
|
||||
// die Laufzeit: die steht erst in der Detailantwort, nicht in der Liste.
|
||||
function passt(movie) {
|
||||
if (props.ohneGesehene && props.gesehenIds.has(movie.id)) return false
|
||||
const werte = props.filter.dauer
|
||||
const grenze = props.filter.grenzen?.runtime
|
||||
if (!werte || !grenze || movie.runtime == null) return true
|
||||
|
||||
@@ -19,6 +19,7 @@ const emit = defineEmits(['toggle', 'all', 'reset'])
|
||||
const search = defineModel('search', { type: String, default: '' })
|
||||
const sort = defineModel('sort', { type: String, default: 'random' })
|
||||
const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' })
|
||||
const ohneGesehene = defineModel('ohneGesehene', { type: Boolean, default: true })
|
||||
const ranges = defineModel('ranges', { type: Object, default: () => ({}) })
|
||||
|
||||
// Auch ein verschobener Regler zählt als aktiver Filter
|
||||
@@ -81,6 +82,11 @@ function beschriften(regler) {
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="gesehen-schalter">
|
||||
<input v-model="ohneGesehene" type="checkbox" />
|
||||
Gesehene ausblenden
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="head">
|
||||
@@ -158,6 +164,25 @@ function beschriften(regler) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gesehen-schalter {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
/* Eigene Zeile unter Kompakt/Details, sonst quetscht er sich daneben */
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* .controls input zieht sonst auf volle Breite — die Checkbox bleibt schmal */
|
||||
.controls .gesehen-schalter input {
|
||||
width: auto;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -135,10 +135,12 @@ async function oeffnen(item) {
|
||||
/* 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 {
|
||||
|
||||
@@ -75,10 +75,12 @@ const meta = computed(() =>
|
||||
Beim Überfahren kommt sie zurück. */
|
||||
.card.gesehen {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.card.gesehen:hover {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
/* Details: breite Karte, Poster links, alles lesbar statt abgeschnitten */
|
||||
|
||||
@@ -8,6 +8,7 @@ const props = defineProps({
|
||||
filter: { type: Object, default: () => ({}) },
|
||||
ansicht: { type: String, default: 'kompakt' },
|
||||
gesehenIds: { type: Set, default: () => new Set() },
|
||||
ohneGesehene: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open'])
|
||||
@@ -44,6 +45,11 @@ function passt(movie) {
|
||||
)
|
||||
}
|
||||
|
||||
// Schon gelaufene Filme fallen weg, solange der Schalter in der Leiste steht
|
||||
function nichtGesehen(movie) {
|
||||
return !props.ohneGesehene || !props.gesehenIds.has(movie.id)
|
||||
}
|
||||
|
||||
async function run(term) {
|
||||
const meine = ++laufende
|
||||
loading.value = true
|
||||
@@ -58,7 +64,7 @@ async function run(term) {
|
||||
const data = await resp.json()
|
||||
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
|
||||
if (meine !== laufende) return
|
||||
results.value = stoebern ? data : data.filter(passt)
|
||||
results.value = (stoebern ? data : data.filter(passt)).filter(nichtGesehen)
|
||||
gestoebert.value = stoebern
|
||||
} catch (err) {
|
||||
if (meine === laufende) error.value = err.message
|
||||
@@ -85,6 +91,7 @@ function leeren() {
|
||||
|
||||
watch(query, spaeterSuchen)
|
||||
watch(() => props.filter, spaeterSuchen, { deep: true, immediate: true })
|
||||
watch(() => props.ohneGesehene, spaeterSuchen)
|
||||
|
||||
async function open(movie) {
|
||||
const resp = await fetch(`/api/search/${movie.id}`)
|
||||
|
||||
@@ -99,10 +99,12 @@ function step(delta) {
|
||||
Beim Überfahren kommt sie zurück. */
|
||||
.card.gesehen {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.card.gesehen:hover {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
|
||||
@@ -120,10 +120,27 @@ const segmente = computed(() => {
|
||||
// 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
|
||||
@@ -245,9 +262,41 @@ async function oeffnen(item) {
|
||||
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">
|
||||
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
|
||||
<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})`"
|
||||
@@ -383,9 +432,11 @@ async function oeffnen(item) {
|
||||
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.45);
|
||||
stroke-width: 3px;
|
||||
stroke: rgba(0, 0, 0, 0.75);
|
||||
stroke-width: 3.5px;
|
||||
}
|
||||
|
||||
.zeiger {
|
||||
@@ -567,10 +618,12 @@ async function oeffnen(item) {
|
||||
/* Schon gelaufen — die Ziehung überspringt diesen Film */
|
||||
.eintrag.gesehen {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.eintrag.gesehen:hover {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.eintrag:hover {
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user