@tollbooth-dpyc/web 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -1
- package/dist/react/AccountPage.d.ts +69 -0
- package/dist/react/AccountPage.js +65 -0
- package/dist/react/AppShell.d.ts +58 -0
- package/dist/react/AppShell.js +65 -0
- package/dist/react/RefreshButton.d.ts +35 -0
- package/dist/react/RefreshButton.js +44 -0
- package/dist/react/SessionKeyClaim.d.ts +9 -3
- package/dist/react/SessionKeyClaim.js +12 -9
- package/dist/react/SiteNav.d.ts +98 -0
- package/dist/react/SiteNav.js +80 -0
- package/dist/react/accountSections.d.ts +20 -0
- package/dist/react/accountSections.js +29 -0
- package/dist/react/index.d.ts +8 -1
- package/dist/react/index.js +7 -1
- package/dist/react/navState.d.ts +21 -0
- package/dist/react/navState.js +49 -0
- package/dist/react/shellLayout.d.ts +17 -0
- package/dist/react/shellLayout.js +23 -0
- package/dist/react/useAvatar.d.ts +2 -0
- package/dist/react/useAvatar.js +13 -0
- package/dist/react/useDisclosure.d.ts +17 -0
- package/dist/react/useDisclosure.js +58 -0
- package/dist/react/useMediaQuery.d.ts +2 -0
- package/dist/react/useMediaQuery.js +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,8 @@ features. It is the peer of the `tollbooth-dpyc` Python wheel.
|
|
|
9
9
|
kind-27235 inline proof when the tab holds a session key
|
|
10
10
|
- **MCP client** — one shared connection to the operator, typed wrappers for
|
|
11
11
|
the standard tools (balance, top-up, statement, price, profile)
|
|
12
|
-
- **React components** — `
|
|
12
|
+
- **React components** — `AppShell` / `useAppShell`, `SiteNav`, `AccountPage`,
|
|
13
|
+
`RefreshButton`, `NpubGate`, `NostrProfilePanel`, `SessionKeyClaim`, `WalletCard`,
|
|
13
14
|
`WalletPage`, `CouponsPanel`, `UsageSummary`, `PatronFundingStatus` /
|
|
14
15
|
`OperatorFundingStatus`, `BuildInfoPanel`, `TimezonePicker`, `TableFilter`,
|
|
15
16
|
`SortHeader` / `PageControls` / `TableShell`, `ThemeToggle`, `ErrorBoundary`,
|
|
@@ -174,6 +175,74 @@ import { BuildInfoPanel, OperatorFundingStatus, PatronFundingStatus, UsageSummar
|
|
|
174
175
|
`service_status`; only https links), the Tollbooth-DPYC™ links, the licence
|
|
175
176
|
and the patent notice. `children` adds rows after the MCP server section.
|
|
176
177
|
|
|
178
|
+
### App shell, site nav, account page, refresh
|
|
179
|
+
|
|
180
|
+
The frame a site used to write for itself. `AppShell` holds the session, the
|
|
181
|
+
sign-in gate, `service_status`, the theme and the debug log (mounted last);
|
|
182
|
+
it owns no routes. `useAppShell()` gives any component inside the same npub,
|
|
183
|
+
`signOut` and status, so the site keeps no session context of its own.
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
import { AccountPage, AppShell, RefreshButton, SiteNav, matchesPath, useAppShell } from "@tollbooth-dpyc/web/react";
|
|
187
|
+
|
|
188
|
+
<AppShell
|
|
189
|
+
signedOut={({ gate }) => <><TopBar /><Hero />{gate}</>}
|
|
190
|
+
footer={({ status }) => <Footer status={status} />}
|
|
191
|
+
debug={{ children: <SchedulerLogSection /> }}
|
|
192
|
+
fit="page" // "viewport" for a bottom rail
|
|
193
|
+
>
|
|
194
|
+
{() => <BrowserRouter><Nav /><Routes>…</Routes></BrowserRouter>}
|
|
195
|
+
</AppShell>
|
|
196
|
+
|
|
197
|
+
function Nav() {
|
|
198
|
+
const { session } = useAppShell();
|
|
199
|
+
const { pathname } = useLocation();
|
|
200
|
+
return (
|
|
201
|
+
<SiteNav
|
|
202
|
+
brand={<Link to="/">eXcalibur</Link>}
|
|
203
|
+
items={[{ href: "/", label: "Posts", end: true }, { href: "/wallet", label: "Wallet", icon: <Wallet /> }]}
|
|
204
|
+
isActive={(href, item) => matchesPath(pathname, href, item.end)}
|
|
205
|
+
renderLink={({ href, ...p }) => <Link to={href} {...p} />}
|
|
206
|
+
trailing={<BalanceChip />}
|
|
207
|
+
account={{ npub: session.npub, links: [{ href: "/profile", label: "Profile & theme" }], onSignOut: session.signOut }}
|
|
208
|
+
classNames={{ root: "flex items-center gap-1.5", list: "flex gap-1", item: "chip", active: "chip-on", end: "ml-auto flex gap-3" }}
|
|
209
|
+
/>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
<AccountPage
|
|
214
|
+
npub={session.npub}
|
|
215
|
+
between={{ sessionKey: <XConnectPanel /> }}
|
|
216
|
+
timezone={{ note: (pref, zone) => (pref === "auto" ? `Currently ${zone}.` : `Using ${zone}.`) }}
|
|
217
|
+
build={{ status, frontend: { version: __APP_VERSION__, source: "https://github.com/you/site" } }}
|
|
218
|
+
onSignOut={session.signOut}
|
|
219
|
+
classNames={{ root: "space-y-5", section: "card p-5", signOut: "chip" }}
|
|
220
|
+
/>
|
|
221
|
+
|
|
222
|
+
<RefreshButton onRefresh={load} label="Refresh posts" classNames={{ root: "h-12 w-12 rounded-lg" }} />
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
- `AppShell`: signed in → `children(shell)`; signed out → `signedOut(shell)`,
|
|
226
|
+
placing `shell.gate` (the `NpubGate`, wired to the operator fingerprint and
|
|
227
|
+
the lapsed-sign-in note), or the gate alone. The frame is a column: "page"
|
|
228
|
+
grows with the content; "viewport" pins it to the screen and lets the
|
|
229
|
+
content shrink, so the debug log's spacer never pushes a bottom rail off.
|
|
230
|
+
- `SiteNav`: brand, pages (icon, label, badge), `trailing`, and an account
|
|
231
|
+
menu (avatar → identity, links, Log out). Below `collapse` (default
|
|
232
|
+
`(max-width: 639px)`; `false` never) the pages fold into a menu button.
|
|
233
|
+
Both popovers set `aria-expanded`, focus their first item, close on Escape
|
|
234
|
+
(focus back to the button), on a press or focus outside, and move with the
|
|
235
|
+
arrows, Home and End; tap targets are at least 40 px. Router-agnostic:
|
|
236
|
+
`isActive` and `renderLink` (plain `<a>` and `location.pathname` by
|
|
237
|
+
default); `matchesPath(pathname, href, end)` is segment-bounded.
|
|
238
|
+
- `AccountPage`: Nostr profile, session key, usage, time zone, theme, coupons,
|
|
239
|
+
build — one order. Each section is `false` or its own props; `before`,
|
|
240
|
+
`after`, and `between[section]` (kept in place when that section is off)
|
|
241
|
+
hold the site's panels.
|
|
242
|
+
- `RefreshButton`: spins (`motion-safe:animate-spin`, or
|
|
243
|
+
`classNames.spinning`), is disabled and `aria-busy` while `onRefresh`'s
|
|
244
|
+
promise runs or `busy` is set; `label` is the tooltip and accessible name.
|
|
245
|
+
|
|
177
246
|
### Debug panel
|
|
178
247
|
|
|
179
248
|
`callTool` logs to a shared, scrubbed ring buffer; `DebugPanel` shows it as a
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Profile page every site composed for itself, in one order for all of
|
|
3
|
+
* them: Nostr profile, session key, usage, time zone, theme, coupons, build.
|
|
4
|
+
*
|
|
5
|
+
* Each section is on by default; `false` turns it off and an object passes it
|
|
6
|
+
* its own props (headings, words, `classNames`). A site's own panels go in
|
|
7
|
+
* `before`, `after`, or `between` — `between.sessionKey` is drawn right after
|
|
8
|
+
* the session key, and keeps that place even when the section is off (a site
|
|
9
|
+
* with its own coupons turns `coupons` off and puts them in `between.coupons`).
|
|
10
|
+
*
|
|
11
|
+
* Mechanics only: the time zone and theme controls sit in a `<section>` with a
|
|
12
|
+
* heading and a line of intro (`classNames.section` …); every other panel draws
|
|
13
|
+
* itself. Log out is a chip at the end when `onSignOut` is given.
|
|
14
|
+
*/
|
|
15
|
+
import { type ReactNode } from "react";
|
|
16
|
+
import type { Kind0 } from "../standardTools.ts";
|
|
17
|
+
import type { TimezonePref } from "../timezone.ts";
|
|
18
|
+
import { type AccountSection } from "./accountSections.ts";
|
|
19
|
+
import { type BuildInfoPanelProps } from "./BuildInfoPanel.tsx";
|
|
20
|
+
import { type CouponsPanelProps } from "./CouponsPanel.tsx";
|
|
21
|
+
import { type SessionKeyClaimProps } from "./SessionKeyClaim.tsx";
|
|
22
|
+
import { type ThemeToggleProps } from "./ThemeToggle.tsx";
|
|
23
|
+
import { type TimezonePickerProps } from "./TimezonePicker.tsx";
|
|
24
|
+
import { type UsageSummaryProps } from "./UsageSummary.tsx";
|
|
25
|
+
export interface AccountSectionText {
|
|
26
|
+
heading?: ReactNode;
|
|
27
|
+
intro?: ReactNode;
|
|
28
|
+
}
|
|
29
|
+
export interface AccountTimezoneProps extends TimezonePickerProps, AccountSectionText {
|
|
30
|
+
/** A line under the picker, given the stored pick and the zone it resolves to now. */
|
|
31
|
+
note?: (pref: TimezonePref, zone: string) => ReactNode;
|
|
32
|
+
}
|
|
33
|
+
export interface AccountThemeProps extends ThemeToggleProps, AccountSectionText {
|
|
34
|
+
}
|
|
35
|
+
export interface AccountPageClassNames {
|
|
36
|
+
root?: string;
|
|
37
|
+
heading?: string;
|
|
38
|
+
/** The time-zone and theme sections: box, heading, intro, note. */
|
|
39
|
+
section?: string;
|
|
40
|
+
sectionHeading?: string;
|
|
41
|
+
sectionIntro?: string;
|
|
42
|
+
sectionNote?: string;
|
|
43
|
+
/** The row holding Log out, and the chip. */
|
|
44
|
+
actions?: string;
|
|
45
|
+
signOut?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface AccountPageProps {
|
|
48
|
+
npub: string;
|
|
49
|
+
/** Default "Profile"; null for none. */
|
|
50
|
+
heading?: ReactNode;
|
|
51
|
+
profile?: boolean | {
|
|
52
|
+
onPublished?: (profile: Kind0) => void;
|
|
53
|
+
};
|
|
54
|
+
sessionKey?: boolean | Omit<SessionKeyClaimProps, "npub">;
|
|
55
|
+
usage?: boolean | UsageSummaryProps;
|
|
56
|
+
timezone?: boolean | AccountTimezoneProps;
|
|
57
|
+
theme?: boolean | AccountThemeProps;
|
|
58
|
+
coupons?: boolean | CouponsPanelProps;
|
|
59
|
+
build?: boolean | BuildInfoPanelProps;
|
|
60
|
+
before?: ReactNode;
|
|
61
|
+
/** A site's panel after the named section. */
|
|
62
|
+
between?: Partial<Record<AccountSection, ReactNode>>;
|
|
63
|
+
after?: ReactNode;
|
|
64
|
+
onSignOut?: () => void;
|
|
65
|
+
/** Default "Log out". */
|
|
66
|
+
signOutLabel?: ReactNode;
|
|
67
|
+
classNames?: AccountPageClassNames;
|
|
68
|
+
}
|
|
69
|
+
export default function AccountPage({ npub, heading, profile, sessionKey, usage, timezone, theme, coupons, build, before, between, after, onSignOut, signOutLabel, classNames: c, }: AccountPageProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The Profile page every site composed for itself, in one order for all of
|
|
4
|
+
* them: Nostr profile, session key, usage, time zone, theme, coupons, build.
|
|
5
|
+
*
|
|
6
|
+
* Each section is on by default; `false` turns it off and an object passes it
|
|
7
|
+
* its own props (headings, words, `classNames`). A site's own panels go in
|
|
8
|
+
* `before`, `after`, or `between` — `between.sessionKey` is drawn right after
|
|
9
|
+
* the session key, and keeps that place even when the section is off (a site
|
|
10
|
+
* with its own coupons turns `coupons` off and puts them in `between.coupons`).
|
|
11
|
+
*
|
|
12
|
+
* Mechanics only: the time zone and theme controls sit in a `<section>` with a
|
|
13
|
+
* heading and a line of intro (`classNames.section` …); every other panel draws
|
|
14
|
+
* itself. Log out is a chip at the end when `onSignOut` is given.
|
|
15
|
+
*/
|
|
16
|
+
import { Fragment } from "react";
|
|
17
|
+
import { accountSteps } from "./accountSections.js";
|
|
18
|
+
import BuildInfoPanel, {} from "./BuildInfoPanel.js";
|
|
19
|
+
import CouponsPanel, {} from "./CouponsPanel.js";
|
|
20
|
+
import NostrProfilePanel from "./NostrProfilePanel.js";
|
|
21
|
+
import SessionKeyClaim, {} from "./SessionKeyClaim.js";
|
|
22
|
+
import ThemeToggle, {} from "./ThemeToggle.js";
|
|
23
|
+
import TimezonePicker, {} from "./TimezonePicker.js";
|
|
24
|
+
import UsageSummary, {} from "./UsageSummary.js";
|
|
25
|
+
import { useTimezone } from "./useTimezone.js";
|
|
26
|
+
const opts = (v) => (typeof v === "object" ? v : {});
|
|
27
|
+
export default function AccountPage({ npub, heading = "Profile", profile, sessionKey, usage, timezone, theme, coupons, build, before, between = {}, after, onSignOut, signOutLabel = "Log out", classNames: c = {}, }) {
|
|
28
|
+
const [tzPref, tzZone] = useTimezone();
|
|
29
|
+
const shown = {
|
|
30
|
+
profile: profile !== false,
|
|
31
|
+
sessionKey: sessionKey !== false,
|
|
32
|
+
usage: usage !== false,
|
|
33
|
+
timezone: timezone !== false,
|
|
34
|
+
theme: theme !== false,
|
|
35
|
+
coupons: coupons !== false,
|
|
36
|
+
build: build !== false,
|
|
37
|
+
};
|
|
38
|
+
const section = (key) => {
|
|
39
|
+
switch (key) {
|
|
40
|
+
case "profile":
|
|
41
|
+
return _jsx(NostrProfilePanel, { npub: npub, ...opts(profile) });
|
|
42
|
+
case "sessionKey":
|
|
43
|
+
// Keyed by npub so a revealed key never carries across a sign-in.
|
|
44
|
+
return _jsx(SessionKeyClaim, { npub: npub, ...opts(sessionKey) }, npub);
|
|
45
|
+
case "usage":
|
|
46
|
+
return _jsx(UsageSummary, { ...opts(usage) });
|
|
47
|
+
case "timezone": {
|
|
48
|
+
const { heading: h = "Display time zone", intro, note, ...picker } = opts(timezone);
|
|
49
|
+
return (_jsxs(Section, { heading: h, intro: intro, c: c, children: [_jsx(TimezonePicker, { ...picker }), note && _jsx("p", { className: c.sectionNote, children: note(tzPref, tzZone) })] }));
|
|
50
|
+
}
|
|
51
|
+
case "theme": {
|
|
52
|
+
const { heading: h = "Appearance", intro, ...toggle } = opts(theme);
|
|
53
|
+
return (_jsx(Section, { heading: h, intro: intro, c: c, children: _jsx(ThemeToggle, { ...toggle }) }));
|
|
54
|
+
}
|
|
55
|
+
case "coupons":
|
|
56
|
+
return _jsx(CouponsPanel, { ...opts(coupons) });
|
|
57
|
+
case "build":
|
|
58
|
+
return _jsx(BuildInfoPanel, { ...opts(build) });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return (_jsxs("div", { className: c.root, children: [heading != null && _jsx("h1", { className: c.heading, children: heading }), before, accountSteps(shown, between).map((step) => (_jsx(Fragment, { children: step.kind === "section" ? section(step.section) : between[step.section] }, `${step.kind}:${step.section}`))), after, onSignOut && (_jsx("div", { className: c.actions, children: _jsx("button", { type: "button", onClick: onSignOut, className: c.signOut, children: signOutLabel }) }))] }));
|
|
62
|
+
}
|
|
63
|
+
function Section({ heading, intro, c, children, }) {
|
|
64
|
+
return (_jsxs("section", { className: c.section, children: [heading != null && _jsx("h2", { className: c.sectionHeading, children: heading }), intro != null && _jsx("p", { className: c.sectionIntro, children: intro }), children] }));
|
|
65
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame every operator site had written for itself: who is signed in, the
|
|
3
|
+
* sign-in gate when nobody is, the service's status, the theme, the footer and
|
|
4
|
+
* the debug log — laid out so the log's room never pushes a bottom rail off
|
|
5
|
+
* the screen.
|
|
6
|
+
*
|
|
7
|
+
* It owns no routes. `children(shell)` draws the signed-in site (its nav and
|
|
8
|
+
* its router); `signedOut(shell)` draws the signed-out one, with
|
|
9
|
+
* `shell.gate` — the package `NpubGate`, already wired to sign in, to the
|
|
10
|
+
* operator's fingerprint and to the "your sign-in lapsed" note — wherever it
|
|
11
|
+
* belongs (under a hero, or on one route of a public site). Without
|
|
12
|
+
* `signedOut` the gate is the page.
|
|
13
|
+
*
|
|
14
|
+
* Inside, `useAppShell()` gives any component the same `shell` — the npub,
|
|
15
|
+
* `signOut`, the `service_status` answer — so a site keeps no session context
|
|
16
|
+
* of its own.
|
|
17
|
+
*
|
|
18
|
+
* Also done here, once: the stored theme applied before paint and followed
|
|
19
|
+
* across tabs; the npub's kind-0 picture seeded as its avatar; `DebugPanel`
|
|
20
|
+
* mounted last, so its spacer is the page's last thing (see `shellLayout`).
|
|
21
|
+
*/
|
|
22
|
+
import { type ReactNode } from "react";
|
|
23
|
+
import { type ServiceStatus } from "../standardTools.ts";
|
|
24
|
+
import { type Theme } from "../theme.ts";
|
|
25
|
+
import { type DebugPanelProps } from "./DebugPanel.tsx";
|
|
26
|
+
import { type ShellFit } from "./shellLayout.ts";
|
|
27
|
+
import { type Session } from "./useSession.ts";
|
|
28
|
+
export interface AppShellContext {
|
|
29
|
+
session: Session;
|
|
30
|
+
/** The `service_status` answer, or null until (or unless) it comes. */
|
|
31
|
+
status: ServiceStatus | null;
|
|
32
|
+
/** The sign-in gate, ready to place. */
|
|
33
|
+
gate: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
export interface AppShellClassNames {
|
|
36
|
+
root?: string;
|
|
37
|
+
/** The region the site draws in, between the top of the page and the footer. */
|
|
38
|
+
body?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface AppShellProps {
|
|
41
|
+
/** The signed-in site: its nav, its routes. */
|
|
42
|
+
children: (shell: AppShellContext) => ReactNode;
|
|
43
|
+
/** The signed-out site, placing `shell.gate`. Default: the gate alone. */
|
|
44
|
+
signedOut?: (shell: AppShellContext) => ReactNode;
|
|
45
|
+
footer?: ReactNode | ((shell: AppShellContext) => ReactNode);
|
|
46
|
+
/** The debug log's props (its children are the site's own section), or false for none. */
|
|
47
|
+
debug?: DebugPanelProps | false;
|
|
48
|
+
/** The theme when none is stored. Default "dark". */
|
|
49
|
+
theme?: Theme;
|
|
50
|
+
/** "page" grows with the content; "viewport" pins to the screen for a bottom rail. Default "page". */
|
|
51
|
+
fit?: ShellFit;
|
|
52
|
+
/** Arrive at the gate with a new key already made. */
|
|
53
|
+
startFresh?: boolean;
|
|
54
|
+
classNames?: AppShellClassNames;
|
|
55
|
+
}
|
|
56
|
+
/** The shell's session, status and gate, for any component inside `AppShell`. */
|
|
57
|
+
export declare function useAppShell(): AppShellContext;
|
|
58
|
+
export default function AppShell({ children, signedOut, footer, debug, theme, fit, startFresh, classNames: c, }: AppShellProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The frame every operator site had written for itself: who is signed in, the
|
|
4
|
+
* sign-in gate when nobody is, the service's status, the theme, the footer and
|
|
5
|
+
* the debug log — laid out so the log's room never pushes a bottom rail off
|
|
6
|
+
* the screen.
|
|
7
|
+
*
|
|
8
|
+
* It owns no routes. `children(shell)` draws the signed-in site (its nav and
|
|
9
|
+
* its router); `signedOut(shell)` draws the signed-out one, with
|
|
10
|
+
* `shell.gate` — the package `NpubGate`, already wired to sign in, to the
|
|
11
|
+
* operator's fingerprint and to the "your sign-in lapsed" note — wherever it
|
|
12
|
+
* belongs (under a hero, or on one route of a public site). Without
|
|
13
|
+
* `signedOut` the gate is the page.
|
|
14
|
+
*
|
|
15
|
+
* Inside, `useAppShell()` gives any component the same `shell` — the npub,
|
|
16
|
+
* `signOut`, the `service_status` answer — so a site keeps no session context
|
|
17
|
+
* of its own.
|
|
18
|
+
*
|
|
19
|
+
* Also done here, once: the stored theme applied before paint and followed
|
|
20
|
+
* across tabs; the npub's kind-0 picture seeded as its avatar; `DebugPanel`
|
|
21
|
+
* mounted last, so its spacer is the page's last thing (see `shellLayout`).
|
|
22
|
+
*/
|
|
23
|
+
import { createContext, useContext, useEffect, useLayoutEffect, useState } from "react";
|
|
24
|
+
import { hydrateAvatarFromNostr } from "../avatar.js";
|
|
25
|
+
import { serviceStatus } from "../standardTools.js";
|
|
26
|
+
import { bootstrapTheme } from "../theme.js";
|
|
27
|
+
import DebugPanel, {} from "./DebugPanel.js";
|
|
28
|
+
import NpubGate from "./NpubGate.js";
|
|
29
|
+
import { shellLayout } from "./shellLayout.js";
|
|
30
|
+
import { useSession } from "./useSession.js";
|
|
31
|
+
import { useTheme } from "./useTheme.js";
|
|
32
|
+
const Ctx = createContext(null);
|
|
33
|
+
/** The shell's session, status and gate, for any component inside `AppShell`. */
|
|
34
|
+
export function useAppShell() {
|
|
35
|
+
const shell = useContext(Ctx);
|
|
36
|
+
if (!shell)
|
|
37
|
+
throw new Error("useAppShell must be used inside <AppShell>");
|
|
38
|
+
return shell;
|
|
39
|
+
}
|
|
40
|
+
export default function AppShell({ children, signedOut, footer, debug = {}, theme = "dark", fit = "page", startFresh, classNames: c = {}, }) {
|
|
41
|
+
const session = useSession();
|
|
42
|
+
const [status, setStatus] = useState(null);
|
|
43
|
+
useTheme(theme);
|
|
44
|
+
useLayoutEffect(() => {
|
|
45
|
+
bootstrapTheme(theme);
|
|
46
|
+
}, [theme]);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
let live = true;
|
|
49
|
+
serviceStatus({ bestEffort: true })
|
|
50
|
+
.then((s) => live && setStatus(s))
|
|
51
|
+
.catch(() => live && setStatus(null));
|
|
52
|
+
return () => {
|
|
53
|
+
live = false;
|
|
54
|
+
};
|
|
55
|
+
}, []);
|
|
56
|
+
const { npub, signedIn, notice, refresh } = session;
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (signedIn && npub)
|
|
59
|
+
void hydrateAvatarFromNostr(npub);
|
|
60
|
+
}, [signedIn, npub]);
|
|
61
|
+
const gate = (_jsx(NpubGate, { onLogin: refresh, operatorHash: status?.operator_npub_hash, notice: notice, startFresh: startFresh }));
|
|
62
|
+
const shell = { session, status, gate };
|
|
63
|
+
const layout = shellLayout(fit);
|
|
64
|
+
return (_jsx(Ctx.Provider, { value: shell, children: _jsxs("div", { className: c.root, style: layout.root, children: [_jsx("div", { className: c.body, style: layout.body, children: signedIn ? children(shell) : signedOut ? signedOut(shell) : gate }), typeof footer === "function" ? footer(shell) : footer, debug !== false && _jsx(DebugPanel, { ...debug })] }) }));
|
|
65
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one refresh control: an icon button that spins while the re-read runs.
|
|
3
|
+
*
|
|
4
|
+
* Give it `onRefresh`; if that returns a promise the button is busy — spinning,
|
|
5
|
+
* disabled, `aria-busy` — until it settles, so a second press cannot start a
|
|
6
|
+
* second read. `busy` adds a busy state the site already holds (a page's
|
|
7
|
+
* first load). A rejected refresh is not swallowed: it still rejects, and the
|
|
8
|
+
* debug log's unhandled-rejection capture records it.
|
|
9
|
+
*
|
|
10
|
+
* Mechanics only: the size, colour and shape are the site's (`classNames`);
|
|
11
|
+
* the spin is `motion-safe:animate-spin` unless `classNames.spinning` names
|
|
12
|
+
* another. The label is both the tooltip and the accessible name — override it
|
|
13
|
+
* only to say WHAT refreshes.
|
|
14
|
+
*/
|
|
15
|
+
import { type ReactNode } from "react";
|
|
16
|
+
export interface RefreshButtonClassNames {
|
|
17
|
+
root?: string;
|
|
18
|
+
/** Around the glyph. */
|
|
19
|
+
icon?: string;
|
|
20
|
+
/** Added around the glyph while busy. Default "motion-safe:animate-spin". */
|
|
21
|
+
spinning?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface RefreshButtonProps {
|
|
24
|
+
onRefresh: () => unknown;
|
|
25
|
+
/** Busy for a reason the button did not start. */
|
|
26
|
+
busy?: boolean;
|
|
27
|
+
/** Tooltip and accessible name. Default "Refresh". */
|
|
28
|
+
label?: string;
|
|
29
|
+
/** Replaces the refresh glyph. */
|
|
30
|
+
icon?: ReactNode;
|
|
31
|
+
/** The default glyph's size in px. Default 20. */
|
|
32
|
+
iconSize?: number;
|
|
33
|
+
classNames?: RefreshButtonClassNames;
|
|
34
|
+
}
|
|
35
|
+
export default function RefreshButton({ onRefresh, busy, label, icon, iconSize, classNames: c, }: RefreshButtonProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The one refresh control: an icon button that spins while the re-read runs.
|
|
4
|
+
*
|
|
5
|
+
* Give it `onRefresh`; if that returns a promise the button is busy — spinning,
|
|
6
|
+
* disabled, `aria-busy` — until it settles, so a second press cannot start a
|
|
7
|
+
* second read. `busy` adds a busy state the site already holds (a page's
|
|
8
|
+
* first load). A rejected refresh is not swallowed: it still rejects, and the
|
|
9
|
+
* debug log's unhandled-rejection capture records it.
|
|
10
|
+
*
|
|
11
|
+
* Mechanics only: the size, colour and shape are the site's (`classNames`);
|
|
12
|
+
* the spin is `motion-safe:animate-spin` unless `classNames.spinning` names
|
|
13
|
+
* another. The label is both the tooltip and the accessible name — override it
|
|
14
|
+
* only to say WHAT refreshes.
|
|
15
|
+
*/
|
|
16
|
+
import { useEffect, useRef, useState } from "react";
|
|
17
|
+
import { RefreshCw } from "lucide-react";
|
|
18
|
+
import { cx } from "./cx.js";
|
|
19
|
+
const TAP = { minWidth: 40, minHeight: 40 };
|
|
20
|
+
const GLYPH = { display: "inline-flex" };
|
|
21
|
+
export default function RefreshButton({ onRefresh, busy = false, label = "Refresh", icon, iconSize = 20, classNames: c = {}, }) {
|
|
22
|
+
const [running, setRunning] = useState(false);
|
|
23
|
+
const live = useRef(true);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
live.current = true;
|
|
26
|
+
return () => {
|
|
27
|
+
live.current = false;
|
|
28
|
+
};
|
|
29
|
+
}, []);
|
|
30
|
+
const spinning = busy || running;
|
|
31
|
+
function press() {
|
|
32
|
+
if (spinning)
|
|
33
|
+
return;
|
|
34
|
+
const result = onRefresh();
|
|
35
|
+
if (!(result instanceof Promise))
|
|
36
|
+
return;
|
|
37
|
+
setRunning(true);
|
|
38
|
+
void result.finally(() => {
|
|
39
|
+
if (live.current)
|
|
40
|
+
setRunning(false);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return (_jsx("button", { type: "button", onClick: press, disabled: spinning, "aria-busy": spinning, "aria-label": label, title: label, className: c.root, style: TAP, children: _jsx("span", { "aria-hidden": true, className: cx(c.icon, spinning && (c.spinning ?? "motion-safe:animate-spin")), style: GLYPH, children: icon ?? _jsx(RefreshCw, { size: iconSize }) }) }));
|
|
44
|
+
}
|
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
* Renders ONLY when this browser holds the session nsec for the signed-in
|
|
5
5
|
* npub. NIP-07 and courier-proof sign-ins see nothing: no disabled control,
|
|
6
6
|
* no placeholder, no mention. Painting the key takes two deliberate taps —
|
|
7
|
-
* "Claim your session key", then the Reveal
|
|
7
|
+
* "Claim your session key", then the Reveal (eye) button — so an account page opened
|
|
8
8
|
* in front of other people does not show it.
|
|
9
9
|
*
|
|
10
10
|
* Destinations: copy, download .env, password-manager prompt, and an
|
|
11
11
|
* encrypted DM to another npub the patron names. The nsec never reaches a
|
|
12
12
|
* log, error text, analytics call, or MCP tool argument.
|
|
13
13
|
*/
|
|
14
|
-
export
|
|
14
|
+
export interface SessionKeyClaimClassNames {
|
|
15
|
+
/** Each round icon action (Reveal, Copy, .env, password manager, DM) when not active. Default: the package's outlined chip. */
|
|
16
|
+
action?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SessionKeyClaimProps {
|
|
15
19
|
npub: string;
|
|
16
|
-
|
|
20
|
+
classNames?: SessionKeyClaimClassNames;
|
|
21
|
+
}
|
|
22
|
+
export default function SessionKeyClaim({ npub, classNames }: SessionKeyClaimProps): import("react").JSX.Element | null;
|
|
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
* Renders ONLY when this browser holds the session nsec for the signed-in
|
|
6
6
|
* npub. NIP-07 and courier-proof sign-ins see nothing: no disabled control,
|
|
7
7
|
* no placeholder, no mention. Painting the key takes two deliberate taps —
|
|
8
|
-
* "Claim your session key", then the Reveal
|
|
8
|
+
* "Claim your session key", then the Reveal (eye) button — so an account page opened
|
|
9
9
|
* in front of other people does not show it.
|
|
10
10
|
*
|
|
11
11
|
* Destinations: copy, download .env, password-manager prompt, and an
|
|
@@ -13,13 +13,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
13
13
|
* log, error text, analytics call, or MCP tool argument.
|
|
14
14
|
*/
|
|
15
15
|
import { useRef, useState } from "react";
|
|
16
|
+
import { Check, Copy, Download, Eye, EyeOff, KeyRound, Send } from "lucide-react";
|
|
16
17
|
import { tollboothConfig } from "../config.js";
|
|
17
18
|
import { passwordManagerOutcomeLabel, sessionKeyClaimVisible, sessionKeyEnvContents, sessionKeyEnvFilename, } from "../sessionKeyClaim.js";
|
|
18
19
|
import { sendSessionKeyDm } from "../sessionKeyDm.js";
|
|
19
20
|
import { getSessionNsec, getSessionNsecBytes, hasSessionNsec, sessionNsecNpub, } from "../sessionNsec.js";
|
|
20
21
|
import { shareOrDownload } from "../shareFile.js";
|
|
21
22
|
import { card, input, muted } from "./ui.js";
|
|
22
|
-
export default function SessionKeyClaim({ npub }) {
|
|
23
|
+
export default function SessionKeyClaim({ npub, classNames = {} }) {
|
|
23
24
|
const visible = sessionKeyClaimVisible({
|
|
24
25
|
hasSessionNsec: hasSessionNsec(),
|
|
25
26
|
sessionNsecNpub: sessionNsecNpub(),
|
|
@@ -28,9 +29,9 @@ export default function SessionKeyClaim({ npub }) {
|
|
|
28
29
|
// Hard requirement: silence when the browser does not hold the key.
|
|
29
30
|
if (!visible)
|
|
30
31
|
return null;
|
|
31
|
-
return _jsx(SessionKeyClaimInner, {});
|
|
32
|
+
return _jsx(SessionKeyClaimInner, { actionClass: classNames.action });
|
|
32
33
|
}
|
|
33
|
-
function SessionKeyClaimInner() {
|
|
34
|
+
function SessionKeyClaimInner({ actionClass }) {
|
|
34
35
|
const { slug, appName } = tollboothConfig();
|
|
35
36
|
const envFilename = sessionKeyEnvFilename(slug);
|
|
36
37
|
const [revealed, setRevealed] = useState(false);
|
|
@@ -162,17 +163,19 @@ function SessionKeyClaimInner() {
|
|
|
162
163
|
setDmOpen(false);
|
|
163
164
|
setFlash(null);
|
|
164
165
|
setRecipient("");
|
|
165
|
-
}, className: `text-[12px] ${muted} hover:text-[var(--tb-ink)]`, children: "Hide" })] }), _jsxs("p", { className: `mb-3 text-[12.5px] leading-relaxed ${muted}`, children: ["Anyone who holds this key is you. It leaves this page only when you choose a destination below \u2014 never logged, never sent to ", appName, "."] }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(DestButton, { onClick: () => setShowKey((v) => !v), active: showKey, children: showKey ? "
|
|
166
|
+
}, className: `text-[12px] ${muted} hover:text-[var(--tb-ink)]`, children: "Hide" })] }), _jsxs("p", { className: `mb-3 text-[12.5px] leading-relaxed ${muted}`, children: ["Anyone who holds this key is you. It leaves this page only when you choose a destination below \u2014 never logged, never sent to ", appName, "."] }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(DestButton, { label: showKey ? "Conceal the key" : "Reveal the key", onClick: () => setShowKey((v) => !v), active: showKey, className: actionClass, children: showKey ? _jsx(EyeOff, { size: 20, "aria-hidden": "true" }) : _jsx(Eye, { size: 20, "aria-hidden": "true" }) }), _jsx(DestButton, { label: copied ? "Copied" : "Copy the key", onClick: () => void copyKey(), disabled: busy, className: actionClass, children: copied ? _jsx(Check, { size: 20, "aria-hidden": "true" }) : _jsx(Copy, { size: 20, "aria-hidden": "true" }) }), _jsx(DestButton, { label: "Download a .env file", onClick: () => void downloadEnv(), disabled: busy, className: actionClass, children: _jsx(Download, { size: 20, "aria-hidden": "true" }) }), _jsx(DestButton, { label: "Save to your password manager", onClick: () => void passwordManagerSave(), disabled: busy, className: actionClass, children: _jsx(KeyRound, { size: 20, "aria-hidden": "true" }) }), _jsx(DestButton, { label: "Send by encrypted DM", onClick: () => {
|
|
166
167
|
setDmOpen((v) => !v);
|
|
167
168
|
setFlash(null);
|
|
168
|
-
}, disabled: busy, active: dmOpen, children:
|
|
169
|
+
}, disabled: busy, active: dmOpen, className: actionClass, children: _jsx(Send, { size: 20, "aria-hidden": "true" }) })] }), showKey && (_jsx("div", { className: "mt-3 break-all rounded-lg border border-[var(--tb-line)] bg-[var(--tb-surface-2)] px-3 py-2 font-mono text-[12px] text-[var(--tb-ink)]", children: getSessionNsec() })), dmOpen && (_jsxs("div", { className: "mt-3 space-y-2 rounded-lg border border-[var(--tb-line)] bg-[var(--tb-surface-2)] p-3", children: [_jsx("p", { className: `text-[12px] leading-relaxed ${muted}`, children: "Encrypts the key to an npub you own and publishes the sealed message to public relays. Only that npub can open it." }), _jsxs("div", { className: "flex flex-wrap items-end gap-2", children: [_jsxs("label", { className: `block min-w-[16rem] flex-1 text-[12px] ${muted}`, children: ["Recipient npub", _jsx("input", { value: recipient, onChange: (e) => setRecipient(e.target.value), spellCheck: false, autoCapitalize: "off", autoCorrect: "off", placeholder: "npub1\u2026", className: `mt-1 h-11 ${input}` })] }), _jsx("button", { type: "button", onClick: () => void sendDm(), disabled: busy || recipient.trim().length < 60, className: "min-h-11 shrink-0 rounded-full border border-[var(--tb-accent)] bg-[var(--tb-accent)] px-4 text-[13px] font-semibold text-[var(--tb-on-accent)] disabled:opacity-40", children: busy ? "Sending…" : "Send encrypted DM" })] })] })), _jsxs("form", { ref: formRef, className: "absolute h-px w-px overflow-hidden opacity-0", "aria-hidden": "true", autoComplete: "on", onSubmit: (e) => {
|
|
169
170
|
e.preventDefault();
|
|
170
171
|
}, children: [_jsx("input", { ref: userRef, name: "username", autoComplete: "username", defaultValue: "", readOnly: true, tabIndex: -1 }), _jsx("input", { ref: passRef, name: "password", type: "password", autoComplete: "new-password", defaultValue: "", readOnly: true, tabIndex: -1 })] }), flash && (_jsx("p", { className: `mt-3 text-[12.5px] leading-relaxed ${flash.tone === "ok" ? "text-[var(--tb-ink)]" : "text-[var(--tb-err-ink)]"}`, role: "status", children: flash.text }))] }));
|
|
171
172
|
}
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
/** An icon-only round action. `label` is its aria-label and tooltip. */
|
|
174
|
+
function DestButton({ label, onClick, disabled, active, className, children, }) {
|
|
175
|
+
const idle = className ?? `border-[var(--tb-line)] ${muted} active:bg-[var(--tb-surface-2)]`;
|
|
176
|
+
return (_jsx("button", { type: "button", onClick: onClick, disabled: disabled, "aria-label": label, title: label, className: `inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--tb-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--tb-surface)] disabled:opacity-40 ${active
|
|
174
177
|
? "border-[var(--tb-accent)] bg-[var(--tb-accent)] text-[var(--tb-on-accent)]"
|
|
175
|
-
:
|
|
178
|
+
: idle}`, children: children }));
|
|
176
179
|
}
|
|
177
180
|
/**
|
|
178
181
|
* Offer the key to a browser password manager. Never claims an OS keychain.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The top bar: the site's brand, its pages, and on the right whatever the site
|
|
3
|
+
* keeps there (a balance chip) and the account menu (avatar → identity, links,
|
|
4
|
+
* log out). On a phone the pages fold into a menu button.
|
|
5
|
+
*
|
|
6
|
+
* Mechanics only. The package owns which page is current, the phone fold, the
|
|
7
|
+
* two popovers (aria-expanded, focus into the menu and back to its button,
|
|
8
|
+
* Escape, a press outside, the arrow keys) and 40 px tap targets; every class
|
|
9
|
+
* is the site's, through `classNames` — with none it is plain markup.
|
|
10
|
+
*
|
|
11
|
+
* Router-agnostic: `isActive(href, item)` says which page is current and
|
|
12
|
+
* `renderLink` draws each link (a react-router `<Link to>`); by default the
|
|
13
|
+
* links are plain `<a href>` and the current page is read from
|
|
14
|
+
* `location.pathname`. `matchesPath` is the matching a site's `isActive`
|
|
15
|
+
* usually wants.
|
|
16
|
+
*/
|
|
17
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
18
|
+
export interface SiteNavItem {
|
|
19
|
+
href: string;
|
|
20
|
+
label: ReactNode;
|
|
21
|
+
icon?: ReactNode;
|
|
22
|
+
/** A count or mark after the label; nothing when null or undefined. */
|
|
23
|
+
badge?: ReactNode;
|
|
24
|
+
/** Current only on this exact path, not the pages beneath it (the home item). */
|
|
25
|
+
end?: boolean;
|
|
26
|
+
title?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface SiteNavLinkProps {
|
|
29
|
+
href: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
title?: string;
|
|
33
|
+
"aria-current"?: "page";
|
|
34
|
+
onClick?: () => void;
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
export interface SiteNavAccount {
|
|
38
|
+
npub: string;
|
|
39
|
+
/** Over the npub in the menu. Default "Nostr identity". */
|
|
40
|
+
heading?: ReactNode;
|
|
41
|
+
/** The menu's links (Profile, Wallet). */
|
|
42
|
+
links?: readonly SiteNavItem[];
|
|
43
|
+
onSignOut?: () => void;
|
|
44
|
+
/** Default "Log out". */
|
|
45
|
+
signOutLabel?: ReactNode;
|
|
46
|
+
/** Default 32. */
|
|
47
|
+
avatarSize?: number;
|
|
48
|
+
/** The avatar button's accessible name. Default "Account". */
|
|
49
|
+
label?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface SiteNavClassNames {
|
|
52
|
+
root?: string;
|
|
53
|
+
/** The `<nav>` holding the pages (or, folded, the menu button and menu). */
|
|
54
|
+
nav?: string;
|
|
55
|
+
/** The pages in a row. */
|
|
56
|
+
list?: string;
|
|
57
|
+
item?: string;
|
|
58
|
+
/** Added to the current page's link, in the row and (unless `menuItemActive`) the menu. */
|
|
59
|
+
active?: string;
|
|
60
|
+
icon?: string;
|
|
61
|
+
label?: string;
|
|
62
|
+
badge?: string;
|
|
63
|
+
/** The right side: `trailing` and the account menu. */
|
|
64
|
+
end?: string;
|
|
65
|
+
/** The phone menu's button, and the menu. */
|
|
66
|
+
toggle?: string;
|
|
67
|
+
menu?: string;
|
|
68
|
+
menuItem?: string;
|
|
69
|
+
menuItemActive?: string;
|
|
70
|
+
/** The account menu's wrapper, avatar button and popover. */
|
|
71
|
+
account?: string;
|
|
72
|
+
accountButton?: string;
|
|
73
|
+
accountMenu?: string;
|
|
74
|
+
accountHeader?: string;
|
|
75
|
+
accountHeading?: string;
|
|
76
|
+
accountNpub?: string;
|
|
77
|
+
accountLink?: string;
|
|
78
|
+
signOut?: string;
|
|
79
|
+
}
|
|
80
|
+
export interface SiteNavProps {
|
|
81
|
+
brand?: ReactNode;
|
|
82
|
+
items: readonly SiteNavItem[];
|
|
83
|
+
isActive?: (href: string, item: SiteNavItem) => boolean;
|
|
84
|
+
renderLink?: (props: SiteNavLinkProps) => ReactNode;
|
|
85
|
+
/** Drawn on the right, before the account menu: a balance chip, a Sign-in link. */
|
|
86
|
+
trailing?: ReactNode;
|
|
87
|
+
account?: SiteNavAccount;
|
|
88
|
+
/** When the pages fold into a menu; false never folds. Default "(max-width: 639px)". */
|
|
89
|
+
collapse?: string | false;
|
|
90
|
+
/** The `<nav>`'s accessible name. Default "Main". */
|
|
91
|
+
label?: string;
|
|
92
|
+
/** The menu button's accessible name. Default "Menu". */
|
|
93
|
+
menuLabel?: string;
|
|
94
|
+
menuIcon?: ReactNode;
|
|
95
|
+
closeIcon?: ReactNode;
|
|
96
|
+
classNames?: SiteNavClassNames;
|
|
97
|
+
}
|
|
98
|
+
export default function SiteNav({ brand, items, isActive, renderLink, trailing, account, collapse, label, menuLabel, menuIcon, closeIcon, classNames: c, }: SiteNavProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The top bar: the site's brand, its pages, and on the right whatever the site
|
|
4
|
+
* keeps there (a balance chip) and the account menu (avatar → identity, links,
|
|
5
|
+
* log out). On a phone the pages fold into a menu button.
|
|
6
|
+
*
|
|
7
|
+
* Mechanics only. The package owns which page is current, the phone fold, the
|
|
8
|
+
* two popovers (aria-expanded, focus into the menu and back to its button,
|
|
9
|
+
* Escape, a press outside, the arrow keys) and 40 px tap targets; every class
|
|
10
|
+
* is the site's, through `classNames` — with none it is plain markup.
|
|
11
|
+
*
|
|
12
|
+
* Router-agnostic: `isActive(href, item)` says which page is current and
|
|
13
|
+
* `renderLink` draws each link (a react-router `<Link to>`); by default the
|
|
14
|
+
* links are plain `<a href>` and the current page is read from
|
|
15
|
+
* `location.pathname`. `matchesPath` is the matching a site's `isActive`
|
|
16
|
+
* usually wants.
|
|
17
|
+
*/
|
|
18
|
+
import { useEffect, useId } from "react";
|
|
19
|
+
import { Menu, X } from "lucide-react";
|
|
20
|
+
import Avatar from "./Avatar.js";
|
|
21
|
+
import { cx } from "./cx.js";
|
|
22
|
+
import { matchesPath } from "./navState.js";
|
|
23
|
+
import { useAvatar } from "./useAvatar.js";
|
|
24
|
+
import { useDisclosure } from "./useDisclosure.js";
|
|
25
|
+
import { useMediaQuery } from "./useMediaQuery.js";
|
|
26
|
+
const TAP = { minWidth: 40, minHeight: 40 };
|
|
27
|
+
const TAP_ROW = { display: "flex", alignItems: "center", minHeight: 40 };
|
|
28
|
+
const plainLink = ({ children, ...p }) => _jsx("a", { ...p, children: children });
|
|
29
|
+
const pathActive = (href, item) => typeof location !== "undefined" && matchesPath(location.pathname, href, item.end);
|
|
30
|
+
export default function SiteNav({ brand, items, isActive = pathActive, renderLink = plainLink, trailing, account, collapse = "(max-width: 639px)", label = "Main", menuLabel = "Menu", menuIcon = _jsx(Menu, { "aria-hidden": true, size: 20 }), closeIcon = _jsx(X, { "aria-hidden": true, size: 20 }), classNames: c = {}, }) {
|
|
31
|
+
const folded = useMediaQuery(collapse);
|
|
32
|
+
const menu = useDisclosure();
|
|
33
|
+
const menuId = useId();
|
|
34
|
+
const { setOpen } = menu;
|
|
35
|
+
// Widened past the fold with the menu open: nothing to close later.
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!folded)
|
|
38
|
+
setOpen(false);
|
|
39
|
+
}, [folded, setOpen]);
|
|
40
|
+
const content = (item) => (_jsxs(_Fragment, { children: [item.icon && (_jsx("span", { "aria-hidden": true, className: c.icon, children: item.icon })), _jsx("span", { className: c.label, children: item.label }), item.badge != null && _jsx("span", { className: c.badge, children: item.badge })] }));
|
|
41
|
+
return (_jsxs("header", { className: c.root, children: [brand, _jsx("nav", { "aria-label": label, className: c.nav, ref: menu.rootRef, children: folded ? (_jsxs(_Fragment, { children: [_jsx("button", { ref: menu.buttonRef, type: "button", "aria-expanded": menu.open, "aria-controls": menuId, "aria-label": menuLabel, title: menuLabel, onClick: menu.toggle, className: c.toggle, style: TAP, children: menu.open ? closeIcon : menuIcon }), menu.open && (_jsx("ul", { id: menuId, ref: menu.panelRef, onKeyDown: menu.onPanelKeyDown, className: c.menu, children: items.map((item) => {
|
|
42
|
+
const on = isActive(item.href, item);
|
|
43
|
+
return (_jsx("li", { children: renderLink({
|
|
44
|
+
href: item.href,
|
|
45
|
+
title: item.title,
|
|
46
|
+
"aria-current": on ? "page" : undefined,
|
|
47
|
+
onClick: () => menu.close(),
|
|
48
|
+
className: cx(c.menuItem, on && (c.menuItemActive ?? c.active)),
|
|
49
|
+
style: TAP_ROW,
|
|
50
|
+
children: content(item),
|
|
51
|
+
}) }, item.href));
|
|
52
|
+
}) }))] })) : (_jsx("ul", { className: c.list, children: items.map((item) => {
|
|
53
|
+
const on = isActive(item.href, item);
|
|
54
|
+
return (_jsx("li", { children: renderLink({
|
|
55
|
+
href: item.href,
|
|
56
|
+
title: item.title,
|
|
57
|
+
"aria-current": on ? "page" : undefined,
|
|
58
|
+
className: cx(c.item, on && c.active),
|
|
59
|
+
children: content(item),
|
|
60
|
+
}) }, item.href));
|
|
61
|
+
}) })) }), (trailing != null || account) && (_jsxs("div", { className: c.end, children: [trailing, account && _jsx(AccountMenu, { account: account, isActive: isActive, renderLink: renderLink, c: c })] }))] }));
|
|
62
|
+
}
|
|
63
|
+
function AccountMenu({ account, isActive, renderLink, c, }) {
|
|
64
|
+
const { npub, heading = "Nostr identity", links = [], onSignOut, signOutLabel = "Log out", avatarSize = 32, label = "Account", } = account;
|
|
65
|
+
const avatar = useAvatar(npub);
|
|
66
|
+
const menu = useDisclosure();
|
|
67
|
+
const menuId = useId();
|
|
68
|
+
return (_jsxs("div", { ref: menu.rootRef, className: c.account, children: [_jsx("button", { ref: menu.buttonRef, type: "button", "aria-expanded": menu.open, "aria-controls": menuId, "aria-label": label, title: npub, onClick: menu.toggle, className: c.accountButton, style: TAP, children: _jsx(Avatar, { value: avatar, size: avatarSize }) }), menu.open && (_jsxs("div", { id: menuId, ref: menu.panelRef, onKeyDown: menu.onPanelKeyDown, className: c.accountMenu, children: [_jsxs("div", { className: c.accountHeader, children: [_jsx("div", { className: c.accountHeading, children: heading }), _jsx("div", { className: c.accountNpub, title: npub, children: npub })] }), links.map((item) => (_jsx("div", { children: renderLink({
|
|
69
|
+
href: item.href,
|
|
70
|
+
title: item.title,
|
|
71
|
+
"aria-current": isActive(item.href, item) ? "page" : undefined,
|
|
72
|
+
onClick: () => menu.close(),
|
|
73
|
+
className: c.accountLink,
|
|
74
|
+
style: TAP_ROW,
|
|
75
|
+
children: item.label,
|
|
76
|
+
}) }, item.href))), onSignOut && (_jsx("button", { type: "button", onClick: () => {
|
|
77
|
+
menu.close();
|
|
78
|
+
onSignOut();
|
|
79
|
+
}, className: c.signOut, style: TAP_ROW, children: signOutLabel }))] }))] }));
|
|
80
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The order `AccountPage` draws its sections in, one order for every site, and
|
|
3
|
+
* where a site's own panels go between them.
|
|
4
|
+
*/
|
|
5
|
+
export type AccountSection = "profile" | "sessionKey" | "usage" | "timezone" | "theme" | "coupons" | "build";
|
|
6
|
+
export declare const ACCOUNT_SECTIONS: readonly AccountSection[];
|
|
7
|
+
export type AccountStep = {
|
|
8
|
+
kind: "section";
|
|
9
|
+
section: AccountSection;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "between";
|
|
12
|
+
section: AccountSection;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Each shown section in order, each followed by the site's panel placed after
|
|
16
|
+
* it. A site panel keeps its place when the section before it is hidden — a
|
|
17
|
+
* site that brings its own coupons turns the package's off and puts its own
|
|
18
|
+
* `between.theme`, and it lands where the coupons would have been.
|
|
19
|
+
*/
|
|
20
|
+
export declare function accountSteps(shown: Partial<Record<AccountSection, boolean>>, between: Partial<Record<AccountSection, unknown>>): AccountStep[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The order `AccountPage` draws its sections in, one order for every site, and
|
|
3
|
+
* where a site's own panels go between them.
|
|
4
|
+
*/
|
|
5
|
+
export const ACCOUNT_SECTIONS = [
|
|
6
|
+
"profile",
|
|
7
|
+
"sessionKey",
|
|
8
|
+
"usage",
|
|
9
|
+
"timezone",
|
|
10
|
+
"theme",
|
|
11
|
+
"coupons",
|
|
12
|
+
"build",
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Each shown section in order, each followed by the site's panel placed after
|
|
16
|
+
* it. A site panel keeps its place when the section before it is hidden — a
|
|
17
|
+
* site that brings its own coupons turns the package's off and puts its own
|
|
18
|
+
* `between.theme`, and it lands where the coupons would have been.
|
|
19
|
+
*/
|
|
20
|
+
export function accountSteps(shown, between) {
|
|
21
|
+
const steps = [];
|
|
22
|
+
for (const section of ACCOUNT_SECTIONS) {
|
|
23
|
+
if (shown[section] !== false)
|
|
24
|
+
steps.push({ kind: "section", section });
|
|
25
|
+
if (between[section] != null && between[section] !== false)
|
|
26
|
+
steps.push({ kind: "between", section });
|
|
27
|
+
}
|
|
28
|
+
return steps;
|
|
29
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export { default as AccountPage, type AccountPageClassNames, type AccountPageProps, type AccountSectionText, type AccountThemeProps, type AccountTimezoneProps, } from "./AccountPage.tsx";
|
|
2
|
+
export { ACCOUNT_SECTIONS, type AccountSection } from "./accountSections.ts";
|
|
3
|
+
export { default as AppShell, useAppShell, type AppShellClassNames, type AppShellContext, type AppShellProps, } from "./AppShell.tsx";
|
|
4
|
+
export type { ShellFit } from "./shellLayout.ts";
|
|
1
5
|
export { default as Avatar } from "./Avatar.tsx";
|
|
2
6
|
export { default as AvatarPicker } from "./AvatarPicker.tsx";
|
|
3
7
|
export { default as BuildInfoPanel, type BuildInfoFrontend, type BuildInfoLicence, type BuildInfoPanelClassNames, type BuildInfoPanelProps, } from "./BuildInfoPanel.tsx";
|
|
@@ -8,8 +12,11 @@ export { OperatorFundingStatus, PatronFundingStatus, type FundingStatusClassName
|
|
|
8
12
|
export { default as NpubGate } from "./NpubGate.tsx";
|
|
9
13
|
export { default as NostrProfilePanel } from "./NostrProfilePanel.tsx";
|
|
10
14
|
export { PageControls, SortHeader, TableShell, type PageControlsProps, type PageControlsClassNames, type SortHeaderProps, type SortHeaderClassNames, type TableShellClassNames, } from "./PagedTable.tsx";
|
|
15
|
+
export { default as RefreshButton, type RefreshButtonClassNames, type RefreshButtonProps, } from "./RefreshButton.tsx";
|
|
11
16
|
export { default as QuoteScroller, type QuoteScrollerProps, type QuoteScrollerClassNames } from "./QuoteScroller.tsx";
|
|
12
|
-
export { default as SessionKeyClaim } from "./SessionKeyClaim.tsx";
|
|
17
|
+
export { default as SessionKeyClaim, type SessionKeyClaimProps, type SessionKeyClaimClassNames, } from "./SessionKeyClaim.tsx";
|
|
18
|
+
export { default as SiteNav, type SiteNavAccount, type SiteNavClassNames, type SiteNavItem, type SiteNavLinkProps, type SiteNavProps, } from "./SiteNav.tsx";
|
|
19
|
+
export { matchesPath } from "./navState.ts";
|
|
13
20
|
export { default as TableFilter, type DateFieldOption, type FilterQuestion, type TableFilterProps, type TableFilterClassNames, type TableFilterDates, type TableFilterQuestions, type TableFilterSearch, } from "./TableFilter.tsx";
|
|
14
21
|
export { default as ThemeToggle, type ThemeToggleProps, type ThemeToggleClassNames } from "./ThemeToggle.tsx";
|
|
15
22
|
export { default as TimezonePicker, type TimezoneOption, type TimezonePickerClassNames, type TimezonePickerProps, } from "./TimezonePicker.tsx";
|
package/dist/react/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export { default as AccountPage, } from "./AccountPage.js";
|
|
2
|
+
export { ACCOUNT_SECTIONS } from "./accountSections.js";
|
|
3
|
+
export { default as AppShell, useAppShell, } from "./AppShell.js";
|
|
1
4
|
export { default as Avatar } from "./Avatar.js";
|
|
2
5
|
export { default as AvatarPicker } from "./AvatarPicker.js";
|
|
3
6
|
export { default as BuildInfoPanel, } from "./BuildInfoPanel.js";
|
|
@@ -8,8 +11,11 @@ export { OperatorFundingStatus, PatronFundingStatus, } from "./FundingStatus.js"
|
|
|
8
11
|
export { default as NpubGate } from "./NpubGate.js";
|
|
9
12
|
export { default as NostrProfilePanel } from "./NostrProfilePanel.js";
|
|
10
13
|
export { PageControls, SortHeader, TableShell, } from "./PagedTable.js";
|
|
14
|
+
export { default as RefreshButton, } from "./RefreshButton.js";
|
|
11
15
|
export { default as QuoteScroller } from "./QuoteScroller.js";
|
|
12
|
-
export { default as SessionKeyClaim } from "./SessionKeyClaim.js";
|
|
16
|
+
export { default as SessionKeyClaim, } from "./SessionKeyClaim.js";
|
|
17
|
+
export { default as SiteNav, } from "./SiteNav.js";
|
|
18
|
+
export { matchesPath } from "./navState.js";
|
|
13
19
|
export { default as TableFilter, } from "./TableFilter.js";
|
|
14
20
|
export { default as ThemeToggle } from "./ThemeToggle.js";
|
|
15
21
|
export { default as TimezonePicker, } from "./TimezonePicker.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of `SiteNav`: which item is the current page, and where focus
|
|
3
|
+
* goes when a key is pressed inside an open menu. No DOM, no router.
|
|
4
|
+
*/
|
|
5
|
+
/** The path part of an href: no query, no hash, no trailing slash (but "/" stays "/"). */
|
|
6
|
+
export declare function pathOf(href: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* True when `href` is the page at `pathname`.
|
|
9
|
+
*
|
|
10
|
+
* `end` asks for the exact path; without it an href also owns everything
|
|
11
|
+
* beneath it, on a segment boundary — "/posts" is active on "/posts/42" but
|
|
12
|
+
* never on "/postscript". "/" without `end` is active everywhere, as a
|
|
13
|
+
* router's NavLink is; give the home item `end`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function matchesPath(pathname: string, href: string, end?: boolean): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The index to focus after `key` in an open menu of `count` items, with focus
|
|
18
|
+
* now on `current` (-1 when it is on none of them). `null` when the key is not
|
|
19
|
+
* one the menu moves on. Arrows wrap; Home and End go to the ends.
|
|
20
|
+
*/
|
|
21
|
+
export declare function menuFocusIndex(key: string, current: number, count: number): number | null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of `SiteNav`: which item is the current page, and where focus
|
|
3
|
+
* goes when a key is pressed inside an open menu. No DOM, no router.
|
|
4
|
+
*/
|
|
5
|
+
/** The path part of an href: no query, no hash, no trailing slash (but "/" stays "/"). */
|
|
6
|
+
export function pathOf(href) {
|
|
7
|
+
const cut = href.split(/[?#]/, 1)[0] || "/";
|
|
8
|
+
return cut.length > 1 ? cut.replace(/\/+$/, "") || "/" : cut;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* True when `href` is the page at `pathname`.
|
|
12
|
+
*
|
|
13
|
+
* `end` asks for the exact path; without it an href also owns everything
|
|
14
|
+
* beneath it, on a segment boundary — "/posts" is active on "/posts/42" but
|
|
15
|
+
* never on "/postscript". "/" without `end` is active everywhere, as a
|
|
16
|
+
* router's NavLink is; give the home item `end`.
|
|
17
|
+
*/
|
|
18
|
+
export function matchesPath(pathname, href, end = false) {
|
|
19
|
+
const here = pathOf(pathname);
|
|
20
|
+
const target = pathOf(href);
|
|
21
|
+
if (here === target)
|
|
22
|
+
return true;
|
|
23
|
+
if (end)
|
|
24
|
+
return false;
|
|
25
|
+
if (target === "/")
|
|
26
|
+
return true;
|
|
27
|
+
return here.startsWith(`${target}/`);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The index to focus after `key` in an open menu of `count` items, with focus
|
|
31
|
+
* now on `current` (-1 when it is on none of them). `null` when the key is not
|
|
32
|
+
* one the menu moves on. Arrows wrap; Home and End go to the ends.
|
|
33
|
+
*/
|
|
34
|
+
export function menuFocusIndex(key, current, count) {
|
|
35
|
+
if (count <= 0)
|
|
36
|
+
return null;
|
|
37
|
+
switch (key) {
|
|
38
|
+
case "ArrowDown":
|
|
39
|
+
return current < 0 ? 0 : (current + 1) % count;
|
|
40
|
+
case "ArrowUp":
|
|
41
|
+
return current < 0 ? count - 1 : (current - 1 + count) % count;
|
|
42
|
+
case "Home":
|
|
43
|
+
return 0;
|
|
44
|
+
case "End":
|
|
45
|
+
return count - 1;
|
|
46
|
+
default:
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame `AppShell` lays out: a column as tall as the viewport, the site's
|
|
3
|
+
* content growing to fill it, then the footer, then the debug log's spacer.
|
|
4
|
+
*
|
|
5
|
+
* "page" (the default) lets a long page grow past the viewport and scroll the
|
|
6
|
+
* window. "viewport" pins the frame to the viewport's height, for a site with
|
|
7
|
+
* a bottom rail that must stay on screen: the content region may shrink
|
|
8
|
+
* (min-height 0) and scrolls inside, so the debug log's spacer takes its room
|
|
9
|
+
* out of the content rather than pushing the rail off the bottom.
|
|
10
|
+
*/
|
|
11
|
+
import type { CSSProperties } from "react";
|
|
12
|
+
export type ShellFit = "page" | "viewport";
|
|
13
|
+
export interface ShellLayout {
|
|
14
|
+
root: CSSProperties;
|
|
15
|
+
body: CSSProperties;
|
|
16
|
+
}
|
|
17
|
+
export declare function shellLayout(fit?: ShellFit): ShellLayout;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame `AppShell` lays out: a column as tall as the viewport, the site's
|
|
3
|
+
* content growing to fill it, then the footer, then the debug log's spacer.
|
|
4
|
+
*
|
|
5
|
+
* "page" (the default) lets a long page grow past the viewport and scroll the
|
|
6
|
+
* window. "viewport" pins the frame to the viewport's height, for a site with
|
|
7
|
+
* a bottom rail that must stay on screen: the content region may shrink
|
|
8
|
+
* (min-height 0) and scrolls inside, so the debug log's spacer takes its room
|
|
9
|
+
* out of the content rather than pushing the rail off the bottom.
|
|
10
|
+
*/
|
|
11
|
+
export function shellLayout(fit = "page") {
|
|
12
|
+
const column = { display: "flex", flexDirection: "column" };
|
|
13
|
+
if (fit === "viewport") {
|
|
14
|
+
return {
|
|
15
|
+
root: { ...column, height: "100dvh", overflow: "hidden" },
|
|
16
|
+
body: { ...column, flex: "1 1 0%", minHeight: 0, overflow: "auto" },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
root: { ...column, minHeight: "100dvh" },
|
|
21
|
+
body: { ...column, flex: "1 0 auto" },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The npub's avatar, following the picker (same-tab event) and the npub. */
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { AVATAR_EVENT, avatarFor } from "../avatar.js";
|
|
4
|
+
export function useAvatar(npub) {
|
|
5
|
+
const [avatar, setAvatar] = useState(() => avatarFor(npub));
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
setAvatar(avatarFor(npub));
|
|
8
|
+
const changed = () => setAvatar(avatarFor(npub));
|
|
9
|
+
window.addEventListener(AVATAR_EVENT, changed);
|
|
10
|
+
return () => window.removeEventListener(AVATAR_EVENT, changed);
|
|
11
|
+
}, [npub]);
|
|
12
|
+
return avatar;
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An open/closed popover done once: the site nav's phone menu and its account
|
|
3
|
+
* menu. The button carries `aria-expanded`; opening focuses the first item;
|
|
4
|
+
* Escape closes and hands focus back to the button; a press or focus outside
|
|
5
|
+
* closes; the arrows, Home and End move between the items.
|
|
6
|
+
*/
|
|
7
|
+
import { type KeyboardEvent } from "react";
|
|
8
|
+
export declare function useDisclosure<Root extends HTMLElement, Panel extends HTMLElement>(): {
|
|
9
|
+
open: boolean;
|
|
10
|
+
setOpen: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
11
|
+
toggle: () => void;
|
|
12
|
+
close: (returnFocus?: boolean) => void;
|
|
13
|
+
rootRef: import("react").RefObject<Root | null>;
|
|
14
|
+
buttonRef: import("react").RefObject<HTMLButtonElement | null>;
|
|
15
|
+
panelRef: import("react").RefObject<Panel | null>;
|
|
16
|
+
onPanelKeyDown: (e: KeyboardEvent) => void;
|
|
17
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An open/closed popover done once: the site nav's phone menu and its account
|
|
3
|
+
* menu. The button carries `aria-expanded`; opening focuses the first item;
|
|
4
|
+
* Escape closes and hands focus back to the button; a press or focus outside
|
|
5
|
+
* closes; the arrows, Home and End move between the items.
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
8
|
+
import { menuFocusIndex } from "./navState.js";
|
|
9
|
+
const FOCUSABLE = "a[href], button:not([disabled]), [tabindex]:not([tabindex='-1'])";
|
|
10
|
+
function items(panel) {
|
|
11
|
+
return panel ? Array.from(panel.querySelectorAll(FOCUSABLE)) : [];
|
|
12
|
+
}
|
|
13
|
+
export function useDisclosure() {
|
|
14
|
+
const [open, setOpen] = useState(false);
|
|
15
|
+
const rootRef = useRef(null);
|
|
16
|
+
const buttonRef = useRef(null);
|
|
17
|
+
const panelRef = useRef(null);
|
|
18
|
+
const close = useCallback((returnFocus = false) => {
|
|
19
|
+
setOpen(false);
|
|
20
|
+
if (returnFocus)
|
|
21
|
+
buttonRef.current?.focus();
|
|
22
|
+
}, []);
|
|
23
|
+
const toggle = useCallback(() => setOpen((o) => !o), []);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!open)
|
|
26
|
+
return;
|
|
27
|
+
items(panelRef.current)[0]?.focus();
|
|
28
|
+
const outside = (e) => {
|
|
29
|
+
const root = rootRef.current;
|
|
30
|
+
if (root && e.target instanceof Node && !root.contains(e.target))
|
|
31
|
+
setOpen(false);
|
|
32
|
+
};
|
|
33
|
+
const onKey = (e) => {
|
|
34
|
+
if (e.key !== "Escape")
|
|
35
|
+
return;
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
setOpen(false);
|
|
38
|
+
buttonRef.current?.focus();
|
|
39
|
+
};
|
|
40
|
+
document.addEventListener("pointerdown", outside);
|
|
41
|
+
document.addEventListener("focusin", outside);
|
|
42
|
+
document.addEventListener("keydown", onKey);
|
|
43
|
+
return () => {
|
|
44
|
+
document.removeEventListener("pointerdown", outside);
|
|
45
|
+
document.removeEventListener("focusin", outside);
|
|
46
|
+
document.removeEventListener("keydown", onKey);
|
|
47
|
+
};
|
|
48
|
+
}, [open]);
|
|
49
|
+
const onPanelKeyDown = useCallback((e) => {
|
|
50
|
+
const list = items(panelRef.current);
|
|
51
|
+
const next = menuFocusIndex(e.key, list.indexOf(document.activeElement), list.length);
|
|
52
|
+
if (next === null)
|
|
53
|
+
return;
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
list[next]?.focus();
|
|
56
|
+
}, []);
|
|
57
|
+
return { open, setOpen, toggle, close, rootRef, buttonRef, panelRef, onPanelKeyDown };
|
|
58
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** True while `query` matches; false on the server, and when `query` is false. */
|
|
2
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
3
|
+
export function useMediaQuery(query) {
|
|
4
|
+
const subscribe = useCallback((changed) => {
|
|
5
|
+
if (!query || typeof globalThis.matchMedia !== "function")
|
|
6
|
+
return () => { };
|
|
7
|
+
const mq = globalThis.matchMedia(query);
|
|
8
|
+
mq.addEventListener("change", changed);
|
|
9
|
+
return () => mq.removeEventListener("change", changed);
|
|
10
|
+
}, [query]);
|
|
11
|
+
const read = useCallback(() => Boolean(query) && typeof globalThis.matchMedia === "function" && globalThis.matchMedia(query).matches, [query]);
|
|
12
|
+
return useSyncExternalStore(subscribe, read, () => false);
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tollbooth-dpyc/web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Shared browser SDK for Tollbooth DPYC operator front ends: npub sign-in, proofs, MCP client, profile and wallet",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Lonnie VanZandt",
|