@atelic-action/ui 0.17.2 → 1.1.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 +432 -45
- package/bin/atelic-check-base-path.ts +5 -0
- package/bin/atelic-clean-sitemap.ts +5 -0
- package/bin/atelic-contact-canary.ts +5 -0
- package/bin/atelic-print-sheets.sh +1 -1
- package/bin/atelic-seal-document.mjs +1 -1
- package/bin/atelic-serve-static.ts +1 -1
- package/dist/gate/index.d.ts +1 -1
- package/dist/gate/index.js +1 -1
- package/dist/gate/middleware.d.ts +4 -1
- package/dist/gate/middleware.js +14 -5
- package/dist/gate/wall.d.ts +25 -0
- package/dist/gate/wall.js +41 -2
- package/dist/routing/basePath.d.ts +24 -0
- package/dist/routing/basePath.js +54 -0
- package/dist/vite/index.d.ts +3 -2
- package/dist/vite/index.js +3 -2
- package/dist/vite/marketing-config.d.ts +50 -0
- package/dist/vite/marketing-config.js +83 -0
- package/package.json +164 -5
- package/src/app/AppProvider.tsx +41 -0
- package/src/app/AuthGuard.tsx +27 -0
- package/src/app/CardErrorBoundary.tsx +39 -0
- package/src/app/ErrorFallback.tsx +39 -0
- package/src/app/GoogleGlyph.tsx +22 -0
- package/src/app/config.ts +54 -0
- package/src/app/configure.ts +18 -0
- package/src/app/context/AppContext.tsx +52 -0
- package/src/app/context/SidebarContext.tsx +124 -0
- package/src/app/context/UserContext.tsx +139 -0
- package/src/app/hooks/useDebounce.ts +12 -0
- package/src/app/hooks/useIsDesktop.ts +31 -0
- package/src/app/hooks/useWakeLock.ts +49 -0
- package/src/app/index.ts +64 -0
- package/src/app/layout/ActionButton.tsx +67 -0
- package/src/app/layout/AppLayout.tsx +110 -0
- package/src/app/layout/AppPage.tsx +268 -0
- package/src/app/layout/Breadcrumbs.tsx +57 -0
- package/src/app/layout/DesktopRail.tsx +168 -0
- package/src/app/layout/FormActionButtons.tsx +69 -0
- package/src/app/layout/MobileMenu.tsx +156 -0
- package/src/app/layout/MobileTabBar.tsx +43 -0
- package/src/app/layout/PageError.tsx +30 -0
- package/src/app/layout/PageSkeleton.tsx +65 -0
- package/src/app/layout/SectionCards.tsx +33 -0
- package/src/app/layout/UserMenu.tsx +104 -0
- package/src/app/layout/identity.ts +22 -0
- package/src/app/layout/index.ts +20 -0
- package/src/app/layout/nav.ts +52 -0
- package/src/app/lib/dates.ts +57 -0
- package/src/app/lib/env.ts +37 -0
- package/src/app/lib/format.ts +61 -0
- package/src/app/lib/http.ts +205 -0
- package/src/app/lib/lastAuth.ts +45 -0
- package/src/app/lib/oauth.ts +32 -0
- package/src/app/lib/publicRoutes.ts +33 -0
- package/src/app/lib/queryClient.ts +38 -0
- package/src/app/lib/search.ts +17 -0
- package/src/app/lib/session.ts +36 -0
- package/src/app/lib/tokenRefresh.ts +137 -0
- package/src/app/lib/transformKeys.ts +60 -0
- package/src/app/lib/units.ts +228 -0
- package/src/app/lib/unwrap.ts +48 -0
- package/src/app/lib/utils.ts +10 -0
- package/src/app/stores/authStore.ts +80 -0
- package/src/app/stores/storage.ts +40 -0
- package/src/app/stores/unitsStore.ts +54 -0
- package/src/app/types/index.ts +72 -0
- package/src/app/types/query.ts +11 -0
- package/src/app/ui/accordion.tsx +54 -0
- package/src/app/ui/alert-dialog.tsx +114 -0
- package/src/app/ui/alert.tsx +49 -0
- package/src/app/ui/avatar.tsx +45 -0
- package/src/app/ui/badge-list.tsx +51 -0
- package/src/app/ui/badge.tsx +33 -0
- package/src/app/ui/button.tsx +49 -0
- package/src/app/ui/callout.tsx +22 -0
- package/src/app/ui/card.tsx +55 -0
- package/src/app/ui/checkbox.tsx +26 -0
- package/src/app/ui/clear-button.tsx +42 -0
- package/src/app/ui/command.tsx +140 -0
- package/src/app/ui/country-dropdown.tsx +184 -0
- package/src/app/ui/dialog.tsx +104 -0
- package/src/app/ui/dropdown-menu.tsx +185 -0
- package/src/app/ui/form.tsx +172 -0
- package/src/app/ui/index.ts +36 -0
- package/src/app/ui/input.tsx +22 -0
- package/src/app/ui/label.tsx +19 -0
- package/src/app/ui/option-select.tsx +56 -0
- package/src/app/ui/pagination.tsx +90 -0
- package/src/app/ui/popover.tsx +29 -0
- package/src/app/ui/progress.tsx +25 -0
- package/src/app/ui/search-input.tsx +63 -0
- package/src/app/ui/select.tsx +151 -0
- package/src/app/ui/separator.tsx +24 -0
- package/src/app/ui/sheet.tsx +119 -0
- package/src/app/ui/slider.tsx +23 -0
- package/src/app/ui/switch.tsx +27 -0
- package/src/app/ui/textarea.tsx +21 -0
- package/src/app/ui/tooltip.tsx +32 -0
- package/src/artifact/ArtifactShell.tsx +2 -2
- package/src/artifact/Gate.tsx +2 -2
- package/src/artifact/Note.tsx +9 -39
- package/src/artifact/Paragraph.tsx +65 -0
- package/src/artifact/Read.tsx +406 -0
- package/src/artifact/Writeup.tsx +7 -7
- package/src/artifact/index.ts +1 -0
- package/src/chrome/SiteShell.tsx +141 -0
- package/src/chrome/index.ts +2 -0
- package/src/components/Disclosure.tsx +80 -14
- package/src/components/GateLogin.tsx +1 -1
- package/src/components/Watermark.tsx +1 -1
- package/src/components/index.ts +6 -2
- package/src/gate/index.ts +1 -1
- package/src/gate/middleware.ts +17 -6
- package/src/gate/wall.ts +60 -2
- package/src/lib/basePathAudit.ts +96 -0
- package/src/lib/displayDate.ts +25 -0
- package/src/lib/honeypot.ts +11 -0
- package/src/lib/hours.ts +28 -0
- package/src/lib/sitemap.ts +34 -0
- package/src/lib/trackEvent.ts +26 -0
- package/src/lib/useOpenHeight.ts +28 -0
- package/src/primitives/Button.tsx +3 -1
- package/src/primitives/Chip.tsx +3 -1
- package/src/primitives/ImageFrame.tsx +48 -0
- package/src/primitives/ProseBlocks.tsx +67 -0
- package/src/primitives/QuoteCard.tsx +20 -0
- package/src/primitives/StarRow.tsx +37 -0
- package/src/primitives/TickList.tsx +24 -0
- package/src/primitives/index.ts +5 -0
- package/src/routing/BasePathProvider.tsx +47 -0
- package/src/routing/basePath.ts +57 -0
- package/src/routing/index.ts +8 -0
- package/src/scripts/check-base-path.ts +130 -0
- package/src/scripts/clean-sitemap.ts +27 -0
- package/src/scripts/contact-canary.ts +98 -0
- package/src/scripts/serve-static.ts +29 -3
- package/src/sections/BlogIndex.tsx +57 -0
- package/src/sections/BlogPost.tsx +65 -0
- package/src/sections/CTABanner.tsx +93 -0
- package/src/sections/CTASection.tsx +60 -0
- package/src/sections/ComparisonTable.tsx +114 -0
- package/src/sections/ContactForm.tsx +163 -0
- package/src/sections/ContactInfoCard.tsx +92 -0
- package/src/sections/CredentialList.tsx +28 -0
- package/src/sections/DeviceShowcase.tsx +39 -0
- package/src/sections/ExpandList.tsx +58 -0
- package/src/sections/FAQBrowser.tsx +283 -0
- package/src/sections/FeatureDetail.tsx +60 -0
- package/src/sections/FeatureGrid.tsx +83 -0
- package/src/sections/Gallery.tsx +84 -0
- package/src/sections/Hero.tsx +110 -0
- package/src/sections/HoursTable.tsx +23 -0
- package/src/sections/JumpRail.tsx +35 -0
- package/src/sections/LegalPage.tsx +43 -0
- package/src/sections/LocationSection.tsx +66 -0
- package/src/sections/Locations.tsx +71 -0
- package/src/sections/LogoMarquee.tsx +27 -0
- package/src/sections/MapEmbed.tsx +75 -0
- package/src/sections/MediaBand.tsx +20 -0
- package/src/sections/MenuList.tsx +44 -0
- package/src/sections/PageHero.tsx +25 -0
- package/src/sections/PressQuotes.tsx +51 -0
- package/src/sections/PricingBand.tsx +43 -0
- package/src/sections/PricingCard.tsx +36 -0
- package/src/sections/ProcessSteps.tsx +45 -0
- package/src/sections/ProfileCards.tsx +73 -0
- package/src/sections/RevealSlider.tsx +156 -0
- package/src/sections/ReviewCarousel.tsx +223 -0
- package/src/sections/ReviewWall.tsx +220 -0
- package/src/sections/SocialStrip.tsx +83 -0
- package/src/sections/SplitSection.tsx +56 -0
- package/src/sections/index.ts +76 -0
- package/src/sections/siteConfig.ts +201 -0
- package/src/styles/app.css +673 -0
- package/src/styles/artifact.css +759 -401
- package/src/styles/base.css +49 -47
- package/src/styles/chrome.css +71 -71
- package/src/styles/components.css +150 -140
- package/src/styles/gate.css +29 -29
- package/src/styles/primitives.css +190 -25
- package/src/styles/sections.css +2652 -0
- package/src/styles/star-rating.css +2 -2
- package/src/vite/index.ts +9 -2
- package/src/vite/marketing-config.ts +128 -0
- package/src/components/Collapsible.tsx +0 -43
- /package/src/scripts/{build-gate-payload.mjs → seal-document.mjs} +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { createContext, type ReactNode, useContext, useEffect, useRef } from "react";
|
|
3
|
+
import { session } from "../lib/session";
|
|
4
|
+
import type { UnitPreferences } from "../lib/units";
|
|
5
|
+
import { useAuthStore } from "../stores/authStore";
|
|
6
|
+
import { DEFAULT_SYSTEMS, useUnitsStore } from "../stores/unitsStore";
|
|
7
|
+
import type { AppUser, RegisteredUser } from "../types";
|
|
8
|
+
|
|
9
|
+
export interface UserContextValue<U extends AppUser = RegisteredUser> {
|
|
10
|
+
user: U | null;
|
|
11
|
+
isLoading: boolean;
|
|
12
|
+
error: Error | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const UserContext = createContext<UserContextValue | null>(null);
|
|
16
|
+
|
|
17
|
+
export function UserProvider({ children }: { children: ReactNode }) {
|
|
18
|
+
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
|
19
|
+
const authUser = useAuthStore((state) => state.user);
|
|
20
|
+
const queryClient = useQueryClient();
|
|
21
|
+
|
|
22
|
+
// Every way out (the menu, a 401, a failed refresh) lands here: a signed out
|
|
23
|
+
// shell holds no copy of the last person, so the next account never sees it.
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!isAuthenticated) queryClient.removeQueries({ queryKey: ["user", "me"] });
|
|
26
|
+
}, [isAuthenticated, queryClient]);
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
data: apiUser,
|
|
30
|
+
isLoading,
|
|
31
|
+
error,
|
|
32
|
+
} = useQuery({
|
|
33
|
+
queryKey: ["user", "me"],
|
|
34
|
+
queryFn: () => session.me(),
|
|
35
|
+
enabled: isAuthenticated,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Use auth store user as fallback while fetching, or API user when available
|
|
39
|
+
const user = isAuthenticated ? (apiUser ?? authUser) : null;
|
|
40
|
+
|
|
41
|
+
useUnitsSync(apiUser ?? null);
|
|
42
|
+
|
|
43
|
+
const value: UserContextValue = {
|
|
44
|
+
user,
|
|
45
|
+
isLoading: isAuthenticated && isLoading,
|
|
46
|
+
error: error as Error | null,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const sameSystems = (a: Partial<UnitPreferences> | undefined, b: UnitPreferences) =>
|
|
53
|
+
(Object.keys(b) as (keyof UnitPreferences)[]).every((measure) => a?.[measure] === b[measure]);
|
|
54
|
+
|
|
55
|
+
/** The account's snapshot as last hydrated into the store, or null while signed out. */
|
|
56
|
+
interface Hydrated {
|
|
57
|
+
id: string;
|
|
58
|
+
units: Partial<UnitPreferences> | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sameSnapshot = (
|
|
62
|
+
a: Hydrated | null,
|
|
63
|
+
id: string,
|
|
64
|
+
units: Partial<UnitPreferences> | undefined,
|
|
65
|
+
) =>
|
|
66
|
+
a !== null &&
|
|
67
|
+
a.id === id &&
|
|
68
|
+
(Object.keys(DEFAULT_SYSTEMS) as (keyof UnitPreferences)[]).every(
|
|
69
|
+
(measure) => a.units?.[measure] === units?.[measure],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Keeps the units store and the person's account in step. When the api user
|
|
74
|
+
* arrives, or comes back from a refetch holding a different choice (made on
|
|
75
|
+
* another device), their saved choices hydrate the store; from then on a
|
|
76
|
+
* change in the store is written back, so the same choice follows them to
|
|
77
|
+
* every device and to what the api itself writes in their units (a Strava
|
|
78
|
+
* description). Nothing is written while signed out, and nothing is written
|
|
79
|
+
* for a change the account already holds, so hydration never echoes.
|
|
80
|
+
*
|
|
81
|
+
* Saves go one at a time, in order, and only the newest one's response lands
|
|
82
|
+
* in the query cache: each save carries the whole snapshot, so an older
|
|
83
|
+
* response arriving late would otherwise hydrate the store backwards. A save
|
|
84
|
+
* outlives no account: after a sign out or a switch, what the old account
|
|
85
|
+
* queued is dropped and what it had in flight is not written to the cache.
|
|
86
|
+
*/
|
|
87
|
+
function useUnitsSync(apiUser: AppUser | null) {
|
|
88
|
+
const queryClient = useQueryClient();
|
|
89
|
+
const hydrated = useRef<Hydrated | null>(null);
|
|
90
|
+
const account = useRef<string | null>(null);
|
|
91
|
+
const queue = useRef<Promise<unknown>>(Promise.resolve());
|
|
92
|
+
const latest = useRef(0);
|
|
93
|
+
const { mutate: save } = useMutation({
|
|
94
|
+
// A save belongs to the account that made it: one still queued when
|
|
95
|
+
// that account signs out is dropped rather than written to the next.
|
|
96
|
+
mutationFn: (units: UnitPreferences) => {
|
|
97
|
+
const owner = account.current;
|
|
98
|
+
const run = queue.current.then(() =>
|
|
99
|
+
owner !== null && owner === account.current ? session.updateUnits(units) : null,
|
|
100
|
+
);
|
|
101
|
+
queue.current = run.catch(() => undefined);
|
|
102
|
+
return run;
|
|
103
|
+
},
|
|
104
|
+
onMutate: () => ({ seq: ++latest.current, owner: account.current }),
|
|
105
|
+
onSuccess: (saved, _units, context) => {
|
|
106
|
+
if (saved && context.seq === latest.current && context.owner === account.current) {
|
|
107
|
+
queryClient.setQueryData(["user", "me"], saved);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
account.current = apiUser?.id ?? null;
|
|
114
|
+
if (!apiUser) {
|
|
115
|
+
hydrated.current = null;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!sameSnapshot(hydrated.current, apiUser.id, apiUser.units)) {
|
|
119
|
+
hydrated.current = { id: apiUser.id, units: apiUser.units };
|
|
120
|
+
useUnitsStore.getState().hydrate(apiUser.units);
|
|
121
|
+
}
|
|
122
|
+
return useUnitsStore.subscribe((state) => {
|
|
123
|
+
if (!sameSystems(apiUser.units, state.systems)) save(state.systems);
|
|
124
|
+
});
|
|
125
|
+
}, [apiUser, save]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The signed in user: the api's copy once it arrives, the one stored at sign
|
|
130
|
+
* in until then. Typed as the app's registered user; pass a type to narrow it
|
|
131
|
+
* further at one call site.
|
|
132
|
+
*/
|
|
133
|
+
export function useUser<U extends AppUser = RegisteredUser>(): UserContextValue<U> {
|
|
134
|
+
const context = useContext(UserContext);
|
|
135
|
+
if (!context) {
|
|
136
|
+
throw new Error("useUser must be used within a UserProvider");
|
|
137
|
+
}
|
|
138
|
+
return context as unknown as UserContextValue<U>;
|
|
139
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export function useDebounce<T>(value: T, delayMs: number): T {
|
|
4
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const timer = setTimeout(() => setDebouncedValue(value), delayMs);
|
|
8
|
+
return () => clearTimeout(timer);
|
|
9
|
+
}, [value, delayMs]);
|
|
10
|
+
|
|
11
|
+
return debouncedValue;
|
|
12
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
/** The shell's one breakpoint: the desktop rail appears at Tailwind's `lg`.
|
|
4
|
+
* Every layout decision keyed on viewport reads this, so CSS and React agree. */
|
|
5
|
+
export const DESKTOP_QUERY = "(min-width: 1024px)";
|
|
6
|
+
|
|
7
|
+
function subscribe(onChange: () => void) {
|
|
8
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
9
|
+
return () => {};
|
|
10
|
+
}
|
|
11
|
+
const media = window.matchMedia(DESKTOP_QUERY);
|
|
12
|
+
media.addEventListener("change", onChange);
|
|
13
|
+
return () => media.removeEventListener("change", onChange);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getSnapshot() {
|
|
17
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return window.matchMedia(DESKTOP_QUERY).matches;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True at the desktop breakpoint (lg and up), false below it and in any
|
|
25
|
+
* environment without matchMedia (jsdom), where the mobile layout is the safe
|
|
26
|
+
* default. Backed by matchMedia rather than a resize listener so it flips
|
|
27
|
+
* exactly when the CSS does and never lags a debounce.
|
|
28
|
+
*/
|
|
29
|
+
export function useIsDesktop(): boolean {
|
|
30
|
+
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
|
31
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keep the screen awake while the consuming surface is mounted (the cook view:
|
|
5
|
+
* saucy fingers should never have to tap a dimming screen). Progressive
|
|
6
|
+
* enhancement over the Screen Wake Lock API:
|
|
7
|
+
* - Silently no-ops where unsupported (Firefox, older Safari).
|
|
8
|
+
* - The browser auto-releases the lock when the tab is hidden, so a
|
|
9
|
+
* visibilitychange listener re-acquires it when the user returns.
|
|
10
|
+
*/
|
|
11
|
+
export function useWakeLock(): void {
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!("wakeLock" in navigator)) return;
|
|
14
|
+
|
|
15
|
+
let sentinel: WakeLockSentinel | null = null;
|
|
16
|
+
let released = false;
|
|
17
|
+
let acquiring = false;
|
|
18
|
+
|
|
19
|
+
const acquire = async () => {
|
|
20
|
+
// Guard against overlapping requests (rapid visibility flips) and
|
|
21
|
+
// redundant ones while a lock is already held.
|
|
22
|
+
if (acquiring || (sentinel && !sentinel.released)) return;
|
|
23
|
+
acquiring = true;
|
|
24
|
+
try {
|
|
25
|
+
const lock = await navigator.wakeLock.request("screen");
|
|
26
|
+
sentinel = lock;
|
|
27
|
+
// The effect may have cleaned up while the request was in flight.
|
|
28
|
+
if (released) await lock.release();
|
|
29
|
+
} catch {
|
|
30
|
+
// Denied (low battery, permissions policy) — cook on without it.
|
|
31
|
+
} finally {
|
|
32
|
+
acquiring = false;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const onVisibilityChange = () => {
|
|
37
|
+
if (document.visibilityState === "visible") void acquire();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
void acquire();
|
|
41
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
42
|
+
|
|
43
|
+
return () => {
|
|
44
|
+
released = true;
|
|
45
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
46
|
+
void sentinel?.release().catch(() => {});
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
}
|
package/src/app/index.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app shell: the frame (rail, tab bar, menu sheet), the `AppPage`
|
|
3
|
+
* scaffold, the provider that hands the shell the app's config and nav, the
|
|
4
|
+
* signed in user, the transport and session, and the stores they share. The
|
|
5
|
+
* shadcn kit is its own entry, `@atelic-action/ui/app/ui`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// The provider, the config, and the frame
|
|
9
|
+
export { AppProvider, type AppProviderProps } from "./AppProvider";
|
|
10
|
+
export { AuthGuard } from "./AuthGuard";
|
|
11
|
+
export { CardErrorBoundary } from "./CardErrorBoundary";
|
|
12
|
+
export { type AppConfig, getAppConfig } from "./config";
|
|
13
|
+
export { configureApp } from "./configure";
|
|
14
|
+
export {
|
|
15
|
+
AppConfigProvider,
|
|
16
|
+
type AppConfigProviderProps,
|
|
17
|
+
useAppConfig,
|
|
18
|
+
useNav,
|
|
19
|
+
} from "./context/AppContext";
|
|
20
|
+
export { SidebarProvider, useRail, useSidebar } from "./context/SidebarContext";
|
|
21
|
+
export { type UserContextValue, UserProvider, useUser } from "./context/UserContext";
|
|
22
|
+
export { ErrorFallback, type ErrorFallbackProps } from "./ErrorFallback";
|
|
23
|
+
export { GoogleGlyph } from "./GoogleGlyph";
|
|
24
|
+
// Hooks
|
|
25
|
+
export { useDebounce } from "./hooks/useDebounce";
|
|
26
|
+
export { DESKTOP_QUERY, useIsDesktop } from "./hooks/useIsDesktop";
|
|
27
|
+
export { useWakeLock } from "./hooks/useWakeLock";
|
|
28
|
+
export * from "./layout";
|
|
29
|
+
export { avatarInitials, displayName } from "./layout/identity";
|
|
30
|
+
// The library: dates and phrasing, the transport, the session, units
|
|
31
|
+
export * from "./lib/dates";
|
|
32
|
+
export { type AppEnv, envSchema, parseEnv } from "./lib/env";
|
|
33
|
+
export * from "./lib/format";
|
|
34
|
+
export { apiFetch, buildQueryString, publicFetch, uploadFormData } from "./lib/http";
|
|
35
|
+
export { type LastAuth, readLastAuth, writeLastAuth } from "./lib/lastAuth";
|
|
36
|
+
export { postNavigate, startGoogleAuth } from "./lib/oauth";
|
|
37
|
+
export { isPublicPage, isPublicRoute } from "./lib/publicRoutes";
|
|
38
|
+
export { queryClient } from "./lib/queryClient";
|
|
39
|
+
export { matchesSearch } from "./lib/search";
|
|
40
|
+
export { session } from "./lib/session";
|
|
41
|
+
export {
|
|
42
|
+
ensureValidToken,
|
|
43
|
+
handleUnauthorized,
|
|
44
|
+
refreshAccessToken,
|
|
45
|
+
resetRefreshState,
|
|
46
|
+
} from "./lib/tokenRefresh";
|
|
47
|
+
export { toSnakeCase, transformKeys } from "./lib/transformKeys";
|
|
48
|
+
export * from "./lib/units";
|
|
49
|
+
export * from "./lib/unwrap";
|
|
50
|
+
export { cn } from "./lib/utils";
|
|
51
|
+
// The stores
|
|
52
|
+
export { type AuthState, DEFAULT_EXPIRY_BUFFER_MS, useAuthStore } from "./stores/authStore";
|
|
53
|
+
export { DEFAULT_SYSTEMS, type UnitsState, useUnitsStore } from "./stores/unitsStore";
|
|
54
|
+
// The wire protocol and the user
|
|
55
|
+
export type {
|
|
56
|
+
AppUser,
|
|
57
|
+
PaginatedResponse,
|
|
58
|
+
PaginationLinks,
|
|
59
|
+
RefreshResponse,
|
|
60
|
+
Register,
|
|
61
|
+
RegisteredUser,
|
|
62
|
+
TokenResponse,
|
|
63
|
+
} from "./types";
|
|
64
|
+
export type { ToastMeta } from "./types/query";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { LucideIcon } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { Button } from "../ui/button";
|
|
4
|
+
|
|
5
|
+
interface ActionButtonProps {
|
|
6
|
+
/** Lucide icon component */
|
|
7
|
+
icon: LucideIcon;
|
|
8
|
+
/** Button label (visible on larger screens) */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Click handler */
|
|
11
|
+
onClick?: () => void;
|
|
12
|
+
/** Button variant */
|
|
13
|
+
variant?: "default" | "outline" | "ghost" | "destructive" | "secondary";
|
|
14
|
+
/** Additional className */
|
|
15
|
+
className?: string;
|
|
16
|
+
/** Disabled state */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** Loading state */
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
/** Accessible label (defaults to label) */
|
|
21
|
+
"aria-label"?: string;
|
|
22
|
+
/** For use with Link components */
|
|
23
|
+
asChild?: boolean;
|
|
24
|
+
/** Button type */
|
|
25
|
+
type?: "button" | "submit" | "reset";
|
|
26
|
+
/** Children (for asChild usage) */
|
|
27
|
+
children?: React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Standardized responsive action button.
|
|
32
|
+
* Shows icon only on mobile, icon + text on larger screens.
|
|
33
|
+
*/
|
|
34
|
+
export function ActionButton({
|
|
35
|
+
icon: Icon,
|
|
36
|
+
label,
|
|
37
|
+
onClick,
|
|
38
|
+
variant = "outline",
|
|
39
|
+
className,
|
|
40
|
+
disabled,
|
|
41
|
+
loading,
|
|
42
|
+
"aria-label": ariaLabel,
|
|
43
|
+
asChild,
|
|
44
|
+
type = "button",
|
|
45
|
+
children,
|
|
46
|
+
}: ActionButtonProps) {
|
|
47
|
+
return (
|
|
48
|
+
<Button
|
|
49
|
+
variant={variant}
|
|
50
|
+
onClick={onClick}
|
|
51
|
+
className={cn("gap-2", className)}
|
|
52
|
+
aria-label={ariaLabel || label}
|
|
53
|
+
disabled={disabled || loading}
|
|
54
|
+
asChild={asChild}
|
|
55
|
+
type={type}
|
|
56
|
+
>
|
|
57
|
+
{asChild ? (
|
|
58
|
+
children
|
|
59
|
+
) : (
|
|
60
|
+
<>
|
|
61
|
+
<Icon className={cn("h-4 w-4 flex-shrink-0", loading && "animate-spin")} />
|
|
62
|
+
<span className="hidden sm:inline">{label}</span>
|
|
63
|
+
</>
|
|
64
|
+
)}
|
|
65
|
+
</Button>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { useAppConfig } from "../context/AppContext";
|
|
5
|
+
import { useSidebar } from "../context/SidebarContext";
|
|
6
|
+
import { useIsDesktop } from "../hooks/useIsDesktop";
|
|
7
|
+
import { isPublicPage } from "../lib/publicRoutes";
|
|
8
|
+
import { cn } from "../lib/utils";
|
|
9
|
+
import { useAuthStore } from "../stores/authStore";
|
|
10
|
+
import { DesktopRail, RAIL_WIDTH, RAIL_WIDTH_COLLAPSED } from "./DesktopRail";
|
|
11
|
+
import { MobileMenu } from "./MobileMenu";
|
|
12
|
+
import { MobileTabBar } from "./MobileTabBar";
|
|
13
|
+
|
|
14
|
+
interface AppLayoutProps {
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The chrome around a public page for a visitor who is not signed in: the
|
|
20
|
+
* brand on the left, a sign in link on the right, and the page. No rail, no
|
|
21
|
+
* tab bar, nothing that assumes an account.
|
|
22
|
+
*/
|
|
23
|
+
function PublicShell({ children }: AppLayoutProps) {
|
|
24
|
+
const appConfig = useAppConfig();
|
|
25
|
+
return (
|
|
26
|
+
<div className="min-h-screen bg-background">
|
|
27
|
+
<header className="sticky top-0 z-30 border-b border-border/40 bg-background">
|
|
28
|
+
<div className="flex h-14 items-center justify-between px-4 lg:px-6">
|
|
29
|
+
<Link
|
|
30
|
+
to="/"
|
|
31
|
+
className="flex items-center space-x-2 transition-opacity hover:opacity-80"
|
|
32
|
+
aria-label={`${appConfig.name} home`}
|
|
33
|
+
>
|
|
34
|
+
<span className="inline-block size-2 rounded-full bg-brand" aria-hidden="true" />
|
|
35
|
+
<span className="text-lg font-medium tracking-tight">{appConfig.mark}</span>
|
|
36
|
+
</Link>
|
|
37
|
+
<Link to="/" className="text-sm font-medium text-muted-foreground hover:text-foreground">
|
|
38
|
+
Sign in
|
|
39
|
+
</Link>
|
|
40
|
+
</div>
|
|
41
|
+
</header>
|
|
42
|
+
<main className="flex min-h-[calc(100vh-3.5rem)] flex-col">{children}</main>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The app shell around every authenticated page. Mobile first: below the
|
|
49
|
+
* desktop breakpoint it is a slim top bar (brand, hamburger menu) over the
|
|
50
|
+
* page with the section tab bar pinned to the bottom. At lg and up the top
|
|
51
|
+
* bar and tab bar give way to the navigation rail on the left. Every nav
|
|
52
|
+
* surface reads the one nav the app hands `AppProvider`.
|
|
53
|
+
*/
|
|
54
|
+
export function AppLayout({ children }: AppLayoutProps) {
|
|
55
|
+
const appConfig = useAppConfig();
|
|
56
|
+
const navigate = useNavigate();
|
|
57
|
+
const { pathname } = useLocation();
|
|
58
|
+
const { isCollapsed, toggleSidebar } = useSidebar();
|
|
59
|
+
const isDesktop = useIsDesktop();
|
|
60
|
+
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
|
61
|
+
const user = useAuthStore((state) => state.user);
|
|
62
|
+
const logout = useAuthStore((state) => state.logout);
|
|
63
|
+
|
|
64
|
+
const handleLogout = () => {
|
|
65
|
+
logout();
|
|
66
|
+
navigate({ to: "/", replace: true });
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (!isAuthenticated) {
|
|
70
|
+
return isPublicPage(pathname) ? <PublicShell>{children}</PublicShell> : children;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="min-h-screen bg-background">
|
|
75
|
+
<DesktopRail
|
|
76
|
+
pathname={pathname}
|
|
77
|
+
collapsed={isCollapsed}
|
|
78
|
+
onToggle={toggleSidebar}
|
|
79
|
+
user={user}
|
|
80
|
+
onLogout={handleLogout}
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
<div
|
|
84
|
+
style={{ paddingLeft: isDesktop ? (isCollapsed ? RAIL_WIDTH_COLLAPSED : RAIL_WIDTH) : 0 }}
|
|
85
|
+
className={cn(
|
|
86
|
+
"flex min-h-screen flex-col transition-[padding-left] duration-200 ease-out",
|
|
87
|
+
"pb-16 lg:pb-0",
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
<header className="sticky top-0 z-30 border-b border-border/40 bg-background lg:hidden">
|
|
91
|
+
<div className="flex h-14 items-center justify-between px-4">
|
|
92
|
+
<Link
|
|
93
|
+
to="/"
|
|
94
|
+
className="flex items-center space-x-2 transition-opacity hover:opacity-80"
|
|
95
|
+
aria-label={`${appConfig.name} home`}
|
|
96
|
+
>
|
|
97
|
+
<span className="inline-block size-2 rounded-full bg-brand" aria-hidden="true" />
|
|
98
|
+
<span className="text-lg font-medium tracking-tight">{appConfig.mark}</span>
|
|
99
|
+
</Link>
|
|
100
|
+
<MobileMenu pathname={pathname} user={user} onLogout={handleLogout} />
|
|
101
|
+
</div>
|
|
102
|
+
</header>
|
|
103
|
+
|
|
104
|
+
<main className="flex flex-1 flex-col">{children}</main>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{!isDesktop && <MobileTabBar pathname={pathname} />}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|