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 KiTab from './components/KiTab.vue'
|
||||
import PersonenTab from './components/PersonenTab.vue'
|
||||
import MerklisteTab from './components/MerklisteTab.vue'
|
||||
import SammlungFilter from './components/SammlungFilter.vue'
|
||||
import MovieDetail from './components/MovieDetail.vue'
|
||||
@@ -24,11 +25,20 @@ import { useLangeListe } from './composables/useLangeListe'
|
||||
const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host } = useEinstellungen()
|
||||
|
||||
const {
|
||||
runde, ich, michSetzen,
|
||||
gesehen, gesehenIds, gesehenZahl, ladeGesehen,
|
||||
meinVorschlag, aufDemRad, ladeVorschlag,
|
||||
merkliste, ladeMerkliste,
|
||||
wuensche, ladeWuensche,
|
||||
runde,
|
||||
ich,
|
||||
michSetzen,
|
||||
gesehen,
|
||||
gesehenIds,
|
||||
gesehenZahl,
|
||||
ladeGesehen,
|
||||
meinVorschlag,
|
||||
aufDemRad,
|
||||
ladeVorschlag,
|
||||
merkliste,
|
||||
ladeMerkliste,
|
||||
wuensche,
|
||||
ladeWuensche,
|
||||
} = useMeins({ host })
|
||||
|
||||
// Ohne gewählten Namen darf man alles lesen, aber nichts eintragen —
|
||||
@@ -37,44 +47,74 @@ const {
|
||||
const angemeldet = computed(() => !!ich.value)
|
||||
|
||||
const {
|
||||
movies, loading, error, load,
|
||||
netflixMovies, canonMovies, canonGroups, netflixEntries, SORTS,
|
||||
movies,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
netflixMovies,
|
||||
canonMovies,
|
||||
canonGroups,
|
||||
netflixEntries,
|
||||
SORTS,
|
||||
} = useKatalog()
|
||||
|
||||
const {
|
||||
search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions,
|
||||
toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter,
|
||||
} = useFilter({ activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene })
|
||||
search,
|
||||
sort,
|
||||
suchSort,
|
||||
suchAnzahl,
|
||||
include,
|
||||
exclude,
|
||||
grenzen,
|
||||
ranges,
|
||||
genreOptions,
|
||||
toggleGenre,
|
||||
toggleAll,
|
||||
resetGenres,
|
||||
nichtGesehen,
|
||||
passt,
|
||||
suchFilter,
|
||||
} = useFilter({
|
||||
activeTab,
|
||||
quelle,
|
||||
suchArt,
|
||||
netflixMovies,
|
||||
canonMovies,
|
||||
gesehenIds,
|
||||
ohneGesehene,
|
||||
})
|
||||
|
||||
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
|
||||
// ihren stärksten Teil
|
||||
function filterGruppen(entries, term) {
|
||||
return entries
|
||||
.filter(
|
||||
(e) =>
|
||||
e.parts.some((m) => passt(m)) &&
|
||||
(!term || e.parts.some((m) => matches(m, term)))
|
||||
(e) => e.parts.some((m) => passt(m)) && (!term || e.parts.some((m) => matches(m, term))),
|
||||
)
|
||||
.sort((a, b) => SORTS[sort.value](a.parts[a.best], b.parts[b.best]))
|
||||
}
|
||||
|
||||
const visible = computed(() =>
|
||||
filterGruppen(netflixEntries.value, search.value.trim().toLowerCase())
|
||||
filterGruppen(netflixEntries.value, search.value.trim().toLowerCase()),
|
||||
)
|
||||
|
||||
// Im Kanon greift die Suche auf die ganze Reihe: wer "Saw" sucht, will den
|
||||
// Eintrag sehen, egal welcher Teil gerade vorne liegt.
|
||||
const visibleCanon = computed(() =>
|
||||
filterGruppen(canonGroups.value, search.value.trim().toLowerCase())
|
||||
filterGruppen(canonGroups.value, search.value.trim().toLowerCase()),
|
||||
)
|
||||
|
||||
const aktuelleListe = computed(() =>
|
||||
quelle.value === 'netflix' ? visible.value : visibleCanon.value
|
||||
quelle.value === 'netflix' ? visible.value : visibleCanon.value,
|
||||
)
|
||||
|
||||
// Die zwei Listen des Katalogs, umschaltbar in der Filterleiste
|
||||
const quellen = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
|
||||
{
|
||||
id: 'netflix',
|
||||
label: 'Netflix',
|
||||
count: netflixMovies.value.filter(nichtGesehen).length,
|
||||
},
|
||||
{
|
||||
id: 'canon',
|
||||
label: 'Horror-Kanon',
|
||||
@@ -98,7 +138,11 @@ 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.filter((w) => !w.done_at).length },
|
||||
{
|
||||
id: 'wuensche',
|
||||
label: 'Wünsche',
|
||||
count: wuensche.value.filter((w) => !w.done_at).length,
|
||||
},
|
||||
...(host.value || angemeldet.value
|
||||
? [{ id: 'verwaltung', label: 'Verwaltung', count: null }]
|
||||
: []),
|
||||
@@ -107,9 +151,7 @@ const tabs = computed(() => [
|
||||
// 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(() =>
|
||||
['katalog', 'suche', 'merkliste'].includes(activeTab.value)
|
||||
)
|
||||
const mitFilter = computed(() => ['katalog', 'suche', 'merkliste'].includes(activeTab.value))
|
||||
|
||||
// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler —
|
||||
// dafür hat er eine eigene Leiste
|
||||
@@ -124,9 +166,7 @@ const leuteMitSammlung = computed(() => {
|
||||
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')
|
||||
)
|
||||
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
|
||||
@@ -180,9 +220,7 @@ const series = computed(() => {
|
||||
: []
|
||||
|
||||
if (group.length < 2) return []
|
||||
return [...group].sort((a, b) =>
|
||||
(a.release_date || '').localeCompare(b.release_date || '')
|
||||
)
|
||||
return [...group].sort((a, b) => (a.release_date || '').localeCompare(b.release_date || ''))
|
||||
})
|
||||
|
||||
// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen
|
||||
@@ -195,10 +233,21 @@ function zurAehnlichkeitsuche(movie) {
|
||||
suchArt.value = 'aehnlich'
|
||||
}
|
||||
|
||||
// Aus der Filmansicht zu einer Person: Regie oder Besetzung
|
||||
const personStart = ref(null)
|
||||
|
||||
function zurPersonensuche(person) {
|
||||
personStart.value = person
|
||||
selected.value = null
|
||||
activeTab.value = 'suche'
|
||||
suchArt.value = 'personen'
|
||||
}
|
||||
|
||||
// Die drei Suchen unter einem Reiter, umschaltbar über der Liste
|
||||
const SUCHARTEN = [
|
||||
{ id: 'search', label: 'Titel' },
|
||||
{ id: 'aehnlich', label: 'Ähnliche Filme' },
|
||||
{ id: 'personen', label: 'Personen' },
|
||||
{ id: 'ki', label: 'KI' },
|
||||
]
|
||||
|
||||
@@ -238,12 +287,7 @@ onMounted(load)
|
||||
Filter
|
||||
</button>
|
||||
<span v-if="host" class="host">Host</span>
|
||||
<NamensWahl
|
||||
:items="runde"
|
||||
:mine="ich"
|
||||
:host="host"
|
||||
:anmelden="michSetzen"
|
||||
/>
|
||||
<NamensWahl :items="runde" :mine="ich" :host="host" :anmelden="michSetzen" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -280,6 +324,17 @@ onMounted(load)
|
||||
@gestartet="aehnlichStart = null"
|
||||
/>
|
||||
|
||||
<PersonenTab
|
||||
v-else-if="suchArt === 'personen'"
|
||||
:gesehen-ids="gesehenIds"
|
||||
:ohne-gesehene="ohneGesehene"
|
||||
:filter="suchFilter"
|
||||
:ansicht="ansicht"
|
||||
:start="personStart"
|
||||
@open="selected = $event"
|
||||
@gestartet="personStart = null"
|
||||
/>
|
||||
|
||||
<KiTab
|
||||
v-else
|
||||
:angemeldet="angemeldet"
|
||||
@@ -294,6 +349,8 @@ onMounted(load)
|
||||
<!-- Die KI ordnet selbst nach Passung, da gibt es nichts zu sortieren -->
|
||||
<FilterSidebar
|
||||
v-model:sort="suchSort"
|
||||
v-model:anzahl="suchAnzahl"
|
||||
:mit-anzahl="suchArt !== 'personen'"
|
||||
v-model:ansicht="ansicht"
|
||||
v-model:ohne-gesehene="ohneGesehene"
|
||||
v-model:ranges="ranges"
|
||||
@@ -363,12 +420,7 @@ onMounted(load)
|
||||
@changed="ladeWuensche"
|
||||
/>
|
||||
|
||||
<VerwaltungTab
|
||||
v-else-if="activeTab === 'verwaltung'"
|
||||
:items="runde"
|
||||
:host="host"
|
||||
:ich="ich"
|
||||
/>
|
||||
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" :host="host" :ich="ich" />
|
||||
|
||||
<StreamTab v-else-if="activeTab === 'stream'" />
|
||||
|
||||
@@ -459,6 +511,7 @@ onMounted(load)
|
||||
@suggested="nachVorschlag"
|
||||
@watched="ladeGesehen"
|
||||
@aehnlich="zurAehnlichkeitsuche"
|
||||
@person="zurPersonensuche"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -649,10 +702,7 @@ onMounted(load)
|
||||
/* 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))
|
||||
);
|
||||
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||
}
|
||||
|
||||
.note {
|
||||
|
||||
@@ -3,9 +3,6 @@ import { computed, ref, watch } from 'vue'
|
||||
import MovieCard from './MovieCard.vue'
|
||||
import { filterParameter } from '../filter.js'
|
||||
|
||||
// Der Ausgangsfilm steht als erste Karte davor, macht einundzwanzig
|
||||
const TREFFER = 20
|
||||
|
||||
// Eine Detailkarte nimmt die halbe Breite ein, eine kompakte ein Achtel.
|
||||
// Gleich viele Vorschläge wären mal eine Reihe, mal zehn.
|
||||
const VORSCHLAEGE = { kompakt: 20, details: 8 }
|
||||
@@ -24,9 +21,7 @@ const emit = defineEmits(['open', 'gestartet'])
|
||||
const query = ref('')
|
||||
const gefunden = ref([])
|
||||
// Beim Wechsel der Ansicht wird nur neu geschnitten, nicht neu gesucht
|
||||
const vorschlaege = computed(() =>
|
||||
gefunden.value.slice(0, VORSCHLAEGE[props.ansicht] ?? 8)
|
||||
)
|
||||
const vorschlaege = computed(() => gefunden.value.slice(0, VORSCHLAEGE[props.ansicht] ?? 8))
|
||||
const gewaehlt = ref(null)
|
||||
const treffer = ref([])
|
||||
const laden = ref(false)
|
||||
@@ -86,8 +81,8 @@ async function nachladen() {
|
||||
laden.value = true
|
||||
fehler.value = ''
|
||||
try {
|
||||
// Die Anzahl kommt aus der Leiste, mit filterParameter als 'limit'
|
||||
const params = new URLSearchParams({
|
||||
limit: TREFFER,
|
||||
voll: 'true',
|
||||
...filterParameter(props.filter),
|
||||
})
|
||||
@@ -123,7 +118,7 @@ watch(
|
||||
waehlen(film)
|
||||
emit('gestartet')
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Am Schieberegler hängen viele Ereignisse — erst abwarten, dann holen
|
||||
@@ -134,7 +129,7 @@ watch(
|
||||
clearTimeout(filterTimer)
|
||||
filterTimer = setTimeout(nachladen, 350)
|
||||
},
|
||||
{ deep: true }
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(query, (term) => {
|
||||
@@ -160,13 +155,7 @@ async function oeffnen(movie) {
|
||||
placeholder="Von welchem Film ausgehen?"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
v-if="query"
|
||||
type="button"
|
||||
class="leeren"
|
||||
title="Suche leeren"
|
||||
@click="query = ''"
|
||||
>
|
||||
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="query = ''">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -196,12 +185,7 @@ async function oeffnen(movie) {
|
||||
</p>
|
||||
|
||||
<div v-if="!laden" class="grid" :class="ansicht">
|
||||
<MovieCard
|
||||
:movie="gewaehlt"
|
||||
:ansicht="ansicht"
|
||||
class="ausgangsfilm"
|
||||
@open="oeffnen"
|
||||
/>
|
||||
<MovieCard :movie="gewaehlt" :ansicht="ansicht" class="ausgangsfilm" @open="oeffnen" />
|
||||
<MovieCard
|
||||
v-for="film in treffer.filter(passt)"
|
||||
:key="film.id"
|
||||
@@ -277,10 +261,7 @@ async function oeffnen(movie) {
|
||||
}
|
||||
|
||||
.grid.details {
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
||||
);
|
||||
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||
}
|
||||
|
||||
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
|
||||
|
||||
@@ -16,6 +16,8 @@ const props = defineProps({
|
||||
ohneSort: Boolean,
|
||||
// Der Katalog hat zwei Listen zur Wahl: [{ id, label, count }]
|
||||
quellen: { type: Array, default: () => [] },
|
||||
// Die Suchen lassen wählen, wie viele Treffer es sein sollen
|
||||
mitAnzahl: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle', 'all', 'reset'])
|
||||
@@ -23,9 +25,13 @@ 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 ohneGesehene = defineModel('ohneGesehene', {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
})
|
||||
const ranges = defineModel('ranges', { type: Object, default: () => ({}) })
|
||||
const quelle = defineModel('quelle', { type: String, default: '' })
|
||||
const anzahl = defineModel('anzahl', { type: Number, default: 24 })
|
||||
|
||||
// Auch ein verschobener Regler zählt als aktiver Filter
|
||||
const etwasAktiv = computed(
|
||||
@@ -36,7 +42,7 @@ const etwasAktiv = computed(
|
||||
const spanne = ranges.value[r.key]
|
||||
const grenze = props.grenzen[r.key]
|
||||
return spanne && grenze && (spanne[0] > grenze.min || spanne[1] < grenze.max)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function beschriften(regler) {
|
||||
@@ -50,7 +56,6 @@ function beschriften(regler) {
|
||||
return zahl + (regler.einheit || '')
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,12 +74,7 @@ function beschriften(regler) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-if="!ohneSuche"
|
||||
v-model="search"
|
||||
type="search"
|
||||
placeholder="Titel suchen…"
|
||||
/>
|
||||
<input v-if="!ohneSuche" v-model="search" type="search" placeholder="Titel suchen…" />
|
||||
<select v-if="!ohneSort" v-model="sort">
|
||||
<option v-if="mitRelevanz" value="relevanz">Wie auf IMDb</option>
|
||||
<option value="random">Zufällig</option>
|
||||
@@ -84,19 +84,16 @@ function beschriften(regler) {
|
||||
<option value="title">Titel A–Z</option>
|
||||
</select>
|
||||
|
||||
<label v-if="mitAnzahl" class="anzahl">
|
||||
<span>Ergebnisse <b>{{ anzahl }}</b></span>
|
||||
<input v-model.number="anzahl" type="range" min="1" max="100" />
|
||||
</label>
|
||||
|
||||
<div class="ansicht">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ an: ansicht === 'kompakt' }"
|
||||
@click="ansicht = 'kompakt'"
|
||||
>
|
||||
<button type="button" :class="{ an: ansicht === 'kompakt' }" @click="ansicht = 'kompakt'">
|
||||
Kompakt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ an: ansicht === 'details' }"
|
||||
@click="ansicht = 'details'"
|
||||
>
|
||||
<button type="button" :class="{ an: ansicht === 'details' }" @click="ansicht = 'details'">
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
@@ -309,6 +306,23 @@ h4 {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.anzahl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.anzahl b {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.controls .anzahl input {
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.ansicht {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
|
||||
@@ -28,14 +28,14 @@ const gefragt = ref(false)
|
||||
|
||||
// Eine verstellte Leiste ist schon eine Frage: "Horror ab 2015" reicht
|
||||
const leisteVerstellt = computed(() => {
|
||||
const { sort, ...rest } = filterParameter(props.filter)
|
||||
const { sort, limit, ...rest } = filterParameter(props.filter)
|
||||
return Object.values(rest).some((w) => w !== '' && w != null)
|
||||
})
|
||||
|
||||
const kannFragen = computed(
|
||||
() =>
|
||||
!laden.value &&
|
||||
(beschreibung.value.trim().length > 0 || mitSammlung.value || leisteVerstellt.value)
|
||||
(beschreibung.value.trim().length > 0 || mitSammlung.value || leisteVerstellt.value),
|
||||
)
|
||||
|
||||
async function fragen() {
|
||||
@@ -196,10 +196,7 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.grid.details {
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
||||
);
|
||||
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||
}
|
||||
|
||||
.note {
|
||||
|
||||
@@ -14,7 +14,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich',
|
||||
'close', 'open', 'suggested', 'watched', 'gemerkt', 'aehnlich', 'person',
|
||||
])
|
||||
|
||||
const vorschlagen = ref({ zustand: 'bereit' })
|
||||
@@ -109,11 +109,25 @@ async function ladeAehnliche(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regie und Besetzung — jeder Name führt zur Personensuche
|
||||
const leute = ref({ regie: [], cast: [] })
|
||||
|
||||
async function ladeLeute(id) {
|
||||
leute.value = { regie: [], cast: [] }
|
||||
try {
|
||||
const resp = await fetch(`/api/movies/${id}/credits`)
|
||||
if (resp.ok && props.movie.id === id) leute.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.movie.id,
|
||||
(id) => {
|
||||
vorschlagen.value = { zustand: 'bereit' }
|
||||
ladeAehnliche(id)
|
||||
ladeLeute(id)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -176,7 +190,13 @@ onUnmounted(() => {
|
||||
<header>
|
||||
<img v-if="poster" class="poster" :src="poster" :alt="movie.title" />
|
||||
<div class="head-text">
|
||||
<h2>{{ movie.title }}</h2>
|
||||
<h2>
|
||||
{{ movie.title }}
|
||||
<!-- Läuft nur im Abo: dann bleibt fürs Leihen nichts übrig -->
|
||||
<span v-if="movie.rentable != null && !movie.rentable" class="nicht-leihbar">
|
||||
Nicht leihbar
|
||||
</span>
|
||||
</h2>
|
||||
<p v-if="movie.original_title !== movie.title" class="original">
|
||||
{{ movie.original_title }}
|
||||
</p>
|
||||
@@ -188,6 +208,22 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</p>
|
||||
<p class="genres">{{ (movie.genres || []).join(' · ') }}</p>
|
||||
|
||||
<p v-for="[titel, liste] in [['Regie', leute.regie], ['Mit', leute.cast]]" :key="titel">
|
||||
<template v-if="liste.length">
|
||||
<span class="leute-titel">{{ titel }}</span>
|
||||
<button
|
||||
v-for="p in liste"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="person"
|
||||
:title="`Filme mit ${p.name}`"
|
||||
@click="$emit('person', p)"
|
||||
>
|
||||
{{ p.name }}
|
||||
</button>
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="movie.teaser" class="teaser">{{ movie.teaser }}</p>
|
||||
<div class="links">
|
||||
<a
|
||||
@@ -208,6 +244,16 @@ onUnmounted(() => {
|
||||
>
|
||||
IMDb
|
||||
</a>
|
||||
<!-- TMDBs Anbieterliste für Deutschland: wo man leihen, kaufen
|
||||
oder streamen kann. IMDb zeigt davon nur Amazon. -->
|
||||
<a
|
||||
class="tmdb"
|
||||
:href="`https://www.themoviedb.org/movie/${movie.id}/watch?locale=DE`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
TMDB
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="vorschlag">
|
||||
@@ -244,12 +290,8 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="host || gesehen" class="gesehen">
|
||||
<span v-if="gesehen || merken.zustand === 'fertig'" class="ok">
|
||||
Steht unter „Gesehen“.
|
||||
</span>
|
||||
<div v-if="host && !gesehen && merken.zustand !== 'fertig'" class="gesehen">
|
||||
<button
|
||||
v-else-if="host"
|
||||
type="button"
|
||||
:disabled="merken.zustand === 'laeuft'"
|
||||
@click="alsGesehen"
|
||||
@@ -485,6 +527,26 @@ h2 {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.leute-titel {
|
||||
margin-right: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Namen als kleine Pillen, ein Klick führt zur Personensuche */
|
||||
.person {
|
||||
margin: 0 0.3rem 0.3rem 0;
|
||||
padding: 0.15rem 0.55rem;
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.person:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.teaser {
|
||||
color: #cfcfda;
|
||||
font-size: 0.98rem;
|
||||
@@ -498,7 +560,8 @@ h2 {
|
||||
}
|
||||
|
||||
.netflix,
|
||||
.imdb {
|
||||
.imdb,
|
||||
.tmdb {
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
@@ -510,11 +573,29 @@ h2 {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.tmdb {
|
||||
color: #0d253f;
|
||||
background: #01b4e4;
|
||||
}
|
||||
|
||||
.imdb {
|
||||
color: #111;
|
||||
background: #f5c518;
|
||||
}
|
||||
|
||||
.nicht-leihbar {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
vertical-align: middle;
|
||||
background: var(--accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Der Textblock richtet seine Kinder linksbündig aus, dadurch war der
|
||||
Trennstrich nur so breit wie die Knöpfe darunter */
|
||||
.vorschlag {
|
||||
@@ -609,7 +690,8 @@ h2 {
|
||||
}
|
||||
|
||||
.netflix:hover,
|
||||
.imdb:hover {
|
||||
.imdb:hover,
|
||||
.tmdb:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
@@ -749,7 +831,8 @@ h3 {
|
||||
.gesehen button,
|
||||
.merken,
|
||||
.netflix,
|
||||
.imdb {
|
||||
.imdb,
|
||||
.tmdb {
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
|
||||
360
frontend/src/components/PersonenTab.vue
Normal file
360
frontend/src/components/PersonenTab.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<script setup>
|
||||
// Nach Leuten suchen: einen Namen tippen, eine Person wählen, dann
|
||||
// stehen ihre Filme da — getrennt nach gespielt und Regie geführt.
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import MovieCard from './MovieCard.vue'
|
||||
import { posterUrl } from '../images'
|
||||
import { passtZurLeiste } from '../filter.js'
|
||||
|
||||
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: mit dieser Person soll es losgehen
|
||||
start: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open', 'gestartet'])
|
||||
|
||||
const query = ref('')
|
||||
const personen = ref([])
|
||||
const personenLaden = ref(false)
|
||||
const gewaehlt = ref(null)
|
||||
const rollen = ref({ gespielt: [], regie: [] })
|
||||
const laden = ref(false)
|
||||
const fehler = ref('')
|
||||
|
||||
let timer = null
|
||||
let laufende = 0
|
||||
let laufendeWahl = 0
|
||||
|
||||
async function suchen(term) {
|
||||
if (term.trim().length < 2) {
|
||||
personen.value = []
|
||||
return
|
||||
}
|
||||
const meine = ++laufende
|
||||
personenLaden.value = true
|
||||
try {
|
||||
const resp = await fetch(`/api/personen?q=${encodeURIComponent(term.trim())}`)
|
||||
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
||||
const daten = await resp.json()
|
||||
if (meine !== laufende) return
|
||||
personen.value = daten
|
||||
} catch {
|
||||
if (meine === laufende) personen.value = []
|
||||
} finally {
|
||||
if (meine === laufende) personenLaden.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(query, () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => suchen(query.value), 350)
|
||||
})
|
||||
|
||||
async function waehlen(person) {
|
||||
const meine = ++laufendeWahl
|
||||
gewaehlt.value = person
|
||||
query.value = ''
|
||||
personen.value = []
|
||||
laden.value = true
|
||||
fehler.value = ''
|
||||
try {
|
||||
const resp = await fetch(`/api/personen/${person.id}/filme`)
|
||||
if (!resp.ok) throw new Error('Das hat nicht geklappt.')
|
||||
const daten = await resp.json()
|
||||
if (meine !== laufendeWahl) return
|
||||
rollen.value = daten
|
||||
} catch (err) {
|
||||
if (meine === laufendeWahl) fehler.value = err.message
|
||||
} finally {
|
||||
if (meine === laufendeWahl) laden.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function passt(movie) {
|
||||
if (props.ohneGesehene && props.gesehenIds.has(movie.id)) return false
|
||||
return passtZurLeiste(movie, props.filter)
|
||||
}
|
||||
|
||||
// Die Reihenfolge aus der Leiste. "Wie auf IMDb" ist hier die von
|
||||
// TMDB: die bekanntesten Filme zuerst.
|
||||
const wuerfel = new Map()
|
||||
function zufall(movie) {
|
||||
if (!wuerfel.has(movie.id)) wuerfel.set(movie.id, Math.random())
|
||||
return wuerfel.get(movie.id)
|
||||
}
|
||||
|
||||
const SORTS = {
|
||||
random: (a, b) => zufall(a) - zufall(b),
|
||||
rating: (a, b) => (b.vote_average || 0) - (a.vote_average || 0),
|
||||
newest: (a, b) => (b.year || 0) - (a.year || 0),
|
||||
oldest: (a, b) => (a.year || 9999) - (b.year || 9999),
|
||||
title: (a, b) => a.title.localeCompare(b.title, 'de'),
|
||||
}
|
||||
|
||||
function sortiert(liste) {
|
||||
const sort = SORTS[props.filter.sort]
|
||||
return sort ? [...liste].sort(sort) : liste
|
||||
}
|
||||
|
||||
const abschnitte = computed(() =>
|
||||
[
|
||||
['Als Schauspieler', rollen.value.gespielt],
|
||||
['Als Regisseur', rollen.value.regie],
|
||||
]
|
||||
.map(([titel, liste]) => [titel, sortiert(liste.filter(passt))])
|
||||
.filter(([, liste]) => liste.length),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.start,
|
||||
(person) => {
|
||||
if (!person) return
|
||||
waehlen(person)
|
||||
emit('gestartet')
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function oeffnen(movie) {
|
||||
const resp = await fetch(`/api/search/${movie.id}`)
|
||||
if (resp.ok) emit('open', await resp.json())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="personen-tab">
|
||||
<div class="suchzeile">
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
class="big-search"
|
||||
placeholder="Wen suchst du?"
|
||||
autofocus
|
||||
/>
|
||||
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="query = ''">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tippt jemand, stehen die Leute vorn, bis einer gewählt ist -->
|
||||
<template v-if="query.trim().length >= 2">
|
||||
<p v-if="!personen.length && !personenLaden" class="note">Nichts gefunden.</p>
|
||||
<ul v-else class="leute">
|
||||
<li v-for="p in personen" :key="p.id">
|
||||
<button type="button" @click="waehlen(p)">
|
||||
<img v-if="p.profile_path" :src="posterUrl(p.profile_path, 'w185')" :alt="p.name" />
|
||||
<span v-else class="kein-bild"></span>
|
||||
<span class="text">
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="fach">{{ p.fach }}</span>
|
||||
<span v-if="p.bekannt_fuer.length" class="bekannt">
|
||||
{{ p.bekannt_fuer.join(' · ') }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template v-else-if="gewaehlt">
|
||||
<div class="kopf">
|
||||
<img
|
||||
v-if="gewaehlt.profile_path"
|
||||
:src="posterUrl(gewaehlt.profile_path, 'w185')"
|
||||
:alt="gewaehlt.name"
|
||||
/>
|
||||
<div>
|
||||
<h3>{{ gewaehlt.name }}</h3>
|
||||
<p class="fach">{{ gewaehlt.fach }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="laden" class="note">Suche läuft…</p>
|
||||
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||
<p v-else-if="!abschnitte.length" class="note">Nichts gefunden. Stell die Filter weiter.</p>
|
||||
|
||||
<section v-for="[titel, liste] in abschnitte" :key="titel">
|
||||
<p class="label">
|
||||
{{ titel }}
|
||||
<span class="anzahl">{{ liste.length }}</span>
|
||||
</p>
|
||||
<div class="grid" :class="ansicht">
|
||||
<MovieCard
|
||||
v-for="film in liste"
|
||||
:key="film.id"
|
||||
:movie="film"
|
||||
:ansicht="ansicht"
|
||||
:gesehen="gesehenIds.has(film.id)"
|
||||
@open="oeffnen"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.personen-tab {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suchzeile {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.big-search {
|
||||
width: 100%;
|
||||
padding: 0.9rem 2.6rem 0.9rem 1.1rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.leeren {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.9rem;
|
||||
color: var(--muted);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.leeren:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Die Trefferliste: Foto, Name, Fach und wofür man die Person kennt */
|
||||
.leute {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.7rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.leute button {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
text-align: left;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.leute button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.leute img,
|
||||
.kein-bild {
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
height: 84px;
|
||||
object-fit: cover;
|
||||
background: #1c1c22;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.fach,
|
||||
.bekannt {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.bekannt {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kopf {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.kopf img {
|
||||
width: 64px;
|
||||
height: 96px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.kopf h3 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.1rem;
|
||||
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-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.grid.details {
|
||||
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 2rem 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note.error {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.grid.details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.leute {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -58,7 +58,7 @@ async function run(term) {
|
||||
try {
|
||||
const url = stoebern
|
||||
? `/api/discover?${new URLSearchParams(filterParameter(props.filter))}`
|
||||
: `/api/search?q=${encodeURIComponent(term.trim())}`
|
||||
: `/api/search?q=${encodeURIComponent(term.trim())}&limit=${props.filter.anzahl || 24}`
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) throw new Error('Suche fehlgeschlagen')
|
||||
const data = await resp.json()
|
||||
@@ -113,15 +113,15 @@ async function open(movie) {
|
||||
<button v-if="query" type="button" class="leeren" title="Suche leeren" @click="leeren">
|
||||
×
|
||||
</button>
|
||||
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">
|
||||
Suchen
|
||||
</button>
|
||||
<button type="button" class="neu" title="Neu suchen" @click="jetztSuchen">Suchen</button>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="note">Suche läuft…</p>
|
||||
<p v-else-if="error" class="note error">{{ error }}</p>
|
||||
<p v-else-if="!results.length" class="note">
|
||||
{{ gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.` }}
|
||||
{{
|
||||
gestoebert ? 'Nichts gefunden. Stell die Filter weiter.' : `Nichts gefunden für „${query}“.`
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="grid" :class="ansicht">
|
||||
@@ -195,10 +195,7 @@ async function open(movie) {
|
||||
}
|
||||
|
||||
.grid.details {
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem))
|
||||
);
|
||||
grid-template-columns: repeat(auto-fill, min(calc(50% - 0.55rem), calc(4 * 190px + 3 * 1.1rem)));
|
||||
}
|
||||
|
||||
/* Auf dem Handy wie im Katalog: drei Poster nebeneinander */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
@@ -7,111 +7,111 @@ const props = defineProps({
|
||||
host: Boolean,
|
||||
// Ohne gewählten Namen bleibt der Reiter zum Mitlesen da
|
||||
angemeldet: Boolean,
|
||||
});
|
||||
})
|
||||
|
||||
const emit = defineEmits(["changed"]);
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
const entwurf = ref("");
|
||||
const entwurf = ref('')
|
||||
|
||||
// Der Host darf jeden Wunsch anfassen, alle anderen nur die eigenen
|
||||
const bearbeitet = ref(null);
|
||||
const bearbeitung = ref("");
|
||||
const bearbeitet = ref(null)
|
||||
const bearbeitung = ref('')
|
||||
|
||||
function darfAendern(item) {
|
||||
return props.host || item.mine;
|
||||
return props.host || item.mine
|
||||
}
|
||||
|
||||
function bearbeiten(item) {
|
||||
bearbeitet.value = item.id;
|
||||
bearbeitung.value = item.text;
|
||||
bearbeitet.value = item.id
|
||||
bearbeitung.value = item.text
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
const text = bearbeitung.value.trim();
|
||||
const text = bearbeitung.value.trim()
|
||||
if (text) {
|
||||
await fetch(`/api/features/${bearbeitet.value}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
emit("changed");
|
||||
})
|
||||
emit('changed')
|
||||
}
|
||||
bearbeitet.value = null;
|
||||
bearbeitet.value = null
|
||||
}
|
||||
|
||||
// Die Liste kommt vom Server schon sortiert: offene nach Stimmen, dann
|
||||
// die erledigten. Hier nur noch in zwei Abschnitte teilen.
|
||||
const offene = computed(() => props.items.filter((w) => !w.done_at));
|
||||
const erledigte = computed(() => props.items.filter((w) => w.done_at));
|
||||
const offene = computed(() => props.items.filter((w) => !w.done_at))
|
||||
const erledigte = computed(() => props.items.filter((w) => w.done_at))
|
||||
|
||||
async function anlegen() {
|
||||
if (!entwurf.value.trim()) return;
|
||||
await fetch("/api/features", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
if (!entwurf.value.trim()) return
|
||||
await fetch('/api/features', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: entwurf.value }),
|
||||
});
|
||||
entwurf.value = "";
|
||||
emit("changed");
|
||||
})
|
||||
entwurf.value = ''
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
async function loeschen(item) {
|
||||
await fetch(`/api/features/${item.id}`, { method: "DELETE" });
|
||||
emit("changed");
|
||||
await fetch(`/api/features/${item.id}`, { method: 'DELETE' })
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
async function abstimmen(item) {
|
||||
await fetch(`/api/features/${item.id}/vote`, { method: "POST" });
|
||||
emit("changed");
|
||||
await fetch(`/api/features/${item.id}/vote`, { method: 'POST' })
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
async function erledigen(item, done) {
|
||||
await fetch(`/api/features/${item.id}/done`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ done }),
|
||||
});
|
||||
emit("changed");
|
||||
})
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
// Ergänzungen: aufgeklappt je Wunsch, mit eigenem Entwurf
|
||||
const aufgeklappt = reactive(new Set());
|
||||
const notizEntwurf = reactive({});
|
||||
const aufgeklappt = reactive(new Set())
|
||||
const notizEntwurf = reactive({})
|
||||
|
||||
function umschalten(id) {
|
||||
if (aufgeklappt.has(id)) aufgeklappt.delete(id);
|
||||
else aufgeklappt.add(id);
|
||||
if (aufgeklappt.has(id)) aufgeklappt.delete(id)
|
||||
else aufgeklappt.add(id)
|
||||
}
|
||||
|
||||
function ergaenzungsLabel(item) {
|
||||
const n = item.notes.length;
|
||||
if (!n) return "Ergänzen";
|
||||
return n === 1 ? "1 Ergänzung" : `${n} Ergänzungen`;
|
||||
const n = item.notes.length
|
||||
if (!n) return 'Ergänzen'
|
||||
return n === 1 ? '1 Ergänzung' : `${n} Ergänzungen`
|
||||
}
|
||||
|
||||
async function anmerken(item) {
|
||||
const text = (notizEntwurf[item.id] || "").trim();
|
||||
if (!text) return;
|
||||
const text = (notizEntwurf[item.id] || '').trim()
|
||||
if (!text) return
|
||||
await fetch(`/api/features/${item.id}/notes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
notizEntwurf[item.id] = "";
|
||||
emit("changed");
|
||||
})
|
||||
notizEntwurf[item.id] = ''
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
async function notizLoeschen(notiz) {
|
||||
await fetch(`/api/feature-notes/${notiz.id}`, { method: "DELETE" });
|
||||
emit("changed");
|
||||
await fetch(`/api/feature-notes/${notiz.id}`, { method: 'DELETE' })
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
function datum(iso) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -129,9 +129,7 @@ function datum(iso) {
|
||||
placeholder="Was fehlt dir auf der Seite?"
|
||||
@keydown.ctrl.enter="anlegen"
|
||||
></textarea>
|
||||
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">
|
||||
Eintragen
|
||||
</button>
|
||||
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">Eintragen</button>
|
||||
</div>
|
||||
|
||||
<section
|
||||
@@ -172,14 +170,8 @@ function datum(iso) {
|
||||
@keydown.esc="bearbeitet = null"
|
||||
></textarea>
|
||||
<div class="zeile">
|
||||
<button type="button" class="klein" @click="speichern">
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="klein"
|
||||
@click="bearbeitet = null"
|
||||
>
|
||||
<button type="button" class="klein" @click="speichern">Speichern</button>
|
||||
<button type="button" class="klein" @click="bearbeitet = null">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
@@ -188,9 +180,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 }">{{ w.name || 'Anonym' }}</span>
|
||||
<span class="punkt">·</span>{{ datum(w.created_at) }}
|
||||
<template v-if="w.done_at">
|
||||
<span class="punkt">·</span>erledigt
|
||||
@@ -210,9 +200,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 }">{{ n.name || 'Anonym' }}</span>
|
||||
<span class="notiz-text">{{ n.text }}</span>
|
||||
<button
|
||||
v-if="host"
|
||||
@@ -250,12 +238,10 @@ function datum(iso) {
|
||||
v-if="host"
|
||||
type="button"
|
||||
class="weg"
|
||||
:title="
|
||||
w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'
|
||||
"
|
||||
:title="w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'"
|
||||
@click="erledigen(w, !w.done_at)"
|
||||
>
|
||||
{{ w.done_at ? "↺" : "✓" }}
|
||||
{{ w.done_at ? '↺' : '✓' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="darfAendern(w) && bearbeitet !== w.id"
|
||||
@@ -288,8 +274,8 @@ function datum(iso) {
|
||||
.spalten {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"neu erledigt"
|
||||
"offen erledigt";
|
||||
'neu erledigt'
|
||||
'offen erledigt';
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 0 2.5rem;
|
||||
@@ -558,9 +544,9 @@ h3 {
|
||||
@media (max-width: 900px) {
|
||||
.spalten {
|
||||
grid-template-areas:
|
||||
"neu"
|
||||
"offen"
|
||||
"erledigt";
|
||||
'neu'
|
||||
'offen'
|
||||
'erledigt';
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const TAB_IDS = [
|
||||
// Netflix und Kanon waren einmal zwei Reiter — heute eine Quelle im Katalog
|
||||
const QUELLEN = ['netflix', 'canon']
|
||||
// Ebenso die drei Suchen: heute ein Reiter mit Umschalter darüber
|
||||
const SUCHARTEN = ['search', 'aehnlich', 'ki']
|
||||
const SUCHARTEN = ['search', 'aehnlich', 'personen', 'ki']
|
||||
|
||||
function gemerkt(schluessel, standard) {
|
||||
const wert = ref(localStorage.getItem(schluessel) ?? standard)
|
||||
|
||||
@@ -5,16 +5,34 @@ import { computed, ref, watch } from 'vue'
|
||||
// Die Skalen richten sich nach dem ungefilterten Bestand des Reiters —
|
||||
// sonst wandern die Enden bei jeder Auswahl
|
||||
export const REGLER = [
|
||||
{ key: 'vote_count', label: 'Bewertungen', feld: 'vote_count', step: 1, gestaucht: true },
|
||||
{ key: 'runtime', label: 'Dauer', feld: 'runtime', step: 1, einheit: ' Min.' },
|
||||
{
|
||||
key: 'vote_count',
|
||||
label: 'Bewertungen',
|
||||
feld: 'vote_count',
|
||||
step: 1,
|
||||
gestaucht: true,
|
||||
},
|
||||
{
|
||||
key: 'runtime',
|
||||
label: 'Dauer',
|
||||
feld: 'runtime',
|
||||
step: 1,
|
||||
einheit: ' Min.',
|
||||
},
|
||||
{ key: 'year', label: 'Jahr', feld: 'year', step: 1, roh: true },
|
||||
{ key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 },
|
||||
{
|
||||
key: 'vote_average',
|
||||
label: 'Bewertung',
|
||||
feld: 'vote_average',
|
||||
step: 0.1,
|
||||
nachkomma: 1,
|
||||
},
|
||||
]
|
||||
|
||||
// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten
|
||||
// ließen — TMDB reicht von den Anfängen bis heute
|
||||
const SUCH_GRENZEN = {
|
||||
vote_count: { min: 50, max: 50000 },
|
||||
vote_count: { min: 0, max: 50000 },
|
||||
runtime: { min: 0, max: 240 },
|
||||
year: { min: 1900, max: new Date().getFullYear() + 1 },
|
||||
vote_average: { min: 0, max: 10 },
|
||||
@@ -22,21 +40,62 @@ const SUCH_GRENZEN = {
|
||||
|
||||
// Die 19 Genres, die TMDB kennt
|
||||
const SUCH_GENRES = [
|
||||
'Abenteuer', 'Action', 'Animation', 'Dokumentarfilm', 'Drama', 'Familie',
|
||||
'Fantasy', 'Historie', 'Horror', 'Komödie', 'Krimi', 'Kriegsfilm',
|
||||
'Liebesfilm', 'Musik', 'Mystery', 'Science Fiction', 'TV-Film', 'Thriller',
|
||||
'Abenteuer',
|
||||
'Action',
|
||||
'Animation',
|
||||
'Dokumentarfilm',
|
||||
'Drama',
|
||||
'Familie',
|
||||
'Fantasy',
|
||||
'Historie',
|
||||
'Horror',
|
||||
'Komödie',
|
||||
'Krimi',
|
||||
'Kriegsfilm',
|
||||
'Liebesfilm',
|
||||
'Musik',
|
||||
'Mystery',
|
||||
'Science Fiction',
|
||||
'TV-Film',
|
||||
'Thriller',
|
||||
'Western',
|
||||
].map((genre) => ({ genre, count: null }))
|
||||
|
||||
export function useFilter({
|
||||
activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene,
|
||||
activeTab,
|
||||
quelle,
|
||||
suchArt,
|
||||
netflixMovies,
|
||||
canonMovies,
|
||||
gesehenIds,
|
||||
ohneGesehene,
|
||||
}) {
|
||||
const search = ref('')
|
||||
const sort = ref('random')
|
||||
// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
|
||||
// dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter
|
||||
// "More like this" zeigt. Im Katalog gibt es die nicht.
|
||||
const suchSort = ref('relevanz')
|
||||
// Jede Suche hat ihre eigene sinnvolle Reihenfolge: die Titelsuche die
|
||||
// von TMDB (wie auf IMDb), Ähnliche gemischt, bei Personen das Neueste
|
||||
// zuerst. Die Wahl bleibt je Suche erhalten.
|
||||
const suchSorts = ref({
|
||||
search: 'relevanz',
|
||||
aehnlich: 'random',
|
||||
personen: 'newest',
|
||||
})
|
||||
const suchSort = computed({
|
||||
get: () => suchSorts.value[suchArt.value] ?? 'relevanz',
|
||||
set: (wert) => {
|
||||
suchSorts.value[suchArt.value] = wert
|
||||
},
|
||||
})
|
||||
|
||||
// Wie viele Treffer eine Suche bringen soll — je Suche gemerkt. Bei
|
||||
// Personen gibt es das nicht: da sind es eben alle Filme.
|
||||
const suchAnzahlen = ref({ search: 24, aehnlich: 20, ki: 12 })
|
||||
const suchAnzahl = computed({
|
||||
get: () => suchAnzahlen.value[suchArt.value] ?? 24,
|
||||
set: (wert) => {
|
||||
suchAnzahlen.value[suchArt.value] = wert
|
||||
},
|
||||
})
|
||||
const include = ref([])
|
||||
const exclude = ref([])
|
||||
|
||||
@@ -47,7 +106,9 @@ export function useFilter({
|
||||
if (suchReiter()) return SUCH_GRENZEN
|
||||
const out = {}
|
||||
for (const regler of REGLER) {
|
||||
const werte = pool().map((m) => m[regler.feld]).filter((v) => v != null)
|
||||
const werte = pool()
|
||||
.map((m) => m[regler.feld])
|
||||
.filter((v) => v != null)
|
||||
const min = werte.length ? Math.min(...werte) : 0
|
||||
const max = werte.length ? Math.max(...werte) : 0
|
||||
out[regler.key] = {
|
||||
@@ -60,9 +121,17 @@ export function useFilter({
|
||||
|
||||
const ranges = ref({})
|
||||
|
||||
// Im Suche-Reiter starten zwei Regler nicht am Anschlag: unter 60
|
||||
// Minuten sind es Kurzfilme, unter 50 Stimmen Making-ofs und Cameos.
|
||||
// Wer das sehen will, zieht den Regler runter — bewusst.
|
||||
const SUCH_START = { runtime: 60, vote_count: 50 }
|
||||
|
||||
function resetRanges() {
|
||||
ranges.value = Object.fromEntries(
|
||||
Object.entries(grenzen.value).map(([k, v]) => [k, [v.min, v.max]])
|
||||
Object.entries(grenzen.value).map(([k, v]) => [
|
||||
k,
|
||||
[suchReiter() && SUCH_START[k] != null ? SUCH_START[k] : v.min, v.max],
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,6 +221,7 @@ export function useFilter({
|
||||
const spanne = (key) => ranges.value[key] || []
|
||||
return {
|
||||
sort: suchSort.value,
|
||||
anzahl: suchAnzahl.value,
|
||||
include: include.value,
|
||||
exclude: exclude.value,
|
||||
stimmen: spanne('vote_count'),
|
||||
@@ -166,6 +236,7 @@ export function useFilter({
|
||||
search,
|
||||
sort,
|
||||
suchSort,
|
||||
suchAnzahl,
|
||||
include,
|
||||
exclude,
|
||||
grenzen,
|
||||
|
||||
@@ -11,9 +11,33 @@ function spanne(filter, name, schluessel, ziel) {
|
||||
return raus
|
||||
}
|
||||
|
||||
// Die Filter der Leiste auf einen Treffer anwenden, den TMDB schon
|
||||
// geliefert hat — ein Regler auf Anschlag schneidet nichts weg
|
||||
export function passtZurLeiste(movie, filter) {
|
||||
const g = filter.grenzen || {}
|
||||
const drin = (werte, wert, key) => {
|
||||
if (!werte || wert == null) return true
|
||||
const grenze = g[key]
|
||||
if (!grenze) return true
|
||||
if (werte[0] > grenze.min && wert < werte[0]) return false
|
||||
if (werte[1] < grenze.max && wert > werte[1]) return false
|
||||
return true
|
||||
}
|
||||
const genres = movie.genres || []
|
||||
if ((filter.include || []).length && !genres.some((x) => filter.include.includes(x))) return false
|
||||
if ((filter.exclude || []).some((x) => genres.includes(x))) return false
|
||||
return (
|
||||
drin(filter.stimmen, movie.vote_count, 'vote_count') &&
|
||||
drin(filter.dauer, movie.runtime, 'runtime') &&
|
||||
drin(filter.jahr, movie.year, 'year') &&
|
||||
drin(filter.note, movie.vote_average, 'vote_average')
|
||||
)
|
||||
}
|
||||
|
||||
export function filterParameter(filter) {
|
||||
return {
|
||||
sort: filter.sort || 'rating',
|
||||
...(filter.anzahl ? { limit: filter.anzahl } : {}),
|
||||
include: (filter.include || []).join(','),
|
||||
exclude: (filter.exclude || []).join(','),
|
||||
...spanne(filter, 'stimmen', 'vote_count', 'stimmen'),
|
||||
|
||||
Reference in New Issue
Block a user