Neuigkeiten: Vereins-Feed mit Fotos/Videos, Reaktionen, Termin-Verknüpfung

This commit is contained in:
rene 2026-05-21 19:17:35 +02:00
parent d4a0a75cf7
commit 13c6ba73ca
6 changed files with 599 additions and 0 deletions

View file

@ -4,6 +4,7 @@ import calendar from './icons/calendar.svg?raw';
import currencyEur from './icons/currency-eur.svg?raw';
import envelope from './icons/envelope.svg?raw';
import gear from './icons/gear.svg?raw';
import images from './icons/images.svg?raw';
export const icons = {
house,
@ -12,6 +13,7 @@ export const icons = {
'currency-eur': currencyEur,
envelope,
gear,
images,
} as const;
export type IconName = keyof typeof icons;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><rect x="64" y="48" width="160" height="128" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="172" cy="84" r="12"/><path d="M64,128.69l38.34-38.35a8,8,0,0,1,11.32,0L163.31,140,189,114.34a8,8,0,0,1,11.31,0L224,138.06" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M192,176v24a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V88a8,8,0,0,1,8-8H64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>

After

Width:  |  Height:  |  Size: 672 B

View file

@ -43,6 +43,24 @@ export interface Mitglied {
}
export type Rolle = 'admin' | 'trainer';
export interface Neuigkeit {
id: string;
verein_id: string;
autor_id: string;
text?: string;
medien: string[];
gruppe_ids: string[];
termin_id?: string;
created: string;
expand?: { autor_id?: { id: string; name: string } };
}
export interface Reaktion {
id: string;
beitrag_id: string;
user_id: string;
}
export type Verfuegbarkeit = 'offen' | 'bestaetigt' | 'abgesagt' | 'vertretung_gesucht';
export interface Gruppe {

View file

@ -70,6 +70,7 @@
const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin';
const allNavItems: { href: string; label: string; icon: IconName; adminOnly?: boolean }[] = [
{ href: '/neuigkeiten', label: 'Neuigkeiten', icon: 'images' },
{ href: '/mitglieder', label: 'Mitglieder', icon: 'users' },
{ href: '/termine', label: 'Termine', icon: 'calendar' },
{ href: '/beitraege', label: 'Beiträge', icon: 'currency-eur', adminOnly: true },

View file

@ -0,0 +1,479 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { onMount } from 'svelte';
import type { Neuigkeit, Gruppe, Termin } from '$lib/types';
const canPost = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle !== null;
const userId = () => pb.authStore.record?.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('');
onMount(async () => {
const vid = pb.authStore.record?.verein_id as string;
const now = new Date().toISOString();
const [nList, gList, tList, rList, meineList] = await Promise.all([
pb.collection('neuigkeiten').getFullList<Neuigkeit>({
sort: '-created', expand: 'autor_id',
}),
pb.collection('gruppen').getFullList<Gruppe>({ sort: 'name' }),
pb.collection('termine').getFullList<Termin>({
filter: `beginn >= "${new Date(Date.now() - 30*24*60*60*1000).toISOString()}"`,
sort: '-beginn',
}),
pb.collection('reaktionen').getFullList({
filter: `beitrag_id.verein_id = "${vid}"`,
}),
pb.collection('reaktionen').getFullList({
filter: `user_id = "${userId()}"`,
}),
]);
neuigkeiten = nList;
gruppen = gList;
termine = tList;
// Reaktionen zählen
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;
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 vid = pb.authStore.record?.verein_id as string;
const form = new FormData();
form.append('verein_id', vid);
form.append('autor_id', userId());
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 pb.collection('neuigkeiten').create<Neuigkeit>(form, {
expand: 'autor_id',
});
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 pb.collection('neuigkeiten').delete(n.id);
neuigkeiten = neuigkeiten.filter(x => x.id !== n.id);
}
async function toggleReaktion(n: Neuigkeit) {
if (meineReaktion[n.id]) {
await pb.collection('reaktionen').delete(meineReaktion[n.id]);
meineReaktion = { ...meineReaktion, [n.id]: '' };
reaktionen = { ...reaktionen, [n.id]: Math.max(0, (reaktionen[n.id] ?? 1) - 1) };
} else {
const r = await pb.collection('reaktionen').create({
beitrag_id: n.id, user_id: userId(),
});
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 pb.files.getURL(n as any, datei, thumb ? { thumb: '400x300' } : {});
}
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.expand?.autor_id?.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 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: #1e293b; }
.hint { color: #94a3b8; text-align: center; margin-top: 3rem; }
.feed { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 5rem; }
.beitrag {
background: #fff; border: 1px solid #e2e8f0; 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: #e0e7ff; color: #1e40af;
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: #1e293b; }
.zeit { font-size: 0.72rem; color: #94a3b8; }
.btn-del {
background: none; border: none; color: #94a3b8;
font-size: 0.85rem; cursor: pointer; padding: 0.2rem;
}
.btn-del:hover { color: #dc2626; }
.termin-tag {
font-size: 0.75rem; color: #1e40af; background: #eff6ff;
border-radius: 20px; padding: 0.2rem 0.65rem;
display: inline-block; margin-bottom: 0.5rem;
}
.beitrag-text {
font-size: 0.92rem; color: #1e293b; 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 #f1f5f9; margin-top: 0.25rem;
}
.btn-like {
background: none; border: 1px solid #e2e8f0; border-radius: 20px;
padding: 0.25rem 0.75rem; font-size: 0.82rem; cursor: pointer;
transition: all 0.15s; color: #64748b;
}
.btn-like.aktiv { background: #fef9c3; border-color: #fbbf24; color: #92400e; }
.gruppe-tag { font-size: 0.72rem; color: #94a3b8; }
/* 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: #fff; 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: #1e293b; 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: #475569; }
textarea, select {
padding: 0.65rem 0.85rem; border: 1.5px solid #e2e8f0; border-radius: 8px;
font-size: 1rem; background: #fff; width: 100%;
box-sizing: border-box; font-family: inherit; resize: vertical;
transition: border-color 0.15s;
}
textarea:focus, select:focus { outline: none; border-color: #1e40af; }
.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 #e2e8f0; border-radius: 8px;
font-size: 0.875rem; color: #64748b; cursor: pointer;
transition: border-color 0.15s;
}
.upload-label:hover { border-color: #1e40af; color: #1e40af; }
.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: #f1f5f9; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; color: #64748b;
}
.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 #e2e8f0; border-radius: 20px;
font-size: 0.82rem; cursor: pointer;
}
.check-label.active { border-color: #1e40af; background: #e0e7ff; color: #1e40af; }
.check-label input { display: none; }
.error { color: #dc2626; 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: #1e40af; color: #fff;
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 #e2e8f0; border-radius: 8px;
font-size: 1rem; color: #64748b; cursor: pointer;
}
.btn-primary { background: #1e40af; color: #fff; 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>

View file

@ -0,0 +1,98 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
// neuigkeiten Vereins-Feed mit Medien
{
const c = new Collection({
"createRule": "@request.auth.verein_id = verein_id",
"deleteRule": "@request.auth.verein_id = verein_id && autor_id = @request.auth.id",
"listRule": "@request.auth.verein_id = verein_id",
"viewRule": "@request.auth.verein_id = verein_id",
"updateRule": "@request.auth.verein_id = verein_id && autor_id = @request.auth.id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}", "id": "text3208210256",
"max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$",
"primaryKey": true, "required": true, "system": true, "type": "text",
"help": "", "hidden": false, "presentable": false
},
{
"type": "relation", "id": "relation2001000200", "name": "verein_id",
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
"cascadeDelete": true, "collectionId": "pbc_3589557411", "maxSelect": 1, "minSelect": 0
},
{
"type": "relation", "id": "relation2001000201", "name": "autor_id",
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
"cascadeDelete": false, "collectionId": "_pb_users_auth_", "maxSelect": 1, "minSelect": 0
},
{
"type": "text", "id": "text2001000202", "name": "text",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
},
{
"type": "file", "id": "file2001000203", "name": "medien",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"maxSelect": 10, "maxSize": 15728640,
"mimeTypes": ["image/jpeg","image/png","image/gif","image/webp","video/mp4","video/quicktime"]
},
{
"type": "relation", "id": "relation2001000204", "name": "gruppe_ids",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"cascadeDelete": false, "collectionId": "pbc_3099069179", "maxSelect": 99, "minSelect": 0
},
{
"type": "relation", "id": "relation2001000205", "name": "termin_id",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"cascadeDelete": false, "collectionId": "pbc_2279568741", "maxSelect": 1, "minSelect": 0
}
],
"id": "pbc_neuigkeiten",
"indexes": [],
"name": "neuigkeiten",
"system": false,
"type": "base"
})
app.save(c)
}
// reaktionen 👍 pro User pro Beitrag
{
const c = new Collection({
"createRule": "@request.auth.id != ''",
"deleteRule": "@request.auth.id = user_id",
"listRule": "@request.auth.verein_id = beitrag_id.verein_id",
"viewRule": "@request.auth.verein_id = beitrag_id.verein_id",
"updateRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}", "id": "text3208210256",
"max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$",
"primaryKey": true, "required": true, "system": true, "type": "text",
"help": "", "hidden": false, "presentable": false
},
{
"type": "relation", "id": "relation2001000210", "name": "beitrag_id",
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
"cascadeDelete": true, "collectionId": "pbc_neuigkeiten", "maxSelect": 1, "minSelect": 0
},
{
"type": "relation", "id": "relation2001000211", "name": "user_id",
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
"cascadeDelete": true, "collectionId": "_pb_users_auth_", "maxSelect": 1, "minSelect": 0
}
],
"id": "pbc_reaktionen",
"indexes": ["CREATE UNIQUE INDEX idx_reaktion_unique ON reaktionen (beitrag_id, user_id)"],
"name": "reaktionen",
"system": false,
"type": "base"
})
app.save(c)
}
}, (app) => {
try { app.delete(app.findCollectionByNameOrId("pbc_reaktionen")) } catch(_) {}
try { app.delete(app.findCollectionByNameOrId("pbc_neuigkeiten")) } catch(_) {}
})