8de93ff27f
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>
470 lines
19 KiB
React
Executable File
470 lines
19 KiB
React
Executable File
import React, { useState, useEffect, useRef } from "react";
|
||
import { STATUS_COLORS, STATUS_BG } from "../constants.js";
|
||
|
||
export function StatusBadge({ status }) {
|
||
const color = STATUS_COLORS[status] || "#888";
|
||
const bg = STATUS_BG[status] || "#f5f5f5";
|
||
return (
|
||
<span style={{
|
||
display: "inline-block", padding: "2px 10px", borderRadius: 20,
|
||
fontSize: 11, fontWeight: 600, letterSpacing: "0.04em",
|
||
color, background: bg, border: `1.5px solid ${color}40`,
|
||
}}>{status}</span>
|
||
);
|
||
}
|
||
|
||
export function StatusSelect({ value, options, onChange }) {
|
||
const color = STATUS_COLORS[value] || "#888";
|
||
const bg = STATUS_BG[value] || "#f5f5f5";
|
||
return (
|
||
<select
|
||
value={value}
|
||
onChange={e => onChange(e.target.value)}
|
||
onClick={e => e.stopPropagation()}
|
||
style={{
|
||
fontSize: 11, height: 26, padding: "0 20px 0 8px",
|
||
background: bg, color, border: `1.5px solid ${color}40`,
|
||
fontWeight: 600, borderRadius: 20, cursor: "pointer",
|
||
fontFamily: "inherit", appearance: "auto",
|
||
}}
|
||
>
|
||
{options.map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
export function Header({ title, action }) {
|
||
return (
|
||
<div style={{ marginBottom: 10, paddingBottom: 7, borderBottom: "1px solid var(--border)" }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<h1 style={{ fontFamily: "'Playfair Display', serif", fontSize: 30, fontWeight: 400, letterSpacing: "-0.02em", lineHeight: 1.1 }}>{title}</h1>
|
||
{action}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function FormField({ label, children }) {
|
||
return (
|
||
<div className="form-group" style={{ marginBottom: 14 }}>
|
||
<label>{label}</label>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Modal({ title, onClose, onSave, saveLabel, hideSave, children, wide, overflow }) {
|
||
return (
|
||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||
<div className="modal" style={{ ...(wide ? { maxWidth: 740 } : {}), ...(overflow ? { overflow: "visible" } : {}) }}>
|
||
<h2 style={{ fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 26, fontSize: 24, letterSpacing: "-0.01em" }}>{title}</h2>
|
||
{children}
|
||
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 24, paddingTop: 20, borderTop: "1px solid var(--border2)" }}>
|
||
<button className="btn btn-ghost" onClick={onClose}>Abbrechen</button>
|
||
{!hideSave && <button className="btn btn-primary" onClick={onSave}>{saveLabel || "Speichern"}</button>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function StudioLogo({ settings, size = 24 }) {
|
||
if (settings.logo) {
|
||
const h = settings.logoSize || 60;
|
||
return <img src={settings.logo} alt={settings.name} style={{ maxHeight: h, maxWidth: h * 5, display: "block" }} />;
|
||
}
|
||
return <div style={{ fontFamily: "'Playfair Display', serif", fontSize: size, fontWeight: 400, fontStyle: "italic", letterSpacing: "-0.01em" }}>{settings.name}</div>;
|
||
}
|
||
|
||
export function useConfirm() {
|
||
const [pending, setPending] = useState(null);
|
||
const askConfirm = (msg, confirmLabel = "Löschen") =>
|
||
new Promise(resolve => setPending({ msg, confirmLabel, resolve }));
|
||
const ConfirmModalEl = pending ? (
|
||
<div style={{ position: "fixed", inset: 0, background: "rgba(26,26,24,0.5)", zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||
<div style={{ background: "#f0ede8", borderRadius: 8, padding: "24px 28px", maxWidth: 400, width: "90%", fontFamily: "inherit", boxShadow: "0 8px 32px rgba(0,0,0,0.2)" }}>
|
||
<div style={{ fontSize: 13, lineHeight: 1.6, color: "#1a1a18", marginBottom: 22 }}>{pending.msg}</div>
|
||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||
<button className="btn btn-ghost" onClick={() => { pending.resolve(false); setPending(null); }}>Abbrechen</button>
|
||
<button className="btn btn-danger" onClick={() => { pending.resolve(true); setPending(null); }}>{pending.confirmLabel}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
return { askConfirm, ConfirmModalEl };
|
||
}
|
||
|
||
export function DateInput({ value, onChange, style, ...props }) {
|
||
const toDE = (iso) => {
|
||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
|
||
return `${iso.slice(8, 10)}.${iso.slice(5, 7)}.${iso.slice(0, 4)}`;
|
||
};
|
||
const toISO = (de) => {
|
||
const m = de.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||
if (!m) return null;
|
||
const iso = `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
|
||
return isNaN(new Date(iso).getTime()) ? null : iso;
|
||
};
|
||
const [text, setText] = React.useState(() => toDE(value));
|
||
React.useEffect(() => { const de = toDE(value); if (de !== text) setText(de); }, [value]);
|
||
const handleChange = (e) => {
|
||
const digits = e.target.value.replace(/\D/g, "").slice(0, 8);
|
||
let fmt = "";
|
||
if (digits.length <= 2) fmt = digits;
|
||
else if (digits.length <= 4) fmt = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||
else fmt = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||
setText(fmt);
|
||
const iso = toISO(fmt);
|
||
if (iso) onChange({ target: { value: iso } });
|
||
};
|
||
return (
|
||
<input
|
||
type="text"
|
||
value={text}
|
||
onChange={handleChange}
|
||
onBlur={() => { if (!toISO(text) && value) setText(toDE(value)); }}
|
||
placeholder="TT.MM.JJJJ"
|
||
style={style}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function RichEditor({ value, onChange, minHeight = 420, compact = false }) {
|
||
const editorRef = React.useRef(null);
|
||
const isInternalChange = React.useRef(false);
|
||
const lastValue = React.useRef(value);
|
||
const savedRange = React.useRef(null);
|
||
const colorInputRef = React.useRef(null);
|
||
const [colorValue, setColorValue] = React.useState("#000000");
|
||
|
||
// Only push content into DOM when value changes from outside (template load)
|
||
useEffect(() => {
|
||
if (!editorRef.current) return;
|
||
if (value !== lastValue.current && !isInternalChange.current) {
|
||
editorRef.current.innerHTML = value;
|
||
lastValue.current = value;
|
||
}
|
||
isInternalChange.current = false;
|
||
}, [value]);
|
||
|
||
const saveSelection = () => {
|
||
const sel = window.getSelection();
|
||
if (sel && sel.rangeCount > 0 && editorRef.current?.contains(sel.anchorNode)) {
|
||
savedRange.current = sel.getRangeAt(0).cloneRange();
|
||
}
|
||
};
|
||
|
||
const restoreSelection = () => {
|
||
editorRef.current?.focus();
|
||
if (!savedRange.current) {
|
||
// Place cursor at end if no saved range
|
||
const range = document.createRange();
|
||
range.selectNodeContents(editorRef.current);
|
||
range.collapse(false);
|
||
const sel = window.getSelection();
|
||
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
|
||
return;
|
||
}
|
||
const sel = window.getSelection();
|
||
if (sel) { sel.removeAllRanges(); sel.addRange(savedRange.current); }
|
||
};
|
||
|
||
const exec = (cmd, val = null) => {
|
||
restoreSelection();
|
||
document.execCommand(cmd, false, val);
|
||
saveSelection();
|
||
// Sync content after command
|
||
isInternalChange.current = true;
|
||
const html = editorRef.current?.innerHTML || "";
|
||
lastValue.current = html;
|
||
onChange(html);
|
||
};
|
||
|
||
|
||
|
||
const TB = ({ cmd, val, title, style: s, children }) => (
|
||
<button
|
||
onMouseDown={e => { e.preventDefault(); exec(cmd, val); }}
|
||
title={title}
|
||
style={{
|
||
padding: "0 9px", height: 30, borderRadius: 4,
|
||
border: "none", cursor: "pointer", fontFamily: "inherit", fontSize: 12,
|
||
background: "transparent", color: "var(--text3)",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
transition: "background 0.1s",
|
||
...s,
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.background = "var(--border2)"}
|
||
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
|
||
>{children}</button>
|
||
);
|
||
const Sep = () => <div style={{ width: 1, height: 16, background: "var(--border)", margin: "0 3px", flexShrink: 0 }} />;
|
||
|
||
return (
|
||
<div style={{ border: "1.5px solid var(--input-border)", borderRadius: 8, overflow: "hidden" }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 1, padding: "5px 8px", borderBottom: "1px solid var(--border2)", background: "var(--surface2)" }}>
|
||
<TB cmd="bold" title="Fett (Ctrl+B)" s={{ fontWeight: 700, minWidth: 28 }}>B</TB>
|
||
<TB cmd="italic" title="Kursiv (Ctrl+I)" s={{ fontStyle: "italic", minWidth: 28 }}>I</TB>
|
||
<TB cmd="underline" title="Unterstrichen (Ctrl+U)" s={{ textDecoration: "underline", minWidth: 28 }}>U</TB>
|
||
<Sep />
|
||
|
||
<TB cmd="justifyLeft" title="Linksbündig" s={{ padding: "0 7px" }}>
|
||
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
|
||
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
|
||
<rect x="0" y="3.5" width="9" height="1.5" rx="0.75"/>
|
||
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
|
||
<rect x="0" y="10.5" width="11" height="1.5" rx="0.75"/>
|
||
</svg>
|
||
</TB>
|
||
<TB cmd="justifyCenter" title="Zentriert" s={{ padding: "0 7px" }}>
|
||
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
|
||
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
|
||
<rect x="2.5" y="3.5" width="9" height="1.5" rx="0.75"/>
|
||
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
|
||
<rect x="1.5" y="10.5" width="11" height="1.5" rx="0.75"/>
|
||
</svg>
|
||
</TB>
|
||
<TB cmd="justifyRight" title="Rechtsbündig" s={{ padding: "0 7px" }}>
|
||
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
|
||
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
|
||
<rect x="5" y="3.5" width="9" height="1.5" rx="0.75"/>
|
||
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
|
||
<rect x="3" y="10.5" width="11" height="1.5" rx="0.75"/>
|
||
</svg>
|
||
</TB>
|
||
<Sep />
|
||
|
||
{/* Font size */}
|
||
<select
|
||
onMouseDown={saveSelection}
|
||
onChange={e => { exec("fontSize", e.target.value); e.target.value = ""; }}
|
||
title="Schriftgrösse"
|
||
style={{
|
||
width: 80, height: 26, border: "1px solid var(--border2)", borderRadius: 4,
|
||
background: "transparent", color: "var(--text3)", fontSize: 11,
|
||
padding: "0 4px", cursor: "pointer", outline: "none",
|
||
}}
|
||
>
|
||
<option value="">Grösse</option>
|
||
<option value="1">Klein</option>
|
||
<option value="3">Normal</option>
|
||
<option value="5">Gross</option>
|
||
<option value="7">Titel</option>
|
||
</select>
|
||
|
||
<Sep />
|
||
|
||
{/* Color */}
|
||
<button
|
||
onMouseDown={e => { e.preventDefault(); saveSelection(); colorInputRef.current?.click(); }}
|
||
title="Textfarbe"
|
||
style={{
|
||
padding: "0 8px", height: 30, borderRadius: 4, border: "none",
|
||
cursor: "pointer", background: "transparent",
|
||
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
|
||
gap: 2, transition: "background 0.1s",
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.background = "var(--border2)"}
|
||
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
|
||
>
|
||
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text3)", lineHeight: 1 }}>A</span>
|
||
<div style={{ width: 14, height: 3, borderRadius: 1, background: colorValue }} />
|
||
</button>
|
||
<input
|
||
ref={colorInputRef}
|
||
type="color"
|
||
value={colorValue}
|
||
onChange={e => { setColorValue(e.target.value); exec("foreColor", e.target.value); }}
|
||
style={{ position: "absolute", width: 0, height: 0, opacity: 0, pointerEvents: "none" }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Editor */}
|
||
<div
|
||
ref={editorRef}
|
||
contentEditable
|
||
suppressContentEditableWarning
|
||
onInput={() => {
|
||
isInternalChange.current = true;
|
||
const html = editorRef.current?.innerHTML || "";
|
||
lastValue.current = html;
|
||
onChange(html);
|
||
}}
|
||
onKeyUp={saveSelection}
|
||
onMouseUp={saveSelection}
|
||
onBlur={saveSelection}
|
||
onPaste={e => {
|
||
e.preventDefault();
|
||
const text = e.clipboardData.getData("text/plain");
|
||
restoreSelection();
|
||
document.execCommand("insertText", false, text);
|
||
saveSelection();
|
||
}}
|
||
style={{
|
||
minHeight, padding: compact ? "8px 10px" : "20px 24px", outline: "none",
|
||
fontSize: compact ? 12 : 13, lineHeight: 1.8, color: "var(--text)",
|
||
background: "var(--surface)",
|
||
fontFamily: "'DM Mono', 'Courier New', monospace",
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CalendarPopup({ value, onChange, onClose, align = "left", showClear = true }) {
|
||
const [viewYM, setViewYM] = useState(() => {
|
||
const d = value ? new Date(value + "T00:00:00") : new Date();
|
||
return { year: d.getFullYear(), month: d.getMonth() };
|
||
});
|
||
const todayStr = new Date().toISOString().slice(0, 10);
|
||
const { year, month } = viewYM;
|
||
const firstDay = new Date(year, month, 1);
|
||
const lastDay = new Date(year, month + 1, 0);
|
||
const startWeekday = (firstDay.getDay() + 6) % 7;
|
||
const cells = [];
|
||
for (let i = 0; i < startWeekday; i++) cells.push(null);
|
||
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||
cells.push(`${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`);
|
||
}
|
||
const prevMonth = () => { const d = new Date(year, month - 1, 1); setViewYM({ year: d.getFullYear(), month: d.getMonth() }); };
|
||
const nextMonth = () => { const d = new Date(year, month + 1, 1); setViewYM({ year: d.getFullYear(), month: d.getMonth() }); };
|
||
const select = (ds) => { onChange(ds); onClose(); };
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: "calc(100% + 4px)",
|
||
[align === "right" ? "right" : "left"]: 0,
|
||
zIndex: 2000,
|
||
background: "#f0ede8",
|
||
border: "1px solid #e0dbd4",
|
||
borderRadius: 8,
|
||
padding: 12,
|
||
boxShadow: "0 8px 28px rgba(0,0,0,0.16)",
|
||
width: 232,
|
||
}}
|
||
onMouseDown={e => e.stopPropagation()}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
|
||
<button onMouseDown={e => { e.preventDefault(); prevMonth(); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px 8px", fontSize: 14, color: "#888", lineHeight: 1 }}>←</button>
|
||
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 13, color: "#1a1a18" }}>
|
||
{firstDay.toLocaleDateString("de-CH", { month: "long", year: "numeric" })}
|
||
</div>
|
||
<button onMouseDown={e => { e.preventDefault(); nextMonth(); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px 8px", fontSize: 14, color: "#888", lineHeight: 1 }}>→</button>
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2, marginBottom: 3 }}>
|
||
{["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].map(d => (
|
||
<div key={d} style={{ textAlign: "center", fontSize: 9, color: "#aaa", padding: "2px 0" }}>{d}</div>
|
||
))}
|
||
</div>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2 }}>
|
||
{cells.map((ds, i) => {
|
||
if (!ds) return <div key={i} />;
|
||
const dow = new Date(ds + "T00:00:00").getDay();
|
||
const isWeekend = dow === 0 || dow === 6;
|
||
const isSelected = ds === value;
|
||
const isToday = ds === todayStr;
|
||
return (
|
||
<button key={ds}
|
||
onMouseDown={e => { e.preventDefault(); select(ds); }}
|
||
style={{
|
||
aspectRatio: "1", fontSize: 11, cursor: "pointer",
|
||
fontFamily: "inherit", borderRadius: 4,
|
||
border: isToday && !isSelected ? "1.5px solid #b07848" : "1.5px solid transparent",
|
||
background: isSelected ? "#1a1a18" : "transparent",
|
||
color: isSelected ? "#b07848" : isWeekend ? "#bbb" : "#1a1a18",
|
||
fontWeight: isToday && !isSelected ? 600 : 400,
|
||
}}
|
||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = "#e8e3dc"; }}
|
||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = "transparent"; }}
|
||
>
|
||
{new Date(ds + "T00:00:00").getDate()}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{showClear && value && (
|
||
<div style={{ marginTop: 8, paddingTop: 8, borderTop: "1px solid #e0dbd4", textAlign: "center" }}>
|
||
<button onMouseDown={e => { e.preventDefault(); onChange(""); onClose(); }}
|
||
style={{ fontSize: 10, color: "#aaa", background: "none", border: "none", cursor: "pointer", fontFamily: "inherit" }}>
|
||
Datum löschen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function DatePicker({ value, onChange, style, placeholder, align = "left", ...props }) {
|
||
const [open, setOpen] = useState(false);
|
||
const wrapRef = useRef(null);
|
||
const toDE = (iso) => {
|
||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
|
||
return `${iso.slice(8, 10)}.${iso.slice(5, 7)}.${iso.slice(0, 4)}`;
|
||
};
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const close = (e) => { if (!wrapRef.current?.contains(e.target)) setOpen(false); };
|
||
document.addEventListener("mousedown", close);
|
||
return () => document.removeEventListener("mousedown", close);
|
||
}, [open]);
|
||
return (
|
||
<div ref={wrapRef} style={{ position: "relative", display: "inline-block", width: "100%" }}>
|
||
<input
|
||
type="text"
|
||
value={toDE(value)}
|
||
readOnly
|
||
onClick={() => setOpen(o => !o)}
|
||
placeholder={placeholder || "TT.MM.JJJJ"}
|
||
style={{ cursor: "pointer", ...style }}
|
||
{...props}
|
||
/>
|
||
{open && (
|
||
<CalendarPopup
|
||
value={value}
|
||
onChange={ds => onChange({ target: { value: ds } })}
|
||
onClose={() => setOpen(false)}
|
||
align={align}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function NavArrows({ onPrev, onNext, disabledNext = false }) {
|
||
const btn = (onClick, disabled, children) => (
|
||
<button
|
||
onClick={disabled ? undefined : onClick}
|
||
disabled={disabled}
|
||
style={{
|
||
background: "none", border: "none", padding: "0 11px", height: 30,
|
||
cursor: disabled ? "default" : "pointer",
|
||
color: disabled ? "var(--border2)" : "var(--text3)",
|
||
fontSize: 17, lineHeight: 1, display: "flex", alignItems: "center",
|
||
fontFamily: "inherit", flexShrink: 0,
|
||
}}
|
||
>{children}</button>
|
||
);
|
||
return (
|
||
<div style={{ display: "flex", border: "1px solid var(--border2)", borderRadius: 20, overflow: "hidden" }}>
|
||
{btn(onPrev, false, "‹")}
|
||
<div style={{ width: 1, background: "var(--border2)", alignSelf: "stretch" }} />
|
||
{btn(onNext, disabledNext, "›")}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function useCalendarNav() {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef(null);
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const close = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||
document.addEventListener("mousedown", close);
|
||
return () => document.removeEventListener("mousedown", close);
|
||
}, [open]);
|
||
return { open, setOpen, ref };
|
||
}
|