vereinshaus/app/src/routes/(app)/neuigkeiten/+page.svelte

476 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { api } from '$lib/api';
import { user } from '$lib/user';
import { get } from 'svelte/store';
import { onMount } from 'svelte';
import type { Neuigkeit, Gruppe, Termin } from '$lib/types';
const canPost = () => true; // alle eingeloggten User dürfen posten
const userId = () => get(user)?.id as string;
let neuigkeiten = $state<Neuigkeit[]>([]);
let gruppen = $state<Gruppe[]>([]);
let termine = $state<Termin[]>([]);
let reaktionen = $state<Record<string, number>>({}); // beitrag_id → count
let meineReaktion = $state<Record<string, string>>({}); // beitrag_id → reaktion_id
let loading = $state(true);
// Formular
let showForm = $state(false);
let fText = $state('');
let fGruppeIds = $state<string[]>([]);
let fTerminId = $state('');
let fDateien = $state<FileList | null>(null);
let previews = $state<string[]>([]);
let saving = $state(false);
let formError = $state('');
// Lightbox
let lightboxUrl = $state('');
let ladeError = $state('');
onMount(async () => {
try {
// Queries einzeln damit ein Fehler sichtbar wird
const [nList, gList] = await Promise.all([
api.get<Neuigkeit[]>('/neuigkeiten', { sort: '-created' }),
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
]);
neuigkeiten = nList;
gruppen = gList;
// Termine der letzten 30 Tage + zukünftige
try {
termine = await api.get<Termin[]>('/termine', { sort: '-beginn' });
} catch { termine = []; }
// Reaktionen separat damit Fehler nicht alles blockiert
try {
const [rList, meineList] = await Promise.all([
api.get<any[]>('/reaktionen'),
api.get<any[]>('/reaktionen', { meine: 'true' }),
]);
const counts: Record<string, number> = {};
for (const r of rList) counts[r.beitrag_id] = (counts[r.beitrag_id] ?? 0) + 1;
reaktionen = counts;
const mine: Record<string, string> = {};
for (const r of meineList) mine[r.beitrag_id] = r.id;
meineReaktion = mine;
} catch { /* keine Reaktionen = kein Problem */ }
} catch (e: unknown) {
ladeError = e instanceof Error ? e.message : 'Ladefehler';
} finally {
loading = false;
}
});
function handleDateiAuswahl(e: Event) {
const files = (e.target as HTMLInputElement).files;
fDateien = files;
previews = [];
if (!files) return;
for (const file of Array.from(files)) {
if (file.type.startsWith('image/')) {
previews.push(URL.createObjectURL(file));
} else {
previews.push(''); // Video kein Preview
}
}
}
function toggleGruppe(id: string) {
fGruppeIds = fGruppeIds.includes(id)
? fGruppeIds.filter(g => g !== id)
: [...fGruppeIds, id];
}
async function posten() {
if (!fText.trim() && (!fDateien || fDateien.length === 0)) {
formError = 'Text oder Foto ist Pflicht.';
return;
}
formError = ''; saving = true;
try {
const u = get(user);
const form = new FormData();
form.append('autor_name', u?.name ?? '');
if (fText.trim()) form.append('text', fText.trim());
if (fTerminId) form.append('termin_id', fTerminId);
for (const id of fGruppeIds) form.append('gruppe_ids', id);
if (fDateien) {
for (const file of Array.from(fDateien)) form.append('medien', file);
}
const neu = await api.postForm<Neuigkeit>('/neuigkeiten', form);
neuigkeiten = [neu, ...neuigkeiten];
showForm = false;
fText = ''; fGruppeIds = []; fTerminId = ''; fDateien = null; previews = [];
} catch (e: unknown) {
formError = e instanceof Error ? e.message : 'Fehler beim Posten.';
} finally {
saving = false;
}
}
async function loeschen(n: Neuigkeit) {
if (!confirm('Beitrag löschen?')) return;
await api.del('/neuigkeiten/' + n.id);
neuigkeiten = neuigkeiten.filter(x => x.id !== n.id);
}
async function toggleReaktion(n: Neuigkeit) {
if (meineReaktion[n.id]) {
await api.del('/reaktionen/' + meineReaktion[n.id]);
meineReaktion = { ...meineReaktion, [n.id]: '' };
reaktionen = { ...reaktionen, [n.id]: Math.max(0, (reaktionen[n.id] ?? 1) - 1) };
} else {
const r = await api.post<{ id: string }>('/reaktionen', { beitrag_id: n.id });
meineReaktion = { ...meineReaktion, [n.id]: r.id };
reaktionen = { ...reaktionen, [n.id]: (reaktionen[n.id] ?? 0) + 1 };
}
}
function mediaUrl(n: Neuigkeit, datei: string, thumb = false): string {
return api.fileUrl(n.verein_id, n.id, datei, thumb);
}
function isVideo(datei: string): boolean {
return /\.(mp4|mov|m4v)$/i.test(datei);
}
function zeitAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min.`;
const h = Math.floor(min / 60);
if (h < 24) return `vor ${h} Std.`;
const d = Math.floor(h / 24);
if (d < 7) return `vor ${d} Tag${d > 1 ? 'en' : ''}`;
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: 'short' });
}
function autorName(n: Neuigkeit): string {
return n.autor_name || 'Unbekannt';
}
function terminName(id: string): string {
return termine.find(t => t.id === id)?.titel ?? '';
}
</script>
<svelte:head><title>Neuigkeiten — vereins.haus</title></svelte:head>
<div class="top">
<h1>Neuigkeiten</h1>
{#if canPost()}
<button class="btn-primary" onclick={() => showForm = true}>+ Beitrag</button>
{/if}
</div>
{#if loading}
<p class="hint">Laden…</p>
{:else if ladeError}
<p class="hint" style="color:#dc2626">{ladeError}</p>
{:else if neuigkeiten.length === 0}
<p class="hint">Noch keine Beiträge schreib den ersten!</p>
{:else}
<div class="feed">
{#each neuigkeiten as n (n.id)}
<article class="beitrag">
<div class="beitrag-kopf">
<div class="avatar">{autorName(n)[0]?.toUpperCase()}</div>
<div class="beitrag-meta">
<span class="autor">{autorName(n)}</span>
<span class="zeit">{zeitAgo(n.created)}</span>
</div>
{#if n.autor_id === userId()}
<button class="btn-del" onclick={() => loeschen(n)}>✕</button>
{/if}
</div>
{#if n.termin_id}
<div class="termin-tag">📅 {terminName(n.termin_id)}</div>
{/if}
{#if n.text}
<p class="beitrag-text">{n.text}</p>
{/if}
{#if n.medien?.length > 0}
<div class="medien-grid" class:single={n.medien.length === 1}>
{#each n.medien as datei (datei)}
{#if isVideo(datei)}
<video controls class="media-item" preload="metadata">
<source src={mediaUrl(n, datei)} />
</video>
{:else}
<button
class="media-btn"
onclick={() => lightboxUrl = mediaUrl(n, datei)}
>
<img
src={mediaUrl(n, datei, true)}
alt=""
class="media-item"
loading="lazy"
/>
</button>
{/if}
{/each}
</div>
{/if}
<div class="beitrag-fuss">
<button
class="btn-like"
class:aktiv={!!meineReaktion[n.id]}
onclick={() => toggleReaktion(n)}
>
👍 {reaktionen[n.id] ?? 0}
</button>
{#if n.gruppe_ids?.length > 0}
<span class="gruppe-tag">
{n.gruppe_ids.length === 1
? (gruppen.find(g => g.id === n.gruppe_ids[0])?.name ?? '')
: `${n.gruppe_ids.length} Gruppen`}
</span>
{/if}
</div>
</article>
{/each}
</div>
{/if}
<!-- Beitrag verfassen -->
{#if showForm}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>Neuer Beitrag</h2>
<form onsubmit={(e) => { e.preventDefault(); posten(); }}>
<div class="field">
<textarea bind:value={fText} rows="4" placeholder="Was gibt's Neues?"></textarea>
</div>
<!-- Foto/Video Upload -->
<div class="upload-area">
<label class="upload-label">
<input
type="file"
accept="image/*,video/mp4,video/quicktime"
multiple
class="file-input"
onchange={handleDateiAuswahl}
/>
📷 Fotos / Videos hinzufügen
</label>
{#if previews.length > 0}
<div class="preview-grid">
{#each previews as p, i}
{#if p}
<img src={p} alt="" class="preview-img" />
{:else}
<div class="preview-video">🎬 Video {i + 1}</div>
{/if}
{/each}
</div>
{/if}
</div>
<!-- Termin verknüpfen -->
{#if termine.length > 0}
<div class="field">
<label for="ftermin">Zum Termin</label>
<select id="ftermin" bind:value={fTerminId}>
<option value="">— keinen verknüpfen —</option>
{#each termine as t (t.id)}
<option value={t.id}>{t.titel} · {new Date(t.beginn).toLocaleDateString('de-DE')}</option>
{/each}
</select>
</div>
{/if}
<!-- Gruppen -->
{#if gruppen.length > 0}
<div class="field">
<span class="field-label">Für Gruppen (leer = alle)</span>
<div class="checkboxes">
{#each gruppen as g (g.id)}
<label class="check-label" class:active={fGruppeIds.includes(g.id)}>
<input type="checkbox" checked={fGruppeIds.includes(g.id)} onchange={() => toggleGruppe(g.id)} />
{g.name}
</label>
{/each}
</div>
</div>
{/if}
{#if formError}<p class="error">{formError}</p>{/if}
<div class="actions">
<button type="button" class="btn-ghost" onclick={() => showForm = false}>Abbrechen</button>
<button type="submit" class="btn-primary-full" disabled={saving}>
{saving ? 'Posten…' : 'Posten'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Lightbox -->
{#if lightboxUrl}
<div class="lightbox" role="button" tabindex="0"
onclick={() => lightboxUrl = ''}
onkeydown={(e) => e.key === 'Escape' && (lightboxUrl = '')}>
<img src={lightboxUrl} alt="" />
</div>
{/if}
<style>
.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
.hint { color: var(--c-text-hint); text-align: center; margin-top: 3rem; }
.feed { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 5rem; }
.beitrag {
background: var(--c-bg-card); border: 1px solid var(--c-border); border-radius: 12px;
overflow: hidden; padding: 0.9rem 1rem;
}
.beitrag-kopf {
display: flex; align-items: center; gap: 0.65rem; margin-bottom: 0.6rem;
}
.avatar {
width: 2.2rem; height: 2.2rem; border-radius: 50%;
background: var(--c-primary-light); color: var(--c-primary);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.85rem; flex-shrink: 0;
}
.beitrag-meta { flex: 1; display: flex; flex-direction: column; gap: 0.05rem; }
.autor { font-weight: 600; font-size: 0.875rem; color: var(--c-text); }
.zeit { font-size: 0.72rem; color: var(--c-text-hint); }
.btn-del {
background: none; border: none; color: var(--c-text-hint);
font-size: 0.85rem; cursor: pointer; padding: 0.2rem;
}
.btn-del:hover { color: var(--c-error); }
.termin-tag {
font-size: 0.75rem; color: var(--c-primary); background: var(--c-primary-subtle);
border-radius: 20px; padding: 0.2rem 0.65rem;
display: inline-block; margin-bottom: 0.5rem;
}
.beitrag-text {
font-size: 0.92rem; color: var(--c-text); line-height: 1.55;
white-space: pre-wrap; margin: 0 0 0.6rem;
}
/* Medien-Grid */
.medien-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.25rem;
border-radius: 8px;
overflow: hidden;
margin-bottom: 0.6rem;
}
.medien-grid.single { grid-template-columns: 1fr; }
.media-btn {
display: block; padding: 0; background: none; border: none;
cursor: pointer; overflow: hidden;
}
.media-item {
width: 100%; aspect-ratio: 4/3; object-fit: cover; display: block;
transition: opacity 0.15s;
}
.media-btn:hover .media-item { opacity: 0.9; }
.beitrag-fuss {
display: flex; align-items: center; gap: 0.75rem; padding-top: 0.5rem;
border-top: 1px solid var(--c-bg); margin-top: 0.25rem;
}
.btn-like {
background: none; border: 1px solid var(--c-border); border-radius: 20px;
padding: 0.25rem 0.75rem; font-size: 0.82rem; cursor: pointer;
transition: all 0.15s; color: var(--c-text-muted);
}
.btn-like.aktiv { background: var(--c-warning-bg); border-color: var(--c-warning); color: var(--c-warning-dark); }
.gruppe-tag { font-size: 0.72rem; color: var(--c-text-hint); }
/* Formular */
.overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: flex-end; justify-content: center;
z-index: 100; padding: 1rem; padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
.sheet {
background: var(--c-bg-card); border-radius: 16px; padding: 1.5rem;
width: 100%; max-width: 480px; max-height: 92dvh; overflow-y: auto;
}
h2 { font-size: 1.1rem; font-weight: 700; color: var(--c-text); margin-bottom: 1rem; }
.field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.85rem; }
label, .field-label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-secondary); }
textarea, select {
padding: 0.65rem 0.85rem; border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; background: var(--c-bg-card); width: 100%;
box-sizing: border-box; font-family: inherit; resize: vertical;
transition: border-color 0.15s;
}
textarea:focus, select:focus { outline: none; border-color: var(--c-primary); }
.upload-area { margin-bottom: 0.85rem; }
.upload-label {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.5rem 0.85rem; border: 1.5px dashed var(--c-border); border-radius: 8px;
font-size: 0.875rem; color: var(--c-text-muted); cursor: pointer;
transition: border-color 0.15s;
}
.upload-label:hover { border-color: var(--c-primary); color: var(--c-primary); }
.file-input { display: none; }
.preview-grid {
display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem;
}
.preview-img { width: 72px; height: 72px; object-fit: cover; border-radius: 6px; }
.preview-video {
width: 72px; height: 72px; background: var(--c-bg); border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; color: var(--c-text-muted);
}
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.25rem; }
.check-label {
display: flex; align-items: center; gap: 0.35rem;
padding: 0.3rem 0.65rem; border: 1.5px solid var(--c-border); border-radius: 20px;
font-size: 0.82rem; cursor: pointer;
}
.check-label.active { border-color: var(--c-primary); background: var(--c-primary-light); color: var(--c-primary); }
.check-label input { display: none; }
.error { color: var(--c-error); font-size: 0.875rem; margin-bottom: 0.5rem; }
.actions { display: flex; gap: 0.75rem; margin-top: 0.5rem; }
.btn-primary-full {
flex: 1; padding: 0.75rem; background: var(--c-primary); color: var(--c-bg-card);
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer;
}
.btn-primary-full:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem; background: none;
border: 1.5px solid var(--c-border); border-radius: 8px;
font-size: 1rem; color: var(--c-text-muted); cursor: pointer;
}
.btn-primary { background: var(--c-primary); color: var(--c-bg-card); border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
/* Lightbox */
.lightbox {
position: fixed; inset: 0; background: rgba(0,0,0,0.9);
display: flex; align-items: center; justify-content: center;
z-index: 200; cursor: zoom-out;
}
.lightbox img { max-width: 95vw; max-height: 90dvh; border-radius: 8px; }
</style>