Repository Public
1"use client";2import { createContext, useCallback, useContext, useEffect, useRef, useState, ViewTransition, type ReactNode } from "react";3import Link from "next/link";4import { usePathname } from "next/navigation";5import { useLocale, useT } from "@/i18n";6import { adminCore } from "@/i18n/admin-core";7import { api, ApiClientError, setCsrf } from "@/lib/client-api";8import { Avatar, Button, cx, Icon, LangToggle, Mark, Notice, Sheet, ThemeToggle, Wordmark, type IconName } from "@/components/ui";9import { tzCity } from "@/lib/format";1011export interface AdminInfo { timezone: string; email: string; displayName: string; publicName: string }12const Ctx = createContext<AdminInfo>({ timezone: "America/Toronto", email: "", displayName: "", publicName: "" });13/** Owner's time zone and identity. Every admin screen shows times in `timezone`. */14export const useAdmin = () => useContext(Ctx);1516/**17 * Loads JSON from the admin API. `data` stays on screen while a reload is in flight; a 401 sends the owner back to18 * the sign-in page.19 */20export function useLoad<T>(path: string | null) {21 const [data, setData] = useState<T | null>(null);22 const [error, setError] = useState<ApiClientError | null>(null);23 const [loading, setLoading] = useState(!!path);24 const seq = useRef(0);25 const reload = useCallback(async () => {26 if (!path) return;27 const mine = ++seq.current;28 setLoading(true);29 try { const d = await api<T>(path); if (mine === seq.current) { setData(d); setError(null); } }30 catch (e) {31 if (e instanceof ApiClientError && e.status === 401) { window.location.href = "/admin/login"; return; }32 if (mine === seq.current) setError(e instanceof ApiClientError ? e : new ApiClientError(0, "internal", "error"));33 } finally { if (mine === seq.current) setLoading(false); }34 }, [path]);35 useEffect(() => { void reload(); }, [reload]);36 return { data, error, loading, reload, setData };37}3839/** Standard loading / error frame for a screen section. */40export function Loadable({ state, children, skeleton }: { state: { data: unknown; error: ApiClientError | null; loading: boolean; reload: () => void }; children: ReactNode; skeleton?: ReactNode }) {41 const t = useT(adminCore);42 if (state.error && !state.data) return <Notice tone="danger"><p>{t("loadError")} <span className="opacity-80">({state.error.code})</span></p><Button small className="mt-2" icon="refresh" onClick={state.reload}>{t("retry")}</Button></Notice>;43 if (!state.data) return <div aria-busy="true" aria-label={t("loading")}>{skeleton ?? <div className="grid gap-3.5"><div className="skeleton h-28" /><div className="skeleton h-24" /><div className="skeleton h-24" /></div>}</div>;44 return <>{children}</>;45}4647/** Large title of a screen. On phones the shell shows a compact copy in the top bar once this one scrolls away. */48export function PageHeader({ title, lead, actions, eyebrow, inline }: { title: string; lead?: string; actions?: ReactNode; eyebrow?: string; inline?: boolean }) {49 return (50 <header className={cx("flex items-end justify-between gap-x-4 gap-y-3.5 mb-5 lg:mb-7", !inline && "flex-wrap")}>51 <div className="min-w-0">52 {eyebrow ? <p className="t-eyebrow mb-1.5 first-letter:uppercase">{eyebrow}</p> : null}53 <h1 className="t-title !text-[34px] lg:!text-[38px]">{title}</h1>54 {lead ? <p className="t-mute mt-2 max-w-2xl text-pretty">{lead}</p> : null}55 </div>56 {actions ? <div className={cx("flex flex-wrap gap-2", inline ? "shrink-0" : "max-sm:w-full max-sm:[&>*]:flex-1")}>{actions}</div> : null}57 </header>58 );59}6061const NAV: { href: string; key: "navHome" | "navRequests" | "navAgenda" | "navAvailability" | "navTypes" | "navContacts" | "navSettings"; icon: IconName; primary: boolean }[] = [62 { href: "/admin", key: "navHome", icon: "home", primary: true },63 { href: "/admin/requests", key: "navRequests", icon: "inbox", primary: true },64 { href: "/admin/agenda", key: "navAgenda", icon: "calendar", primary: true },65 { href: "/admin/availability", key: "navAvailability", icon: "sliders", primary: true },66 { href: "/admin/types", key: "navTypes", icon: "tag", primary: false },67 { href: "/admin/contacts", key: "navContacts", icon: "users", primary: false },68 { href: "/admin/settings", key: "navSettings", icon: "settings", primary: false },69];7071export function AdminShell({ info, csrfToken, children }: { info: AdminInfo; csrfToken: string; children: ReactNode }) {72 setCsrf(csrfToken);73 const t = useT(adminCore);74 const locale = useLocale();75 const path = usePathname();76 const [more, setMore] = useState(false);77 const [open, setOpen] = useState<number | null>(null);78 const active = (href: string) => (href === "/admin" ? path === "/admin" : path.startsWith(href));79 useEffect(() => {80 let stop = false;81 const tick = () => api<{ requests: unknown[] }>("/api/v1/admin/requests?filter=open").then((d) => { if (!stop) setOpen(d.requests.length); }).catch(() => {});82 tick(); const id = setInterval(tick, 60_000);83 const onChange = () => tick();84 window.addEventListener("spcal:requests-changed", onChange);85 return () => { stop = true; clearInterval(id); window.removeEventListener("spcal:requests-changed", onChange); };86 }, []);87 const logout = async () => { try { await api("/api/v1/auth/logout", { method: "POST" }); } finally { window.location.href = "/admin/login"; } };88 // Compact title in the phone top bar, shown once the large title has scrolled under it.89 const [scrolled, setScrolled] = useState(false);90 useEffect(() => {91 const on = () => setScrolled(window.scrollY > 46);92 on(); window.addEventListener("scroll", on, { passive: true });93 return () => window.removeEventListener("scroll", on);94 }, [path]);95 const current = NAV.find((n) => active(n.href));96 const badge = open ? <span className="badge !px-2 !py-0 text-[11.5px] bg-danger text-surface" aria-label={t("openRequests", { n: open })}>{open}</span> : null;97 const link = (n: (typeof NAV)[number], onClick?: () => void) => (98 <Link key={n.href} href={n.href} onClick={onClick} aria-current={active(n.href) ? "page" : undefined} className="navlink">99 <Icon name={n.icon} size={19} /><span className="flex-1">{t(n.key)}</span>{n.href === "/admin/requests" ? badge : null}100 </Link>101 );102 return (103 <Ctx.Provider value={info}>104 <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:z-[70] focus:top-3 focus:left-3 btn btn-primary">{locale === "fr" ? "Aller au contenu" : "Skip to content"}</a>105 <div className="lg:grid lg:grid-cols-[272px_1fr] min-h-dvh">106 <aside className="hidden lg:flex flex-col gap-1 p-4 border-r border-[var(--glass-line)] glass sticky top-0 h-dvh">107 <div className="px-2 py-2 mb-4"><Wordmark href="/admin" /></div>108 <nav aria-label="Administration" className="flex flex-col gap-1">{NAV.map((n) => link(n))}</nav>109 <div className="mt-auto flex flex-col gap-1">110 <a href="/" target="_blank" rel="noreferrer" className="navlink"><Icon name="external" size={19} />{t("publicPage")}</a>111 <button onClick={logout} className="navlink"><Icon name="logout" size={19} />{t("logout")}</button>112 <div className="mt-2 rounded-[18px] bg-elev/70 p-2.5 flex items-center gap-2.5">113 <Avatar name={info.displayName || info.email} size={38} />114 <span className="min-w-0 flex-1"><span className="block font-semibold text-[14px] truncate">{info.displayName}</span><span className="block text-[12px] t-mute truncate" title={info.timezone}>{tzCity(info.timezone)}</span></span>115 <span className="flex -mr-1"><LangToggle /><ThemeToggle /></span>116 </div>117 </div>118 </aside>119 <div className="min-w-0 flex flex-col">120 <header className={cx("lg:hidden sticky top-0 z-40 pt-[env(safe-area-inset-top)] border-b transition-colors duration-300", scrolled ? "glass border-[var(--glass-line)]" : "border-transparent")}>121 <div className="relative flex items-center justify-between px-3.5 h-[52px]">122 <Wordmark href="/admin" compact />123 <span aria-hidden className={cx("absolute left-1/2 -translate-x-1/2 font-bold text-[16.5px] tracking-tight transition-all duration-300 pointer-events-none max-w-[50%] truncate", scrolled ? "opacity-100" : "opacity-0 translate-y-1.5")}>{current ? t(current.key) : ""}</span>124 <span className="flex items-center"><LangToggle /><ThemeToggle /></span>125 </div>126 </header>127 <main id="main" tabIndex={-1} className="flex-1 px-4 sm:px-6 lg:px-10 pt-2 lg:pt-9 pb-[calc(var(--tabbar-h)+3rem+env(safe-area-inset-bottom))] lg:pb-12 max-w-6xl w-full mx-auto outline-none">128 <ViewTransition key={path} enter="page-in" exit="page-out" default="none"><div>{children}</div></ViewTransition>129 </main>130 </div>131 </div>132 <nav aria-label="Administration" className="lg:hidden tabbar glass">133 {NAV.filter((n) => n.primary).map((n) => (134 <Link key={n.href} href={n.href} aria-current={active(n.href) ? "page" : undefined} className="tab">135 <Icon name={n.icon} size={23} /><span>{t(n.key)}</span>136 {n.href === "/admin/requests" && open ? <span className="tab-count" aria-label={t("openRequests", { n: open })}>{open}</span> : null}137 </Link>138 ))}139 <button onClick={() => setMore(true)} aria-haspopup="dialog" data-active={NAV.some((n) => !n.primary && active(n.href))} className="tab"><Icon name="more" size={23} /><span>{t("navMore")}</span></button>140 </nav>141 <Sheet open={more} onClose={() => setMore(false)} title={t("navMore")}>142 <div className="flex items-center gap-3 mb-4 rounded-[20px] bg-elev p-3">143 <Avatar name={info.displayName || info.email} size={46} />144 <span className="min-w-0"><span className="block font-semibold truncate">{info.displayName}</span><span className="block text-[13px] t-mute truncate">{info.email}</span></span>145 </div>146 <div className="lgroup">147 {NAV.filter((n) => !n.primary).map((n) => (148 <Link key={n.href} href={n.href} onClick={() => setMore(false)} aria-current={active(n.href) ? "page" : undefined} className="row font-semibold">149 <span className="tile-icon"><Icon name={n.icon} size={19} /></span><span className="flex-1">{t(n.key)}</span><Icon name="right" size={16} className="t-mute" />150 </Link>151 ))}152 <a href="/" target="_blank" rel="noreferrer" className="row font-semibold"><span className="tile-icon"><Icon name="external" size={19} /></span><span className="flex-1">{t("publicPage")}</span><Icon name="right" size={16} className="t-mute" /></a>153 </div>154 <div className="lgroup mt-3"><button onClick={logout} className="row font-semibold text-danger"><span className="tile-icon !bg-danger-wash"><Icon name="logout" size={19} /></span>{t("logout")}</button></div>155 </Sheet>156 </Ctx.Provider>157 );158}159160/** Tell the shell that the number of open requests may have changed. */161export const requestsChanged = () => window.dispatchEvent(new Event("spcal:requests-changed"));162