00f07d76f6
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>
418 lines
22 KiB
React
Executable File
418 lines
22 KiB
React
Executable File
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>
|
|
);
|
|
}
|