476 lines
15 KiB
Svelte
476 lines
15 KiB
Svelte
<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>
|