@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,156 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import { LogOut, Menu } from "lucide-react";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useAppConfig, useNav } from "../context/AppContext";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import type { AppUser } from "../types";
|
|
7
|
+
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
|
8
|
+
import { Button } from "../ui/button";
|
|
9
|
+
import { Sheet, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from "../ui/sheet";
|
|
10
|
+
import { avatarInitials, displayName } from "./identity";
|
|
11
|
+
import { isActivePath, isActiveSection } from "./nav";
|
|
12
|
+
|
|
13
|
+
interface MobileMenuProps {
|
|
14
|
+
pathname: string;
|
|
15
|
+
user: AppUser | null;
|
|
16
|
+
onLogout: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ROW =
|
|
20
|
+
"flex min-h-12 items-center gap-3 rounded-lg px-3 text-base transition-colors duration-150 active:bg-accent";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The hamburger menu for phones and tablets: a right side sheet holding the
|
|
24
|
+
* whole map (every section with its sub pages beneath it) and, at the foot,
|
|
25
|
+
* the signed in user with Profile and Log out. The tab bar stays the fast
|
|
26
|
+
* path between sections; this is where everything else lives. It closes
|
|
27
|
+
* itself on navigation.
|
|
28
|
+
*/
|
|
29
|
+
export function MobileMenu({ pathname, user, onLogout }: MobileMenuProps) {
|
|
30
|
+
const appConfig = useAppConfig();
|
|
31
|
+
const profileHref = appConfig.profileHref ?? "/profile";
|
|
32
|
+
const navItems = useNav();
|
|
33
|
+
const [open, setOpen] = useState(false);
|
|
34
|
+
const name = displayName(user);
|
|
35
|
+
|
|
36
|
+
// Any navigation, by tap or by history, shuts the sheet.
|
|
37
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger, not a dependency of the effect body
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
setOpen(false);
|
|
40
|
+
}, [pathname]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Sheet open={open} onOpenChange={setOpen}>
|
|
44
|
+
<SheetTrigger asChild>
|
|
45
|
+
<Button
|
|
46
|
+
variant="ghost"
|
|
47
|
+
size="icon"
|
|
48
|
+
className="-mr-2 h-11 w-11 text-foreground hover:bg-accent"
|
|
49
|
+
aria-label="Open menu"
|
|
50
|
+
aria-expanded={open}
|
|
51
|
+
>
|
|
52
|
+
<Menu className="h-6 w-6" />
|
|
53
|
+
</Button>
|
|
54
|
+
</SheetTrigger>
|
|
55
|
+
<SheetContent side="right" className="flex w-[84vw] max-w-sm flex-col gap-0 p-0">
|
|
56
|
+
<SheetTitle className="sr-only">Menu</SheetTitle>
|
|
57
|
+
<SheetDescription className="sr-only">
|
|
58
|
+
Every page in the app, and your account.
|
|
59
|
+
</SheetDescription>
|
|
60
|
+
|
|
61
|
+
<div className="flex h-14 items-center border-b border-border/40 px-5">
|
|
62
|
+
<Link
|
|
63
|
+
onClick={() => setOpen(false)}
|
|
64
|
+
to="/"
|
|
65
|
+
className="flex items-center space-x-2"
|
|
66
|
+
aria-label={`${appConfig.name} home`}
|
|
67
|
+
>
|
|
68
|
+
<span className="inline-block size-2 rounded-full bg-brand" aria-hidden="true" />
|
|
69
|
+
<span className="text-lg font-medium tracking-tight">{appConfig.mark}</span>
|
|
70
|
+
</Link>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Menu">
|
|
74
|
+
<ul className="space-y-0.5">
|
|
75
|
+
{navItems.map((item) => {
|
|
76
|
+
const Icon = item.icon;
|
|
77
|
+
const active = isActiveSection(pathname, item);
|
|
78
|
+
// A section with sub pages lights its row only on its own landing
|
|
79
|
+
// page; deeper in, the sub page row carries the highlight.
|
|
80
|
+
const exact = pathname === item.href;
|
|
81
|
+
return (
|
|
82
|
+
<li key={item.href}>
|
|
83
|
+
<Link
|
|
84
|
+
onClick={() => setOpen(false)}
|
|
85
|
+
to={item.href}
|
|
86
|
+
className={cn(
|
|
87
|
+
ROW,
|
|
88
|
+
"font-medium",
|
|
89
|
+
active && !item.children?.length
|
|
90
|
+
? "bg-primary/10 text-primary"
|
|
91
|
+
: "text-foreground",
|
|
92
|
+
item.children && exact && "bg-primary/10 text-primary",
|
|
93
|
+
)}
|
|
94
|
+
aria-current={exact ? "page" : undefined}
|
|
95
|
+
>
|
|
96
|
+
<Icon className="h-5 w-5 flex-shrink-0" />
|
|
97
|
+
<span>{item.label}</span>
|
|
98
|
+
</Link>
|
|
99
|
+
{item.children ? (
|
|
100
|
+
<ul className="mb-1 mt-0.5 space-y-0.5" aria-label={`${item.label} pages`}>
|
|
101
|
+
{item.children.map((child) => {
|
|
102
|
+
const ChildIcon = child.icon;
|
|
103
|
+
const childActive = isActivePath(pathname, child.href);
|
|
104
|
+
return (
|
|
105
|
+
<li key={child.href}>
|
|
106
|
+
<Link
|
|
107
|
+
onClick={() => setOpen(false)}
|
|
108
|
+
to={child.href}
|
|
109
|
+
className={cn(
|
|
110
|
+
ROW,
|
|
111
|
+
"min-h-11 pl-11 text-[15px]",
|
|
112
|
+
childActive
|
|
113
|
+
? "bg-primary/10 font-medium text-primary"
|
|
114
|
+
: "text-muted-foreground",
|
|
115
|
+
)}
|
|
116
|
+
aria-current={childActive ? "page" : undefined}
|
|
117
|
+
>
|
|
118
|
+
<ChildIcon className="h-4 w-4 flex-shrink-0" />
|
|
119
|
+
<span>{child.label}</span>
|
|
120
|
+
</Link>
|
|
121
|
+
</li>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</ul>
|
|
125
|
+
) : null}
|
|
126
|
+
</li>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
</ul>
|
|
130
|
+
</nav>
|
|
131
|
+
|
|
132
|
+
<div className="border-t border-border/40 px-2 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
|
133
|
+
<Link
|
|
134
|
+
onClick={() => setOpen(false)}
|
|
135
|
+
to={profileHref}
|
|
136
|
+
className={cn(ROW, "min-h-14", pathname.startsWith(profileHref) && "bg-primary/10")}
|
|
137
|
+
aria-current={pathname.startsWith(profileHref) ? "page" : undefined}
|
|
138
|
+
>
|
|
139
|
+
<Avatar className="h-9 w-9 flex-shrink-0 border border-border/40">
|
|
140
|
+
<AvatarImage src={user?.avatar ?? undefined} alt={name} />
|
|
141
|
+
<AvatarFallback className="text-sm">{avatarInitials(name)}</AvatarFallback>
|
|
142
|
+
</Avatar>
|
|
143
|
+
<span className="flex min-w-0 flex-col">
|
|
144
|
+
<span className="truncate text-sm font-medium">{name}</span>
|
|
145
|
+
<span className="text-xs text-muted-foreground">Profile</span>
|
|
146
|
+
</span>
|
|
147
|
+
</Link>
|
|
148
|
+
<button type="button" onClick={onLogout} className={cn(ROW, "w-full text-destructive")}>
|
|
149
|
+
<LogOut className="h-5 w-5 flex-shrink-0" />
|
|
150
|
+
<span>Log out</span>
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
</SheetContent>
|
|
154
|
+
</Sheet>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import { useNav } from "../context/AppContext";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { isActiveSection } from "./nav";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The bottom tab bar below the desktop breakpoint: one tab per section, the
|
|
8
|
+
* fast path between them. Sub pages live in the menu sheet and on each
|
|
9
|
+
* section's landing page. Padded for the home indicator on notched phones.
|
|
10
|
+
*/
|
|
11
|
+
export function MobileTabBar({ pathname }: { pathname: string }) {
|
|
12
|
+
const navItems = useNav();
|
|
13
|
+
return (
|
|
14
|
+
<nav
|
|
15
|
+
className="fixed bottom-0 left-0 right-0 z-40 border-t border-border/40 bg-background pb-[env(safe-area-inset-bottom)] shadow-[0_-2px_10px_rgba(0,0,0,0.06)] lg:hidden"
|
|
16
|
+
aria-label="Mobile navigation"
|
|
17
|
+
>
|
|
18
|
+
<div className="flex h-16 items-center justify-around">
|
|
19
|
+
{navItems
|
|
20
|
+
.filter((item) => !item.desktopOnly)
|
|
21
|
+
.map((item) => {
|
|
22
|
+
const Icon = item.icon;
|
|
23
|
+
const active = isActiveSection(pathname, item);
|
|
24
|
+
return (
|
|
25
|
+
<Link
|
|
26
|
+
key={item.href}
|
|
27
|
+
to={item.href}
|
|
28
|
+
className={cn(
|
|
29
|
+
"flex h-full flex-1 flex-col items-center justify-center py-2 transition-colors duration-150 active:bg-accent",
|
|
30
|
+
active ? "bg-primary/5 text-primary" : "text-muted-foreground",
|
|
31
|
+
)}
|
|
32
|
+
aria-current={active ? "page" : undefined}
|
|
33
|
+
aria-label={item.label}
|
|
34
|
+
>
|
|
35
|
+
<Icon className={cn("mb-1 h-5 w-5", active && "text-primary")} />
|
|
36
|
+
<span className={cn("text-xs", active && "font-medium")}>{item.label}</span>
|
|
37
|
+
</Link>
|
|
38
|
+
);
|
|
39
|
+
})}
|
|
40
|
+
</div>
|
|
41
|
+
</nav>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AlertCircle } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { Card, CardContent } from "../ui/card";
|
|
4
|
+
|
|
5
|
+
interface PageErrorProps {
|
|
6
|
+
/** Error message to display */
|
|
7
|
+
message: string;
|
|
8
|
+
/** Optional title (defaults to "Error") */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** Additional className */
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Standardized error state for pages.
|
|
16
|
+
* Provides consistent error display across the application.
|
|
17
|
+
*/
|
|
18
|
+
export function PageError({ message, title = "Error", className }: PageErrorProps) {
|
|
19
|
+
return (
|
|
20
|
+
<Card className={cn("border-destructive/50", className)}>
|
|
21
|
+
<CardContent className="flex items-center gap-3 py-6">
|
|
22
|
+
<AlertCircle className="h-5 w-5 flex-shrink-0 text-destructive" />
|
|
23
|
+
<div>
|
|
24
|
+
<p className="font-medium text-destructive">{title}</p>
|
|
25
|
+
<p className="text-sm text-muted-foreground">{message}</p>
|
|
26
|
+
</div>
|
|
27
|
+
</CardContent>
|
|
28
|
+
</Card>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { cn } from "../lib/utils";
|
|
2
|
+
import { Card, CardContent, CardHeader } from "../ui/card";
|
|
3
|
+
|
|
4
|
+
interface PageSkeletonProps {
|
|
5
|
+
/** Show header skeleton with title and subtitle */
|
|
6
|
+
showHeader?: boolean;
|
|
7
|
+
/** Show action button skeleton in header */
|
|
8
|
+
showHeaderAction?: boolean;
|
|
9
|
+
/** Number of card skeletons to show */
|
|
10
|
+
cardCount?: number;
|
|
11
|
+
/** Layout for cards: single column, two columns, or grid */
|
|
12
|
+
cardLayout?: "single" | "two-column" | "grid";
|
|
13
|
+
/** Additional className */
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Standardized loading skeleton for pages.
|
|
19
|
+
* Provides consistent loading states across the application.
|
|
20
|
+
*/
|
|
21
|
+
export function PageSkeleton({
|
|
22
|
+
showHeader = true,
|
|
23
|
+
showHeaderAction = false,
|
|
24
|
+
cardCount = 3,
|
|
25
|
+
cardLayout = "single",
|
|
26
|
+
className,
|
|
27
|
+
}: PageSkeletonProps) {
|
|
28
|
+
const gridClasses = {
|
|
29
|
+
single: "space-y-6",
|
|
30
|
+
"two-column": "grid grid-cols-1 gap-6 lg:grid-cols-2",
|
|
31
|
+
grid: "grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className={cn("animate-pulse", className)}>
|
|
36
|
+
{showHeader && (
|
|
37
|
+
<div className="mb-6 sm:mb-8">
|
|
38
|
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
39
|
+
<div className="space-y-2">
|
|
40
|
+
<div className="h-8 w-48 rounded bg-muted sm:h-9" />
|
|
41
|
+
<div className="h-4 w-64 rounded bg-muted sm:h-5" />
|
|
42
|
+
</div>
|
|
43
|
+
{showHeaderAction && <div className="h-10 w-32 rounded bg-muted" />}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
|
|
48
|
+
<div className={gridClasses[cardLayout]}>
|
|
49
|
+
{Array.from({ length: cardCount }).map((_, i) => (
|
|
50
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items never reorder
|
|
51
|
+
<Card key={i}>
|
|
52
|
+
<CardHeader>
|
|
53
|
+
<div className="h-6 w-2/3 rounded bg-muted" />
|
|
54
|
+
<div className="h-4 w-1/2 rounded bg-muted" />
|
|
55
|
+
</CardHeader>
|
|
56
|
+
<CardContent className="space-y-3">
|
|
57
|
+
<div className="h-4 w-full rounded bg-muted" />
|
|
58
|
+
<div className="h-4 w-3/4 rounded bg-muted" />
|
|
59
|
+
</CardContent>
|
|
60
|
+
</Card>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
|
|
3
|
+
import { useNav } from "../context/AppContext";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A section's sub pages as cards on its landing page, read from the nav
|
|
7
|
+
* config so the rail, the menu sheet, and the landing page never disagree.
|
|
8
|
+
* Renders nothing for a section without children.
|
|
9
|
+
*/
|
|
10
|
+
export function SectionCards({ section }: { section: string }) {
|
|
11
|
+
const navItems = useNav();
|
|
12
|
+
const item = navItems.find((candidate) => candidate.href === section);
|
|
13
|
+
if (!item?.children?.length) return null;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<nav className="ap-sections" aria-label={`${item.label} pages`}>
|
|
17
|
+
{item.children.map((child) => {
|
|
18
|
+
const Icon = child.icon;
|
|
19
|
+
return (
|
|
20
|
+
<Link key={child.href} to={child.href} className="ap-section-card">
|
|
21
|
+
<span className="ic">
|
|
22
|
+
<Icon size={20} />
|
|
23
|
+
</span>
|
|
24
|
+
<span className="flex flex-col gap-1">
|
|
25
|
+
<span className="lb">{child.label}</span>
|
|
26
|
+
<span className="bl">{child.blurb}</span>
|
|
27
|
+
</span>
|
|
28
|
+
</Link>
|
|
29
|
+
);
|
|
30
|
+
})}
|
|
31
|
+
</nav>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import { ChevronDown, LogOut, User as UserIcon } from "lucide-react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useAppConfig } from "../context/AppContext";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import type { AppUser } from "../types";
|
|
7
|
+
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
|
8
|
+
import { Button } from "../ui/button";
|
|
9
|
+
import {
|
|
10
|
+
DropdownMenu,
|
|
11
|
+
DropdownMenuContent,
|
|
12
|
+
DropdownMenuItem,
|
|
13
|
+
DropdownMenuSeparator,
|
|
14
|
+
DropdownMenuTrigger,
|
|
15
|
+
} from "../ui/dropdown-menu";
|
|
16
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
|
17
|
+
import { avatarInitials, displayName } from "./identity";
|
|
18
|
+
|
|
19
|
+
interface UserMenuProps {
|
|
20
|
+
user: AppUser | null;
|
|
21
|
+
onLogout: () => void;
|
|
22
|
+
/** Icon only, for the collapsed desktop rail. */
|
|
23
|
+
collapsed?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The signed in user's block at the foot of the desktop rail: avatar and name
|
|
28
|
+
* opening a small menu with Profile and Log out. Collapsed, it is the avatar
|
|
29
|
+
* alone with the name in a tooltip.
|
|
30
|
+
*/
|
|
31
|
+
export function UserMenu({ user, onLogout, collapsed = false }: UserMenuProps) {
|
|
32
|
+
const profileHref = useAppConfig().profileHref ?? "/profile";
|
|
33
|
+
const [open, setOpen] = useState(false);
|
|
34
|
+
const name = displayName(user);
|
|
35
|
+
const initials = avatarInitials(name);
|
|
36
|
+
|
|
37
|
+
const avatar = (
|
|
38
|
+
<Avatar className="h-9 w-9 border border-border/40 flex-shrink-0">
|
|
39
|
+
<AvatarImage src={user?.avatar ?? undefined} alt={name} />
|
|
40
|
+
<AvatarFallback className="text-sm">{initials}</AvatarFallback>
|
|
41
|
+
</Avatar>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const trigger = collapsed ? (
|
|
45
|
+
<Button
|
|
46
|
+
variant="ghost"
|
|
47
|
+
size="sm"
|
|
48
|
+
className="w-full p-2 hover:bg-accent"
|
|
49
|
+
aria-label="User menu"
|
|
50
|
+
aria-expanded={open}
|
|
51
|
+
aria-haspopup="true"
|
|
52
|
+
>
|
|
53
|
+
{avatar}
|
|
54
|
+
</Button>
|
|
55
|
+
) : (
|
|
56
|
+
<Button
|
|
57
|
+
variant="ghost"
|
|
58
|
+
className="w-full justify-between px-3 py-2.5 h-auto hover:bg-accent overflow-hidden"
|
|
59
|
+
aria-label="User menu"
|
|
60
|
+
aria-expanded={open}
|
|
61
|
+
aria-haspopup="true"
|
|
62
|
+
>
|
|
63
|
+
<div className="flex items-center space-x-3 min-w-0">
|
|
64
|
+
{avatar}
|
|
65
|
+
<span className="text-sm font-medium truncate">{name}</span>
|
|
66
|
+
</div>
|
|
67
|
+
<ChevronDown
|
|
68
|
+
className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-180")}
|
|
69
|
+
/>
|
|
70
|
+
</Button>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
75
|
+
{collapsed ? (
|
|
76
|
+
<TooltipProvider>
|
|
77
|
+
<Tooltip delayDuration={0}>
|
|
78
|
+
<TooltipTrigger asChild>
|
|
79
|
+
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
80
|
+
</TooltipTrigger>
|
|
81
|
+
<TooltipContent side="right" className="font-medium text-sm">
|
|
82
|
+
<p>{name}</p>
|
|
83
|
+
</TooltipContent>
|
|
84
|
+
</Tooltip>
|
|
85
|
+
</TooltipProvider>
|
|
86
|
+
) : (
|
|
87
|
+
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
88
|
+
)}
|
|
89
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
90
|
+
<DropdownMenuItem asChild>
|
|
91
|
+
<Link to={profileHref} className="cursor-pointer">
|
|
92
|
+
<UserIcon className="mr-2 h-4 w-4" />
|
|
93
|
+
<span>Profile</span>
|
|
94
|
+
</Link>
|
|
95
|
+
</DropdownMenuItem>
|
|
96
|
+
<DropdownMenuSeparator />
|
|
97
|
+
<DropdownMenuItem onClick={onLogout} className="cursor-pointer text-destructive">
|
|
98
|
+
<LogOut className="mr-2 h-4 w-4" />
|
|
99
|
+
<span>Log out</span>
|
|
100
|
+
</DropdownMenuItem>
|
|
101
|
+
</DropdownMenuContent>
|
|
102
|
+
</DropdownMenu>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AppUser } from "../types";
|
|
2
|
+
|
|
3
|
+
/** The name the shell shows for the signed in user: "First Last", "First", or
|
|
4
|
+
* a neutral fallback while the profile is still loading. */
|
|
5
|
+
export function displayName(user: Pick<AppUser, "firstName" | "lastName"> | null | undefined) {
|
|
6
|
+
if (user?.firstName && user?.lastName) return `${user.firstName} ${user.lastName}`;
|
|
7
|
+
if (user?.firstName) return user.firstName;
|
|
8
|
+
return "User";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Up to two initials for the avatar fallback, "MF" for "Matthew Fornaciari". */
|
|
12
|
+
export function avatarInitials(name: string) {
|
|
13
|
+
return (
|
|
14
|
+
name
|
|
15
|
+
.split(" ")
|
|
16
|
+
.map((part) => part[0])
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.slice(0, 2)
|
|
19
|
+
.join("")
|
|
20
|
+
.toUpperCase() || "U"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { ActionButton } from "./ActionButton";
|
|
2
|
+
export { AppLayout } from "./AppLayout";
|
|
3
|
+
export {
|
|
4
|
+
AppPage,
|
|
5
|
+
AppPageToolbar,
|
|
6
|
+
type ToolbarAction,
|
|
7
|
+
type ToolbarFilter,
|
|
8
|
+
} from "./AppPage";
|
|
9
|
+
export { Breadcrumbs, type Crumb } from "./Breadcrumbs";
|
|
10
|
+
export { FormActionButtons } from "./FormActionButtons";
|
|
11
|
+
export {
|
|
12
|
+
activeSection,
|
|
13
|
+
isActivePath,
|
|
14
|
+
isActiveSection,
|
|
15
|
+
type NavChild,
|
|
16
|
+
type NavItem,
|
|
17
|
+
} from "./nav";
|
|
18
|
+
export { PageError } from "./PageError";
|
|
19
|
+
export { PageSkeleton } from "./PageSkeleton";
|
|
20
|
+
export { SectionCards } from "./SectionCards";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical navigation — the single source of truth for every nav
|
|
5
|
+
* surface the app shell renders: the desktop rail, the mobile tab bar, the
|
|
6
|
+
* mobile menu sheet, and a section's landing cards. Active state is derived
|
|
7
|
+
* from the current route against each item's `href`, so there is one place to
|
|
8
|
+
* add or reorder a destination. The items are the app's, handed to
|
|
9
|
+
* `AppProvider` as `nav`; this module holds the types and the active path
|
|
10
|
+
* helpers.
|
|
11
|
+
*/
|
|
12
|
+
export interface NavChild {
|
|
13
|
+
label: string;
|
|
14
|
+
href: string;
|
|
15
|
+
icon: ComponentType<{ className?: string; size?: number }>;
|
|
16
|
+
/** One line for the section landing card. */
|
|
17
|
+
blurb: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NavItem {
|
|
21
|
+
label: string;
|
|
22
|
+
href: string;
|
|
23
|
+
icon: ComponentType<{ className?: string; size?: number }>;
|
|
24
|
+
/** Sub destinations inside this section, rendered beneath it in the rail
|
|
25
|
+
* and the menu sheet, and as cards on the section's landing page. */
|
|
26
|
+
children?: NavChild[];
|
|
27
|
+
/** Show only in the mobile tab bar. */
|
|
28
|
+
mobileOnly?: boolean;
|
|
29
|
+
/** Show only in the desktop nav rail. */
|
|
30
|
+
desktopOnly?: boolean;
|
|
31
|
+
/** Other paths that belong to this section though they sit outside its
|
|
32
|
+
* `href`, such as a record's own page at a short top level path. */
|
|
33
|
+
matches?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when `pathname` is `href` or sits beneath it. The root ("/") only
|
|
37
|
+
* matches itself, since every path sits beneath it. */
|
|
38
|
+
export function isActivePath(pathname: string, href: string): boolean {
|
|
39
|
+
if (href === "/") return pathname === "/";
|
|
40
|
+
return pathname === href || pathname.startsWith(`${href}/`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True when `pathname` belongs to the section: its `href` or one of its
|
|
44
|
+
* `matches`, or anything beneath them. */
|
|
45
|
+
export function isActiveSection(pathname: string, item: NavItem): boolean {
|
|
46
|
+
return [item.href, ...(item.matches ?? [])].some((href) => isActivePath(pathname, href));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The section (top level nav item) the current path belongs to, if any. */
|
|
50
|
+
export function activeSection(pathname: string, items: NavItem[]): NavItem | undefined {
|
|
51
|
+
return items.find((item) => isActiveSection(pathname, item));
|
|
52
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Local calendar helpers shared across features. Everything here works on ISO
|
|
2
|
+
// date strings (YYYY-MM-DD) or local date parts, never on an ISO instant, so a
|
|
3
|
+
// date never shifts a day across time zones.
|
|
4
|
+
|
|
5
|
+
export const MONTHS = [
|
|
6
|
+
"Jan",
|
|
7
|
+
"Feb",
|
|
8
|
+
"Mar",
|
|
9
|
+
"Apr",
|
|
10
|
+
"May",
|
|
11
|
+
"Jun",
|
|
12
|
+
"Jul",
|
|
13
|
+
"Aug",
|
|
14
|
+
"Sep",
|
|
15
|
+
"Oct",
|
|
16
|
+
"Nov",
|
|
17
|
+
"Dec",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Today as a local YYYY-MM-DD string, for lexical comparison against other ISO
|
|
21
|
+
// dates and for asking the api about the day the user is standing in. `now` is
|
|
22
|
+
// injectable for tests.
|
|
23
|
+
export function todayIso(now: Date = new Date()): string {
|
|
24
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
25
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
26
|
+
return `${now.getFullYear()}-${month}-${day}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The IANA zone the browser is running in, which is the zone the user is
|
|
30
|
+
// standing in for anything read from the app. Undefined where the runtime
|
|
31
|
+
// cannot say, so the caller can leave the parameter off and let the api resolve
|
|
32
|
+
// the zone from the user's own record instead of asserting UTC.
|
|
33
|
+
export function localTimeZone(): string | undefined {
|
|
34
|
+
try {
|
|
35
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Format an ISO date without constructing a Date. "2026-06-08" -> "Jun 8, 2026".
|
|
42
|
+
// Returns the input untouched when it does not parse, so a bad value is visible
|
|
43
|
+
// rather than blank.
|
|
44
|
+
export function formatIsoDate(iso: string | null): string | null {
|
|
45
|
+
if (!iso) return null;
|
|
46
|
+
const [year, month, day] = iso.split("-").map(Number);
|
|
47
|
+
if (!year || !month || !day || month < 1 || month > 12) return iso;
|
|
48
|
+
return `${MONTHS[month - 1]} ${day}, ${year}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// "Jun 8" from an ISO date, no year. Null on a bad or absent input.
|
|
52
|
+
export function monthDay(iso: string | null): string | null {
|
|
53
|
+
if (!iso) return null;
|
|
54
|
+
const [year, month, day] = iso.split("-").map(Number);
|
|
55
|
+
if (!year || !month || !day || month < 1 || month > 12) return null;
|
|
56
|
+
return `${MONTHS[month - 1]} ${day}`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The environment variables an app on this shell reads, with runtime
|
|
5
|
+
* validation. The package never reads `import.meta.env` itself, since a
|
|
6
|
+
* bundler replaces it in the app's own code and not reliably inside a
|
|
7
|
+
* dependency: the app parses its own, once, and hands the api's base URL to
|
|
8
|
+
* the shell through its config.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // src/app/env.ts
|
|
12
|
+
* export const env = parseEnv(import.meta.env);
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export const envSchema = z.object({
|
|
16
|
+
VITE_API_BASE_URL: z.string().url().optional(),
|
|
17
|
+
VITE_APP_URL: z.string().url().optional(),
|
|
18
|
+
VITE_SENTRY_DSN: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type AppEnv = z.infer<typeof envSchema>;
|
|
22
|
+
|
|
23
|
+
/** Validate the environment. Throws, naming each bad variable, so the app fails at startup. */
|
|
24
|
+
export function parseEnv(source: Record<string, unknown>): AppEnv {
|
|
25
|
+
const result = envSchema.safeParse(source);
|
|
26
|
+
|
|
27
|
+
if (!result.success) {
|
|
28
|
+
const errors = result.error.flatten().fieldErrors;
|
|
29
|
+
const errorMessage = Object.entries(errors)
|
|
30
|
+
.map(([key, messages]) => ` ${key}: ${messages?.join(", ")}`)
|
|
31
|
+
.join("\n");
|
|
32
|
+
|
|
33
|
+
throw new Error(`Environment validation failed:\n${errorMessage}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return result.data;
|
|
37
|
+
}
|