Rapport 0.6 — Initial Public Release
Sicherheits-Hardening - Passwort-Hashing mit PBKDF2 (SHA-256, 100k Iterationen) inkl. transparenter Migration bestehender Klartext-Passwörter beim ersten Login - Login Brute-Force-Schutz (5 Fehlversuche → 60s Lockout), Constant-Time-Compare, Mindestpasswortlänge 8 Zeichen - HTML-Sanitizer für Brieftexte (Allowlist, entfernt javascript:/data:/vbscript:-URLs, Event-Handler, Script-Tags; rel=noopener für target=_blank) - Datenexport entfernt Legacy-Klartextpasswörter (Hashes bleiben) - Kryptografische IDs via crypto.randomUUID statt Math.random - sessionStorage speichert keine Credentials mehr GUI & Performance - Code-Splitting pro View via React.lazy + Suspense (Initial-Bundle 86 KB gzipped) - swissqrbill als lokale Dependency — QR-Rechnungen offline-fähig - Spesenbelege (Bild/PDF) direkt in der Tageserfassung mit Bildkomprimierung - Avatar-Upload: 256px-Skalierung + JPEG-Kompression, Typprüfung - Über-Rapport-Modal, einheitliche Bearbeiten-Icons, Pinnwand-Kategorien als Pills Bug-Fixes - Auto-überfällig-Routine läuft nur noch einmal pro Tag (verhindert Re-Render-Loop) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Executable
+417
@@ -0,0 +1,417 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { generateId } from "../utils.js";
|
||||
|
||||
const TYPE_META = {
|
||||
beitrag: { label: "Beitrag", color: "#1a4e8a", bg: "#e8f0fa" },
|
||||
ankuendigung: { label: "Ankündigung", color: "#b5621e", bg: "#fdf0e8" },
|
||||
event: { label: "Event", color: "#2d6a4f", bg: "#e8f5ee" },
|
||||
};
|
||||
|
||||
function canWriteCheck(currentUser, data) {
|
||||
const myUser = (data.users || []).find(u => u.id === currentUser?.id);
|
||||
const myRole = (data.appRoles|| []).find(r => r.id === (currentUser?.appRoleId || myUser?.appRoleId));
|
||||
return currentUser?.role === "admin" || myRole?.permissions === null || (myRole?.permissions || []).includes("pinnwand-schreiben");
|
||||
}
|
||||
|
||||
// ─── Poll bar ─────────────────────────────────────────────────────────────────
|
||||
function PollBlock({ poll, postId, currentUserId, onVote }) {
|
||||
if (!poll) return null;
|
||||
const total = Object.keys(poll.votes || {}).length;
|
||||
const myVote = poll.votes?.[currentUserId];
|
||||
const hasVoted = !!myVote;
|
||||
return (
|
||||
<div style={{ marginTop: 14, padding: "12px 14px", background: "var(--surface2)", borderRadius: 10, border: "1px solid var(--border2)" }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text)", marginBottom: 10 }}>{poll.question}</div>
|
||||
{poll.options.map(opt => {
|
||||
const count = Object.values(poll.votes || {}).filter(v => v === opt.id).length;
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
const isMyVote = myVote === opt.id;
|
||||
return (
|
||||
<div key={opt.id} style={{ marginBottom: 8 }}>
|
||||
{!hasVoted ? (
|
||||
<button onClick={() => onVote(postId, opt.id)} style={{
|
||||
width: "100%", padding: "8px 14px", textAlign: "left",
|
||||
background: "var(--surface)", border: "1.5px solid var(--border)",
|
||||
borderRadius: 8, cursor: "pointer", fontSize: 13, fontFamily: "inherit",
|
||||
color: "var(--text)", transition: "border-color 0.15s",
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = "#9a7858"}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = "var(--border)"}
|
||||
>{opt.label}</button>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4, fontSize: 12 }}>
|
||||
<span style={{ color: isMyVote ? "var(--text)" : "var(--text3)", fontWeight: isMyVote ? 600 : 400 }}>
|
||||
{isMyVote ? "✓ " : ""}{opt.label}
|
||||
</span>
|
||||
<span style={{ color: "var(--text4)" }}>{count} ({pct}%)</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: "var(--border2)", borderRadius: 2 }}>
|
||||
<div style={{ width: `${pct}%`, height: "100%", background: isMyVote ? "#1a4e8a" : "var(--border3)", borderRadius: 2, transition: "width 0.4s" }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasVoted && <div style={{ fontSize: 11, color: "var(--text4)", marginTop: 6 }}>{total} Stimme{total !== 1 ? "n" : ""} abgegeben</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Post card ────────────────────────────────────────────────────────────────
|
||||
function PostCard({ post, currentUser, canWrite, onEdit, onDelete, onPin, onVote }) {
|
||||
const meta = TYPE_META[post.type] || TYPE_META.beitrag;
|
||||
const isOwn = post.authorId === currentUser?.id;
|
||||
const isAdmin = currentUser?.role === "admin";
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [confirmDel, setConfirmDel] = useState(false);
|
||||
const bodyLines = (post.body || "").split("\n");
|
||||
const isLong = bodyLines.length > 6 || post.body?.length > 400;
|
||||
const isDraft = post.status === "entwurf";
|
||||
|
||||
return (
|
||||
<div style={{ background: "var(--surface)", border: isDraft ? "1.5px dashed var(--border3)" : "1px solid var(--border2)", borderRadius: 14, marginBottom: 14, boxShadow: "0 1px 4px rgba(0,0,0,0.05)", overflow: "hidden", opacity: isDraft ? 0.85 : 1 }}>
|
||||
{post.image && (
|
||||
<img src={post.image} alt="" style={{ width: "100%", maxHeight: 300, objectFit: "cover", display: "block" }} />
|
||||
)}
|
||||
<div style={{ padding: "18px 22px" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: "2px 10px", borderRadius: 20, color: meta.color, background: meta.bg, letterSpacing: "0.06em" }}>
|
||||
{meta.label.toUpperCase()}
|
||||
</span>
|
||||
{isDraft && <span style={{ fontSize: 10, color: "#7a6a00", background: "#fffbe6", fontWeight: 600, padding: "2px 10px", borderRadius: 20, letterSpacing: "0.06em" }}>ENTWURF</span>}
|
||||
{post.pinned && <span style={{ fontSize: 10, color: "#b07848", fontWeight: 600, letterSpacing: "0.06em", display: "flex", alignItems: "center", gap: 3 }}><span className="material-icons" style={{ fontSize: 12 }}>push_pin</span> ANGEPINNT</span>}
|
||||
{post.eventDate && (
|
||||
<span style={{ fontSize: 11, color: "#2d6a4f", background: "#e8f5ee", padding: "2px 10px", borderRadius: 20 }}>
|
||||
📅 {new Date(post.eventDate).toLocaleDateString("de-CH", { weekday: "short", day: "numeric", month: "long" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, flexShrink: 0, marginLeft: 8 }}>
|
||||
{isAdmin && !isDraft && (
|
||||
<button onClick={() => onPin(post.id)} title={post.pinned ? "Loslösen" : "Anpinnen"}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", opacity: post.pinned ? 1 : 0.4, padding: 2, display: "flex", alignItems: "center" }}><span className="material-icons" style={{ fontSize: 18 }}>push_pin</span></button>
|
||||
)}
|
||||
{(isOwn || isAdmin) && canWrite && (
|
||||
<>
|
||||
<button onClick={() => onEdit(post)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text4)", padding: "2px 6px", display: "flex", alignItems: "center" }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
|
||||
{confirmDel ? (
|
||||
<>
|
||||
<span style={{ fontSize: 11, color: "#8a1a1a", fontFamily: "inherit" }}>Löschen?</span>
|
||||
<button onClick={() => onDelete(post.id)} style={{ background: "#8a1a1a", color: "#fff", border: "none", borderRadius: 5, cursor: "pointer", fontSize: 11, padding: "2px 8px", fontFamily: "inherit" }}>Ja</button>
|
||||
<button onClick={() => setConfirmDel(false)} style={{ background: "none", border: "1px solid var(--border3)", borderRadius: 5, cursor: "pointer", fontSize: 11, padding: "2px 8px", fontFamily: "inherit", color: "var(--text3)" }}>Nein</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDel(true)} style={{ background: "none", border: "none", cursor: "pointer", color: "#8a1a1a", padding: "2px 6px", display: "flex", alignItems: "center" }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{post.title && <div style={{ fontSize: 17, fontFamily: "'Playfair Display', serif", color: "var(--text)", marginBottom: 8, lineHeight: 1.3 }}>{post.title}</div>}
|
||||
|
||||
<div style={{ fontSize: 13, color: "var(--text2)", lineHeight: 1.7, whiteSpace: "pre-wrap", overflow: "hidden", maxHeight: expanded ? "none" : "6.8em" }}>
|
||||
{post.body}
|
||||
</div>
|
||||
{isLong && (
|
||||
<button onClick={() => setExpanded(e => !e)} style={{ background: "none", border: "none", color: "#9a7858", fontSize: 12, cursor: "pointer", fontFamily: "inherit", padding: "4px 0", marginTop: 2 }}>
|
||||
{expanded ? "Weniger anzeigen ↑" : "Mehr anzeigen ↓"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<PollBlock poll={post.poll} postId={post.id} currentUserId={currentUser?.id} onVote={onVote} />
|
||||
|
||||
<div style={{ marginTop: 12, fontSize: 11, color: "var(--text4)", display: "flex", gap: 16 }}>
|
||||
<span>{post.authorName}</span>
|
||||
<span>{new Date(post.createdAt).toLocaleDateString("de-CH", { day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Post modal ───────────────────────────────────────────────────────────────
|
||||
function PostModal({ initial, onSave, onClose }) {
|
||||
const [form, setForm] = useState(initial || { title: "", body: "", type: "beitrag", eventDate: "", pinned: false, poll: null, image: "" });
|
||||
const [pollEnabled, setPollEnabled] = useState(!!initial?.poll);
|
||||
const [poll, setPoll] = useState(initial?.poll || { question: "", options: [{ id: generateId(), label: "" }, { id: generateId(), label: "" }], votes: {} });
|
||||
const fileInputRef = useRef(null);
|
||||
const setF = patch => setForm(f => ({ ...f, ...patch }));
|
||||
const setOpt = (id, label) => setPoll(p => ({ ...p, options: p.options.map(o => o.id === id ? { ...o, label } : o) }));
|
||||
const addOpt = () => { if (poll.options.length >= 5) return; setPoll(p => ({ ...p, options: [...p.options, { id: generateId(), label: "" }] })); };
|
||||
const delOpt = id => { if (poll.options.length <= 2) return; setPoll(p => ({ ...p, options: p.options.filter(o => o.id !== id) })); };
|
||||
|
||||
const handleImage = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const maxW = 1200;
|
||||
const scale = img.width > maxW ? maxW / img.width : 1;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(img.width * scale);
|
||||
canvas.height = Math.round(img.height * scale);
|
||||
canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
setF({ image: canvas.toDataURL("image/jpeg", 0.8) });
|
||||
};
|
||||
img.src = ev.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleSave = (draft = false) => {
|
||||
if (!form.body.trim()) return;
|
||||
const finalPoll = pollEnabled && poll.question.trim() && poll.options.filter(o => o.label.trim()).length >= 2
|
||||
? { ...poll, options: poll.options.filter(o => o.label.trim()), votes: initial?.poll?.votes || {} }
|
||||
: null;
|
||||
onSave({ ...form, poll: finalPoll }, draft);
|
||||
};
|
||||
|
||||
const isDraft = initial?.status === "entwurf";
|
||||
const isNew = !initial;
|
||||
const primaryLabel = (isNew || isDraft) ? "Veröffentlichen" : "Speichern";
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth: 580 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 20, marginBottom: 20, color: "var(--text)" }}>
|
||||
{initial ? "Beitrag bearbeiten" : "Neuer Beitrag"}
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: 14 }}>
|
||||
<div className="form-group" style={{ flex: "0 0 160px" }}>
|
||||
<label>TYP</label>
|
||||
<select value={form.type} onChange={e => setF({ type: e.target.value })}>
|
||||
{Object.entries(TYPE_META).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{form.type === "event" && (
|
||||
<div className="form-group">
|
||||
<label>DATUM</label>
|
||||
<input type="date" value={form.eventDate} onChange={e => setF({ eventDate: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 14 }}>
|
||||
<label>TITEL (optional)</label>
|
||||
<input value={form.title} onChange={e => setF({ title: e.target.value })} placeholder="Titel des Beitrags…" />
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: 14 }}>
|
||||
<label>INHALT *</label>
|
||||
<textarea value={form.body} onChange={e => setF({ body: e.target.value })} rows={5} placeholder="Beitragstext…" style={{ minHeight: 120, resize: "vertical" }} />
|
||||
</div>
|
||||
|
||||
{/* Image upload */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: "none" }} onChange={handleImage} />
|
||||
{form.image ? (
|
||||
<div style={{ position: "relative" }}>
|
||||
<img src={form.image} alt="" style={{ width: "100%", maxHeight: 220, objectFit: "cover", borderRadius: 8, display: "block" }} />
|
||||
<button onClick={() => setF({ image: "" })} style={{
|
||||
position: "absolute", top: 6, right: 6, background: "rgba(0,0,0,0.55)", border: "none",
|
||||
borderRadius: 6, color: "#fff", fontSize: 11, padding: "4px 10px", cursor: "pointer", fontFamily: "inherit",
|
||||
}}>Bild entfernen</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => fileInputRef.current?.click()} style={{
|
||||
width: "100%", padding: "10px 0", border: "1.5px dashed var(--border3)", borderRadius: 8,
|
||||
background: "transparent", color: "var(--text4)", fontSize: 12, cursor: "pointer", fontFamily: "inherit",
|
||||
}}>+ Beitragsbild hochladen</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Poll */}
|
||||
<div style={{ marginBottom: 18, padding: "12px 14px", background: "var(--surface2)", borderRadius: 10, border: "1px solid var(--border2)" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 12, marginBottom: pollEnabled ? 12 : 0 }}>
|
||||
<input type="checkbox" checked={pollEnabled} onChange={e => setPollEnabled(e.target.checked)} style={{ width: "auto" }} />
|
||||
Umfrage hinzufügen
|
||||
</label>
|
||||
{pollEnabled && (
|
||||
<>
|
||||
<div className="form-group" style={{ marginBottom: 10 }}>
|
||||
<label>FRAGE</label>
|
||||
<input value={poll.question} onChange={e => setPoll(p => ({ ...p, question: e.target.value }))} placeholder="z.B. Bist du dabei?" />
|
||||
</div>
|
||||
<label style={{ fontSize: 9, letterSpacing: "0.1em", color: "var(--text4)", fontWeight: 500, display: "block", marginBottom: 6 }}>ANTWORTMÖGLICHKEITEN</label>
|
||||
{poll.options.map((opt, i) => (
|
||||
<div key={opt.id} style={{ display: "flex", gap: 6, marginBottom: 6 }}>
|
||||
<input value={opt.label} onChange={e => setOpt(opt.id, e.target.value)} placeholder={`Option ${i + 1}`} style={{ flex: 1 }} />
|
||||
{poll.options.length > 2 && <button onClick={() => delOpt(opt.id)} style={{ background: "none", border: "none", color: "#8a1a1a", cursor: "pointer", padding: "0 4px", display: "flex", alignItems: "center" }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>}
|
||||
</div>
|
||||
))}
|
||||
{poll.options.length < 5 && (
|
||||
<button onClick={addOpt} style={{ background: "none", border: "1px dashed var(--border3)", borderRadius: 6, padding: "5px 12px", fontSize: 11, color: "var(--text4)", cursor: "pointer", fontFamily: "inherit", marginTop: 2 }}>+ Option</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
||||
<button className="btn btn-ghost" onClick={onClose}>Abbrechen</button>
|
||||
<button className="btn btn-ghost" onClick={() => handleSave(true)} disabled={!form.body.trim()}>
|
||||
Als Entwurf speichern
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => handleSave(false)} disabled={!form.body.trim()}>
|
||||
{primaryLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main view ────────────────────────────────────────────────────────────────
|
||||
export default function Pinboard({ data, update, currentUser }) {
|
||||
const [modal, setModal] = useState(null); // null | { mode: "create" } | { mode: "edit", post }
|
||||
const [filter, setFilter] = useState("alle");
|
||||
const canWrite = canWriteCheck(currentUser, data);
|
||||
const posts = data.blogPosts || [];
|
||||
const myId = currentUser?.id;
|
||||
const isAdminUser = currentUser?.role === "admin";
|
||||
|
||||
const sorted = [...posts]
|
||||
.filter(p => {
|
||||
if (p.status === "entwurf" && p.authorId !== myId && !isAdminUser) return false;
|
||||
if (filter === "entwuerfe") return p.status === "entwurf";
|
||||
if (filter !== "alle") return p.type === filter;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
return b.createdAt.localeCompare(a.createdAt);
|
||||
});
|
||||
|
||||
const savePost = (form, draft = false) => {
|
||||
const status = draft ? "entwurf" : "published";
|
||||
if (modal?.mode === "edit") {
|
||||
update("blogPosts", posts.map(p => p.id === form.id ? { ...p, ...form, status } : p));
|
||||
} else {
|
||||
const newPost = {
|
||||
...form,
|
||||
id: generateId(),
|
||||
authorId: myId || "admin",
|
||||
authorName: currentUser?.displayName || currentUser?.username || "—",
|
||||
createdAt: new Date().toISOString(),
|
||||
pinned: false,
|
||||
status,
|
||||
};
|
||||
update("blogPosts", [newPost, ...posts]);
|
||||
}
|
||||
setModal(null);
|
||||
};
|
||||
|
||||
const deletePost = id => update("blogPosts", posts.filter(p => p.id !== id));
|
||||
const togglePin = id => update("blogPosts", posts.map(p => p.id === id ? { ...p, pinned: !p.pinned } : p));
|
||||
|
||||
const vote = (postId, optionId) => {
|
||||
update("blogPosts", posts.map(p => {
|
||||
if (p.id !== postId || !p.poll) return p;
|
||||
const votes = { ...p.poll.votes, [currentUser?.id]: optionId };
|
||||
return { ...p, poll: { ...p.poll, votes } };
|
||||
}));
|
||||
};
|
||||
|
||||
// Holiday notice this week
|
||||
const today = new Date();
|
||||
const getMonday = d => { const dd = new Date(d); dd.setDate(dd.getDate() - (dd.getDay() === 0 ? 6 : dd.getDay() - 1)); return dd; };
|
||||
const weekStart = getMonday(today);
|
||||
const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
const toISO = d => d.toISOString().slice(0, 10);
|
||||
const feiertageThisWeek = (data.feiertage || []).filter(f => f.date >= toISO(weekStart) && f.date <= toISO(weekEnd));
|
||||
|
||||
const FILTER_OPTS = [
|
||||
{ id: "alle", label: "Alle" },
|
||||
{ id: "beitrag", label: "Beiträge" },
|
||||
{ id: "ankuendigung", label: "Ankündigungen" },
|
||||
{ id: "event", label: "Events" },
|
||||
...(canWrite ? [{ id: "entwuerfe", label: "Entwürfe" }] : []),
|
||||
];
|
||||
|
||||
const btnStyle = active => ({
|
||||
padding: "5px 14px", borderRadius: 20, border: "1.5px solid", fontSize: 11, cursor: "pointer", fontFamily: "inherit",
|
||||
background: active ? "var(--text)" : "var(--surface)",
|
||||
color: active ? "var(--bg)" : "var(--text3)",
|
||||
borderColor: active ? "var(--text)" : "var(--border3)",
|
||||
transition: "all 0.15s",
|
||||
});
|
||||
|
||||
const leftPosts = sorted.filter((_, i) => i % 2 === 0);
|
||||
const rightPosts = sorted.filter((_, i) => i % 2 === 1);
|
||||
|
||||
const renderCard = post => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
currentUser={currentUser}
|
||||
canWrite={canWrite}
|
||||
onEdit={p => setModal({ mode: "edit", post: p })}
|
||||
onDelete={deletePost}
|
||||
onPin={togglePin}
|
||||
onVote={vote}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 style={{ fontFamily: "'Playfair Display', serif", fontSize: 30, fontWeight: 400, letterSpacing: "-0.02em", color: "var(--text)", marginBottom: 6 }}>Pinnwand</h1>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
{FILTER_OPTS.map(opt => (
|
||||
<button key={opt.id} onClick={() => setFilter(opt.id)} style={btnStyle(filter === opt.id)}>{opt.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<button className="btn btn-primary" onClick={() => setModal({ mode: "create" })} style={{ flexShrink: 0 }}>
|
||||
+ Beitrag
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Holiday notice */}
|
||||
{feiertageThisWeek.length > 0 && (
|
||||
<div style={{ padding: "12px 18px", background: "#e8f5ee", border: "1.5px solid #a8d8b8", borderRadius: 12, marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontSize: 20 }}>🎉</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: "#2d6a4f", marginBottom: 2 }}>Feiertag diese Woche</div>
|
||||
<div style={{ fontSize: 12, color: "#3a7a5a" }}>
|
||||
{feiertageThisWeek.map(f => `${new Date(f.date).toLocaleDateString("de-CH", { weekday: "long", day: "numeric", month: "long" })}: ${f.name}`).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sorted.length === 0 && (
|
||||
<div style={{ textAlign: "center", padding: "60px 0", color: "var(--text4)" }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 12 }}>📋</div>
|
||||
<div style={{ fontSize: 14 }}>Noch keine Beiträge</div>
|
||||
{canWrite && <div style={{ fontSize: 12, marginTop: 6 }}>Erstelle den ersten Beitrag mit dem Button oben rechts.</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sorted.length > 0 && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, alignItems: "start" }}>
|
||||
<div>{leftPosts.map(renderCard)}</div>
|
||||
<div>{rightPosts.map(renderCard)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal && (
|
||||
<PostModal
|
||||
initial={modal.mode === "edit" ? modal.post : null}
|
||||
onSave={savePost}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user