update
This commit is contained in:
@@ -21,7 +21,7 @@ import { matches, useKatalog } from './composables/useKatalog'
|
||||
import { REGLER, useFilter } from './composables/useFilter'
|
||||
import { useLangeListe } from './composables/useLangeListe'
|
||||
|
||||
const { ansicht, ohneGesehene, activeTab, host } = useEinstellungen()
|
||||
const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host } = useEinstellungen()
|
||||
|
||||
const {
|
||||
runde, ich, michSetzen,
|
||||
@@ -44,7 +44,7 @@ const {
|
||||
const {
|
||||
search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions,
|
||||
toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter,
|
||||
} = useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene })
|
||||
} = useFilter({ activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene })
|
||||
|
||||
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
|
||||
// ihren stärksten Teil
|
||||
@@ -69,24 +69,31 @@ const visibleCanon = computed(() =>
|
||||
)
|
||||
|
||||
const aktuelleListe = computed(() =>
|
||||
activeTab.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: 'canon',
|
||||
label: 'Horror-Kanon',
|
||||
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
|
||||
},
|
||||
])
|
||||
|
||||
const { gezeigt, sichtbar, nachschub, obenKnopf, nachOben } = useLangeListe({
|
||||
liste: aktuelleListe,
|
||||
ansicht,
|
||||
})
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ 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: 'katalog',
|
||||
label: 'Katalog',
|
||||
count: quellen.value.find((q) => q.id === quelle.value)?.count ?? null,
|
||||
},
|
||||
{ id: 'search', label: 'Suche', count: null },
|
||||
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
|
||||
{ id: 'ki', label: 'KI-Suche', count: null },
|
||||
{ id: 'suche', label: 'Suche', count: null },
|
||||
...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
||||
{ id: 'wheel', label: 'Rad', count: null },
|
||||
{ id: 'stream', label: 'Stream', count: null },
|
||||
@@ -101,7 +108,7 @@ const tabs = computed(() => [
|
||||
// legt sie sich über einen Knopf über die Seite
|
||||
const filterOffen = ref(false)
|
||||
const mitFilter = computed(() =>
|
||||
['netflix', 'canon', 'search', 'aehnlich', 'ki', 'merkliste'].includes(activeTab.value)
|
||||
['katalog', 'suche', 'merkliste'].includes(activeTab.value)
|
||||
)
|
||||
|
||||
// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler —
|
||||
@@ -125,7 +132,7 @@ const leuteMitSammlung = computed(() => {
|
||||
// Fällt der offene Reiter weg — Hostmodus aus, Name abgewählt —, wäre
|
||||
// sonst keiner markiert und die Seite bliebe leer
|
||||
watch(tabs, (liste) => {
|
||||
if (!liste.some((t) => t.id === activeTab.value)) activeTab.value = 'netflix'
|
||||
if (!liste.some((t) => t.id === activeTab.value)) activeTab.value = 'katalog'
|
||||
})
|
||||
|
||||
// Passen die Reiter nicht in eine Zeile, soll der offene sichtbar sein
|
||||
@@ -184,9 +191,17 @@ const aehnlichStart = ref(null)
|
||||
function zurAehnlichkeitsuche(movie) {
|
||||
aehnlichStart.value = movie
|
||||
selected.value = null
|
||||
activeTab.value = 'aehnlich'
|
||||
activeTab.value = 'suche'
|
||||
suchArt.value = 'aehnlich'
|
||||
}
|
||||
|
||||
// Die drei Suchen unter einem Reiter, umschaltbar über der Liste
|
||||
const SUCHARTEN = [
|
||||
{ id: 'search', label: 'Titel' },
|
||||
{ id: 'aehnlich', label: 'Ähnliche Filme' },
|
||||
{ id: 'ki', label: 'KI' },
|
||||
]
|
||||
|
||||
const rad = ref(null)
|
||||
|
||||
function nachVorschlag() {
|
||||
@@ -232,26 +247,51 @@ onMounted(load)
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="activeTab === 'search' || activeTab === 'aehnlich'" class="layout">
|
||||
<SearchTab
|
||||
v-if="activeTab === 'search'"
|
||||
:gesehen-ids="gesehenIds"
|
||||
:ohne-gesehene="ohneGesehene"
|
||||
:filter="suchFilter"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
<div v-if="activeTab === 'suche'" class="layout">
|
||||
<div class="suche">
|
||||
<div class="sucharten">
|
||||
<button
|
||||
v-for="art in SUCHARTEN"
|
||||
:key="art.id"
|
||||
type="button"
|
||||
:class="{ active: suchArt === art.id }"
|
||||
@click="suchArt = art.id"
|
||||
>
|
||||
{{ art.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AehnlichTab
|
||||
v-else
|
||||
:gesehen-ids="gesehenIds"
|
||||
:filter="suchFilter"
|
||||
:ansicht="ansicht"
|
||||
:start="aehnlichStart"
|
||||
@open="filmOeffnen"
|
||||
@gestartet="aehnlichStart = null"
|
||||
/>
|
||||
<SearchTab
|
||||
v-if="suchArt === 'search'"
|
||||
:gesehen-ids="gesehenIds"
|
||||
:ohne-gesehene="ohneGesehene"
|
||||
:filter="suchFilter"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
|
||||
<AehnlichTab
|
||||
v-else-if="suchArt === 'aehnlich'"
|
||||
:gesehen-ids="gesehenIds"
|
||||
:filter="suchFilter"
|
||||
:ansicht="ansicht"
|
||||
:start="aehnlichStart"
|
||||
@open="filmOeffnen"
|
||||
@gestartet="aehnlichStart = null"
|
||||
/>
|
||||
|
||||
<KiTab
|
||||
v-else
|
||||
:angemeldet="angemeldet"
|
||||
:gesehen-ids="gesehenIds"
|
||||
:ohne-gesehene="ohneGesehene"
|
||||
:filter="suchFilter"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Die KI ordnet selbst nach Passung, da gibt es nichts zu sortieren -->
|
||||
<FilterSidebar
|
||||
v-model:sort="suchSort"
|
||||
v-model:ansicht="ansicht"
|
||||
@@ -264,35 +304,8 @@ onMounted(load)
|
||||
:include="include"
|
||||
:exclude="exclude"
|
||||
:ohne-suche="true"
|
||||
:mit-relevanz="true"
|
||||
@toggle="toggleGenre"
|
||||
@all="toggleAll"
|
||||
@reset="resetGenres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'ki'" class="layout">
|
||||
<KiTab
|
||||
:angemeldet="angemeldet"
|
||||
:gesehen-ids="gesehenIds"
|
||||
:ohne-gesehene="ohneGesehene"
|
||||
:filter="suchFilter"
|
||||
:ansicht="ansicht"
|
||||
@open="selected = $event"
|
||||
/>
|
||||
|
||||
<FilterSidebar
|
||||
v-model:ansicht="ansicht"
|
||||
v-model:ohne-gesehene="ohneGesehene"
|
||||
v-model:ranges="ranges"
|
||||
:class="{ offen: filterOffen }"
|
||||
:regler="REGLER"
|
||||
:grenzen="grenzen"
|
||||
:options="genreOptions"
|
||||
:include="include"
|
||||
:exclude="exclude"
|
||||
:ohne-suche="true"
|
||||
:ohne-sort="true"
|
||||
:mit-relevanz="suchArt !== 'ki'"
|
||||
:ohne-sort="suchArt === 'ki'"
|
||||
@toggle="toggleGenre"
|
||||
@all="toggleAll"
|
||||
@reset="resetGenres"
|
||||
@@ -399,6 +412,7 @@ onMounted(load)
|
||||
|
||||
<FilterSidebar
|
||||
v-if="genreOptions.length"
|
||||
v-model:quelle="quelle"
|
||||
v-model:search="search"
|
||||
v-model:sort="sort"
|
||||
v-model:ansicht="ansicht"
|
||||
@@ -410,6 +424,7 @@ onMounted(load)
|
||||
:options="genreOptions"
|
||||
:include="include"
|
||||
:exclude="exclude"
|
||||
:quellen="quellen"
|
||||
@toggle="toggleGenre"
|
||||
@all="toggleAll"
|
||||
@reset="resetGenres"
|
||||
@@ -570,6 +585,39 @@ onMounted(load)
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suche {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Umschalter zwischen den drei Suchen: drei gleich breite Knöpfe über
|
||||
die volle Breite, im Stil von Kompakt/Details in der Leiste */
|
||||
.sucharten {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.sucharten button {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.9rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sucharten button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sucharten button.active {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.results {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -197,11 +197,6 @@ async function entfernen() {
|
||||
|
||||
<!-- ── Rechte Seite: das Gespräch ─────────────────────────────── -->
|
||||
<section class="rechts">
|
||||
<h3>
|
||||
Diskussion
|
||||
<span v-if="anzahl" class="anzahl">{{ anzahl }}</span>
|
||||
</h3>
|
||||
|
||||
<p v-if="!anzahl" class="leer">Noch nichts gesagt.</p>
|
||||
|
||||
<ul v-else class="liste">
|
||||
@@ -515,30 +510,9 @@ h2:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
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);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* Zeilen mit Trennlinie, wie bei den Wünschen */
|
||||
.liste {
|
||||
list-style: none;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.zeile {
|
||||
|
||||
@@ -212,10 +212,6 @@ async function oeffnen(movie) {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-else class="note">
|
||||
Such einen Film. Danach zeigt dir dieser Reiter, was dazu passt.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ const props = defineProps({
|
||||
mitRelevanz: Boolean,
|
||||
// Die KI-Suche hat keine Sortierung — ihre Reihenfolge ist die Relevanz
|
||||
ohneSort: Boolean,
|
||||
// Der Katalog hat zwei Listen zur Wahl: [{ id, label, count }]
|
||||
quellen: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle', 'all', 'reset'])
|
||||
@@ -23,6 +25,7 @@ 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: () => ({}) })
|
||||
const quelle = defineModel('quelle', { type: String, default: '' })
|
||||
|
||||
// Auch ein verschobener Regler zählt als aktiver Filter
|
||||
const etwasAktiv = computed(
|
||||
@@ -53,6 +56,19 @@ function beschriften(regler) {
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="controls">
|
||||
<div v-if="quellen.length" class="ansicht">
|
||||
<button
|
||||
v-for="q in quellen"
|
||||
:key="q.id"
|
||||
type="button"
|
||||
:class="{ an: quelle === q.id }"
|
||||
@click="quelle = q.id"
|
||||
>
|
||||
{{ q.label }}
|
||||
<span v-if="q.count != null" class="zahl">{{ q.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-if="!ohneSuche"
|
||||
v-model="search"
|
||||
@@ -298,6 +314,12 @@ h4 {
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.ansicht .zahl {
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ansicht button {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.5rem;
|
||||
|
||||
@@ -79,7 +79,7 @@ async function open(movie) {
|
||||
v-model="beschreibung"
|
||||
:maxlength="MAX"
|
||||
rows="3"
|
||||
placeholder="Was suchst du? Zum Beispiel: langsamer Folk-Horror ohne viel Gore, gern aus Europa, ab 2010 …"
|
||||
placeholder="Was suchst du?"
|
||||
@keydown.ctrl.enter="fragen"
|
||||
></textarea>
|
||||
<div class="zeile">
|
||||
@@ -89,7 +89,7 @@ async function open(movie) {
|
||||
</label>
|
||||
<span class="rest"></span>
|
||||
<button type="button" class="los" :disabled="!kannFragen" @click="fragen">
|
||||
{{ laden ? 'Überlegt …' : 'Vorschlagen' }}
|
||||
{{ laden ? 'Sucht …' : 'Suchen' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,272 +7,310 @@ 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>
|
||||
|
||||
<template>
|
||||
<div class="wuensche-tab">
|
||||
<div v-if="angemeldet" class="neu">
|
||||
<textarea
|
||||
v-model="entwurf"
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
placeholder="Was fehlt dir auf der Seite?"
|
||||
@keydown.ctrl.enter="anlegen"
|
||||
></textarea>
|
||||
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">
|
||||
Eintragen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!items.length" class="note">Noch keine Wünsche.</p>
|
||||
|
||||
<section v-for="[titel, liste] in [['Offen', offene], ['Erledigt', erledigte]]" :key="titel">
|
||||
<template v-if="liste.length">
|
||||
<h3>
|
||||
{{ titel }}
|
||||
<span class="anzahl">{{ liste.length }}</span>
|
||||
</h3>
|
||||
<!-- Links das Offene samt Eingabe, rechts das Erledigte -->
|
||||
<div class="spalten">
|
||||
<div v-if="angemeldet" class="neu">
|
||||
<textarea
|
||||
v-model="entwurf"
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
placeholder="Was fehlt dir auf der Seite?"
|
||||
@keydown.ctrl.enter="anlegen"
|
||||
></textarea>
|
||||
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">
|
||||
Eintragen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="liste">
|
||||
<li v-for="w in liste" :key="w.id" :class="{ erledigt: w.done_at }">
|
||||
<!-- Eine Stimme je Person; nochmal klicken nimmt sie zurück -->
|
||||
<button
|
||||
type="button"
|
||||
class="stimme"
|
||||
:class="{ an: w.voted, hat: w.votes > 0 }"
|
||||
:disabled="!angemeldet"
|
||||
:title="w.voted ? 'Stimme zurücknehmen' : 'Dafür stimmen'"
|
||||
@click="abstimmen(w)"
|
||||
>
|
||||
▲ {{ w.votes }}
|
||||
</button>
|
||||
<section
|
||||
v-for="[titel, liste] in [
|
||||
['Offen', offene],
|
||||
['Erledigt', erledigte],
|
||||
]"
|
||||
:key="titel"
|
||||
:class="titel === 'Offen' ? 'spalte-offen' : 'spalte-erledigt'"
|
||||
>
|
||||
<template v-if="liste.length">
|
||||
<h3>
|
||||
{{ titel }}
|
||||
<span class="anzahl">{{ liste.length }}</span>
|
||||
</h3>
|
||||
|
||||
<div class="inhalt">
|
||||
<template v-if="bearbeitet === w.id">
|
||||
<textarea
|
||||
v-model="bearbeitung"
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
@keydown.ctrl.enter="speichern"
|
||||
@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">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<ul class="liste">
|
||||
<li v-for="w in liste" :key="w.id" :class="{ erledigt: w.done_at }">
|
||||
<!-- Eine Stimme je Person; nochmal klicken nimmt sie zurück -->
|
||||
<button
|
||||
type="button"
|
||||
class="stimme"
|
||||
:class="{ an: w.voted, hat: w.votes > 0 }"
|
||||
:disabled="!angemeldet"
|
||||
:title="w.voted ? 'Stimme zurücknehmen' : 'Dafür stimmen'"
|
||||
@click="abstimmen(w)"
|
||||
>
|
||||
▲ {{ w.votes }}
|
||||
</button>
|
||||
|
||||
<template v-else>
|
||||
<p class="text">{{ w.text }}</p>
|
||||
<p class="meta">
|
||||
<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 {{ datum(w.done_at) }}
|
||||
</template>
|
||||
<span class="punkt">·</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
:class="{ offen: aufgeklappt.has(w.id) }"
|
||||
@click="umschalten(w.id)"
|
||||
>
|
||||
{{ ergaenzungsLabel(w) }}
|
||||
</button>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<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="notiz-text">{{ n.text }}</span>
|
||||
<button
|
||||
v-if="host"
|
||||
type="button"
|
||||
class="weg sichtbar"
|
||||
title="Ergänzung entfernen"
|
||||
@click="notizLoeschen(n)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</p>
|
||||
<div v-if="angemeldet" class="notiz-neu">
|
||||
<input
|
||||
v-model="notizEntwurf[w.id]"
|
||||
type="text"
|
||||
<div class="inhalt">
|
||||
<template v-if="bearbeitet === w.id">
|
||||
<textarea
|
||||
v-model="bearbeitung"
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
placeholder="Ergänzung …"
|
||||
@keydown.enter="anmerken(w)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="klein"
|
||||
:disabled="!(notizEntwurf[w.id] || '').trim()"
|
||||
@click="anmerken(w)"
|
||||
>
|
||||
Anhängen
|
||||
</button>
|
||||
@keydown.ctrl.enter="speichern"
|
||||
@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"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text">{{ w.text }}</p>
|
||||
<p class="meta">
|
||||
<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
|
||||
{{ datum(w.done_at) }}
|
||||
</template>
|
||||
<span class="punkt">·</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
:class="{ offen: aufgeklappt.has(w.id) }"
|
||||
@click="umschalten(w.id)"
|
||||
>
|
||||
{{ ergaenzungsLabel(w) }}
|
||||
</button>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<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="notiz-text">{{ n.text }}</span>
|
||||
<button
|
||||
v-if="host"
|
||||
type="button"
|
||||
class="weg sichtbar"
|
||||
title="Ergänzung entfernen"
|
||||
@click="notizLoeschen(n)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</p>
|
||||
<div v-if="angemeldet" class="notiz-neu">
|
||||
<input
|
||||
v-model="notizEntwurf[w.id]"
|
||||
type="text"
|
||||
maxlength="500"
|
||||
placeholder="Ergänzung …"
|
||||
@keydown.enter="anmerken(w)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="klein"
|
||||
:disabled="!(notizEntwurf[w.id] || '').trim()"
|
||||
@click="anmerken(w)"
|
||||
>
|
||||
Anhängen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erst beim Überfahren sichtbar, sonst ist jede Zeile voller Knöpfe -->
|
||||
<div class="aktionen">
|
||||
<button
|
||||
v-if="host"
|
||||
type="button"
|
||||
class="weg"
|
||||
:title="w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'"
|
||||
@click="erledigen(w, !w.done_at)"
|
||||
>
|
||||
{{ w.done_at ? '↺' : '✓' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="darfAendern(w) && bearbeitet !== w.id"
|
||||
type="button"
|
||||
class="weg"
|
||||
title="Wunsch bearbeiten"
|
||||
@click="bearbeiten(w)"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
v-if="darfAendern(w)"
|
||||
type="button"
|
||||
class="weg"
|
||||
title="Wunsch entfernen"
|
||||
@click="loeschen(w)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</section>
|
||||
<!-- Erst beim Überfahren sichtbar, sonst ist jede Zeile voller Knöpfe -->
|
||||
<div class="aktionen">
|
||||
<button
|
||||
v-if="host"
|
||||
type="button"
|
||||
class="weg"
|
||||
:title="
|
||||
w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'
|
||||
"
|
||||
@click="erledigen(w, !w.done_at)"
|
||||
>
|
||||
{{ w.done_at ? "↺" : "✓" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="darfAendern(w) && bearbeitet !== w.id"
|
||||
type="button"
|
||||
class="weg"
|
||||
title="Wunsch bearbeiten"
|
||||
@click="bearbeiten(w)"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
v-if="darfAendern(w)"
|
||||
type="button"
|
||||
class="weg"
|
||||
title="Wunsch entfernen"
|
||||
@click="loeschen(w)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wuensche-tab {
|
||||
max-width: 46rem;
|
||||
.spalten {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"neu erledigt"
|
||||
"offen erledigt";
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 0 2.5rem;
|
||||
}
|
||||
|
||||
.neu {
|
||||
display: flex;
|
||||
grid-area: neu;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.spalte-offen {
|
||||
grid-area: offen;
|
||||
}
|
||||
|
||||
.spalte-erledigt {
|
||||
grid-area: erledigt;
|
||||
}
|
||||
|
||||
.neu textarea {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.8rem;
|
||||
@@ -517,6 +555,16 @@ h3 {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.spalten {
|
||||
grid-template-areas:
|
||||
"neu"
|
||||
"offen"
|
||||
"erledigt";
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px), (pointer: coarse) {
|
||||
.stimme {
|
||||
padding: 0.4rem 0.7rem;
|
||||
|
||||
@@ -29,10 +29,14 @@ async function hostStand() {
|
||||
}
|
||||
|
||||
const TAB_IDS = [
|
||||
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched',
|
||||
'wuensche', 'verwaltung', 'stream',
|
||||
'katalog', 'suche', 'wheel', 'merkliste', 'watched', 'wuensche', 'verwaltung', 'stream',
|
||||
]
|
||||
|
||||
// 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']
|
||||
|
||||
function gemerkt(schluessel, standard) {
|
||||
const wert = ref(localStorage.getItem(schluessel) ?? standard)
|
||||
watch(wert, (neu) => localStorage.setItem(schluessel, neu))
|
||||
@@ -50,11 +54,21 @@ export function useEinstellungen() {
|
||||
localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus')
|
||||
)
|
||||
|
||||
// Nach dem Neuladen soll derselbe Reiter offen sein
|
||||
// Nach dem Neuladen soll derselbe Reiter offen sein. Ein gemerkter
|
||||
// alter Reiter "netflix" oder "canon" landet im Katalog.
|
||||
const zuletzt = localStorage.getItem('filmabend_tab')
|
||||
const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'netflix')
|
||||
const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'katalog')
|
||||
watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id))
|
||||
|
||||
// Welche Liste der Katalog zeigt: Netflix oder den Horror-Kanon
|
||||
const quelle = gemerkt('filmabend_quelle', QUELLEN.includes(zuletzt) ? zuletzt : 'netflix')
|
||||
if (!QUELLEN.includes(quelle.value)) quelle.value = 'netflix'
|
||||
|
||||
// Welche Suche offen ist: Titel, Ähnliche oder KI
|
||||
const suchArt = gemerkt('filmabend_suchart', SUCHARTEN.includes(zuletzt) ? zuletzt : 'search')
|
||||
if (!SUCHARTEN.includes(suchArt.value)) suchArt.value = 'search'
|
||||
if (SUCHARTEN.includes(zuletzt)) activeTab.value = 'suche'
|
||||
|
||||
// Wer den Abend leitet, darf ziehen und die Runde leeren. Zwei Dinge
|
||||
// hängen daran: ob die Sitzung überhaupt freigeschaltet ist — das weiß
|
||||
// der Server — und ob die Knöpfe gerade sichtbar sind. Letzteres schaltet
|
||||
@@ -100,5 +114,5 @@ export function useEinstellungen() {
|
||||
onMounted(() => window.addEventListener('keydown', tastatur))
|
||||
onUnmounted(() => window.removeEventListener('keydown', tastatur))
|
||||
|
||||
return { ansicht, ohneGesehene, activeTab, host }
|
||||
return { ansicht, ohneGesehene, activeTab, quelle, suchArt, host }
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ const SUCH_GENRES = [
|
||||
'Western',
|
||||
].map((genre) => ({ genre, count: null }))
|
||||
|
||||
export function useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene }) {
|
||||
export function useFilter({
|
||||
activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene,
|
||||
}) {
|
||||
const search = ref('')
|
||||
const sort = ref('random')
|
||||
// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
|
||||
@@ -38,8 +40,8 @@ export function useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, o
|
||||
const include = ref([])
|
||||
const exclude = ref([])
|
||||
|
||||
const suchReiter = () => ['search', 'aehnlich', 'ki'].includes(activeTab.value)
|
||||
const pool = () => (activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value)
|
||||
const suchReiter = () => activeTab.value === 'suche'
|
||||
const pool = () => (quelle.value === 'netflix' ? netflixMovies.value : canonMovies.value)
|
||||
|
||||
const grenzen = computed(() => {
|
||||
if (suchReiter()) return SUCH_GRENZEN
|
||||
@@ -80,7 +82,7 @@ export function useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, o
|
||||
return true
|
||||
}
|
||||
|
||||
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben
|
||||
// Nur Genres anbieten, die in der gewählten Quelle auch Treffer haben
|
||||
const genreOptions = computed(() => {
|
||||
if (suchReiter()) return SUCH_GENRES
|
||||
const counts = new Map()
|
||||
|
||||
Reference in New Issue
Block a user