Neuigkeiten: Vereins-Feed mit Fotos/Videos, Reaktionen, Termin-Verknüpfung
This commit is contained in:
parent
d4a0a75cf7
commit
13c6ba73ca
6 changed files with 599 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
1
app/src/lib/icons/images.svg
Normal file
1
app/src/lib/icons/images.svg
Normal 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 |
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
479
app/src/routes/(app)/neuigkeiten/+page.svelte
Normal file
479
app/src/routes/(app)/neuigkeiten/+page.svelte
Normal 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>
|
||||
98
pocketbase/pb_migrations/1779230900_neuigkeiten.js
Normal file
98
pocketbase/pb_migrations/1779230900_neuigkeiten.js
Normal 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(_) {}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue