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
+469
@@ -0,0 +1,469 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user