Every user-visible date now goes through web/src/lib/datetime.ts, driven by three settings (Settings > General > Locale): - Language & region: automatic, or any of the 618 locales CLDR has data for, each named in its own language and script (web/src/lib/locales.ts, generated by probing Intl over the subtag space). - Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025, or ISO 8601 2025-11-22. - Time format: automatic (locale), 24-hour, or 12-hour. Automatic takes the locale Stalwart has for the account, read best-effort at login via x:Account/get (urn:stalwart:jmap) and passed to the client in the session; servers without the capability, or that deny sysAccountGet to a regular user, fall back to the browser locale. POSIX forms are normalised (de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS, uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are dropped and a script the locale already implies is not appended. Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so one line never mixes digit systems. Rewired: message list and headers, quoted reply headers, calendar (titles, weekday and hour gutters, mini calendar, agenda, popovers, invite cards, free/busy), contacts, files, sessions. No raw toLocale*String date calls are left in web/src. Native <input type="datetime-local"> pickers always follow the browser locale and cannot be restyled by a page, so the out-of-office fields echo the entered instant in the chosen format underneath. Also: month-grid day labels no longer wrap when they hold a date, and the mock server serves x:Account/get (MOCK_LOCALE, default en_US). Closes #1
83 lines
5.4 KiB
TypeScript
83 lines
5.4 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useLocation } from "wouter";
|
|
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
|
|
import { useCalendar } from "@/store/calendar";
|
|
import { dateTimeKey, useSettings } from "@/store/settings";
|
|
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
|
import { formatMonthYear } from "@/lib/format";
|
|
import { formatWeekday } from "@/lib/datetime";
|
|
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
|
import { confirmDialog } from "@/ui/dialog";
|
|
import { toast } from "@/ui/toast";
|
|
import type { Calendar } from "@/jmap/types";
|
|
import { CalendarDialog } from "./CalendarDialog";
|
|
import { ShareDialog } from "../settings/ShareDialog";
|
|
|
|
export function CalendarSidebar() {
|
|
const [location, navigate] = useLocation();
|
|
const cal = useCalendar();
|
|
const weekStart = useSettings((s) => s.settings.weekStart);
|
|
const locale = useSettings((s) => dateTimeKey(s.settings));
|
|
const parts = location.split("/");
|
|
const view = parts[2] || "week";
|
|
const dateStr = parts[3];
|
|
const selected = useMemo(() => (dateStr ? new Date(`${dateStr}T00:00:00`) : new Date()), [dateStr]);
|
|
const [anchor, setAnchor] = useState(() => startOfDay(selected));
|
|
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
|
const menu = useMenu();
|
|
const [menuCal, setMenuCal] = useState<Calendar | null>(null);
|
|
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
|
|
const [share, setShare] = useState<Calendar | null>(null);
|
|
const instances = cal.instancesIn(grid[0]!, new Date(grid[41]!.getTime() + 86400000));
|
|
const dow = useMemo(() => grid.slice(0, 7).map((d) => formatWeekday(d, "narrow")), [grid, locale]);
|
|
|
|
if (!cal.available) return null;
|
|
const calendars = Object.values(cal.calendars).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
|
|
|
return (
|
|
<div style={{ padding: "4px 8px" }}>
|
|
<div className="mini-cal">
|
|
<div className="mc-head">
|
|
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, -1))} aria-label="Previous month"><ChevronLeft size={16} /></button>
|
|
<span>{formatMonthYear(anchor)}</span>
|
|
<button className="icon-btn xs" onClick={() => setAnchor(addMonths(anchor, 1))} aria-label="Next month"><ChevronRight size={16} /></button>
|
|
</div>
|
|
<div className="mc-grid">
|
|
{dow.map((d, i) => <div key={i} className="mc-dow">{d}</div>)}
|
|
{grid.map((d) => (
|
|
<div key={d.toISOString()} className={`mc-day ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""} ${isSameDay(d, selected) ? "selected" : ""} ${instances.some((i) => i.start < new Date(d.getTime() + 86400000) && i.end > d) ? "has-events" : ""}`} onClick={() => navigate(`/calendar/${view === "month" ? "day" : view}/${toLocalDateOnly(d)}`)}>
|
|
{d.getDate()}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="nav-section" style={{ paddingLeft: 4 }}>
|
|
<span>My calendars</span>
|
|
<button className="icon-btn" title="New calendar" onClick={() => setEditCal({})}><Plus size={16} /></button>
|
|
</div>
|
|
{calendars.map((c) => (
|
|
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
|
|
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
|
|
<span className="cal-name">{c.name}</span>
|
|
{c.isDefault && <Star size={12} className="faint" />}
|
|
<button className="icon-btn xs nav-more" onClick={(e) => { e.stopPropagation(); setMenuCal(c); menu.open(e); }} aria-label="Calendar options"><MoreVertical size={14} /></button>
|
|
</div>
|
|
))}
|
|
<Popover anchor={menu.anchor} onClose={menu.close} width={220}>
|
|
{menuCal && (
|
|
<>
|
|
<MenuItem icon={cal.hidden[menuCal.id] ? <Eye size={16} /> : <EyeOff size={16} />} label={cal.hidden[menuCal.id] ? "Show" : "Hide"} onClick={() => cal.toggleHidden(menuCal.id)} />
|
|
<MenuItem icon={<Pencil size={16} />} label="Edit" onClick={() => setEditCal(menuCal)} />
|
|
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
|
|
<MenuItem icon={<Star size={16} />} label="Make default" disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
|
|
<MenuSep />
|
|
<MenuItem danger icon={<Trash2 size={16} />} label="Delete" disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
|
|
</>
|
|
)}
|
|
</Popover>
|
|
{editCal && <CalendarDialog calendar={editCal} onClose={() => setEditCal(null)} />}
|
|
{share && <ShareDialog kind="Calendar" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
|
|
</div>
|
|
);
|
|
}
|