@atelic-action/ui 0.17.2 → 1.0.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 +428 -44
- 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/Writeup.tsx +7 -7
- 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 +472 -399
- 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,268 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import { PanelRightClose, PanelRightOpen, Search, X } from "lucide-react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { useRail } from "../context/SidebarContext";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { OptionSelect } from "../ui/option-select";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AppPage — the shared scaffold every redesigned authenticated surface renders
|
|
10
|
+
* through. It owns the page chrome: a header (crumb · title · live
|
|
11
|
+
* dot · subtitle · aside), an optional summary block, a toolbar slot, the page
|
|
12
|
+
* content, an optional footer, and a canonical right rail.
|
|
13
|
+
*
|
|
14
|
+
* The rail is the new primitive. On desktop (lg+) it is a true full-height
|
|
15
|
+
* column mirroring the left nav rail, scrolling independently beside the main
|
|
16
|
+
* column. On mobile it stacks inline beneath the header. It is optional — pages
|
|
17
|
+
* pass it only when they have an at-a-glance summary.
|
|
18
|
+
*
|
|
19
|
+
* AppPage does NOT render navigation; the app shell (AppLayout) owns the nav
|
|
20
|
+
* rail / tab bar from the single nav config. Styling lives in globals.css under
|
|
21
|
+
* "AppPage scaffold (SHARED) — ap-*".
|
|
22
|
+
*/
|
|
23
|
+
interface AppPageProps {
|
|
24
|
+
/** Breadcrumb trail, rendered above the title (pass the Breadcrumbs component). */
|
|
25
|
+
crumb?: ReactNode;
|
|
26
|
+
/** Optional content opposite the crumb (a status badge, attribution). */
|
|
27
|
+
aside?: ReactNode;
|
|
28
|
+
/** Optional icon before the title. */
|
|
29
|
+
icon?: ReactNode;
|
|
30
|
+
/** Page title. */
|
|
31
|
+
title: ReactNode;
|
|
32
|
+
/** Show the live (accent) dot beside the title. */
|
|
33
|
+
live?: boolean;
|
|
34
|
+
/** Optional one-line description below the title. */
|
|
35
|
+
subtitle?: ReactNode;
|
|
36
|
+
/** Optional block between the header and the toolbar (mobile + desktop). */
|
|
37
|
+
summary?: ReactNode;
|
|
38
|
+
/** Toolbar slot — typically an AppPageToolbar. */
|
|
39
|
+
toolbar?: ReactNode;
|
|
40
|
+
/** Optional at-a-glance rail. Full-height column on desktop, inline on mobile. */
|
|
41
|
+
rail?: ReactNode;
|
|
42
|
+
/** Optional footer below the content. */
|
|
43
|
+
footer?: ReactNode;
|
|
44
|
+
/** Page content. */
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function PageHeader({
|
|
49
|
+
crumb,
|
|
50
|
+
aside,
|
|
51
|
+
icon,
|
|
52
|
+
title,
|
|
53
|
+
live,
|
|
54
|
+
subtitle,
|
|
55
|
+
railToggle,
|
|
56
|
+
}: Pick<AppPageProps, "crumb" | "aside" | "icon" | "title" | "live" | "subtitle"> & {
|
|
57
|
+
railToggle?: ReactNode;
|
|
58
|
+
}) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="ap-head">
|
|
61
|
+
{(crumb || aside || railToggle) && (
|
|
62
|
+
<div className="ap-crumb">
|
|
63
|
+
<div>{crumb}</div>
|
|
64
|
+
{(aside || railToggle) && (
|
|
65
|
+
<div className="ap-crumb-actions">
|
|
66
|
+
{aside}
|
|
67
|
+
{railToggle}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
<div className="ap-title">
|
|
73
|
+
{icon && <span className="glyph">{icon}</span>}
|
|
74
|
+
<h1>{title}</h1>
|
|
75
|
+
{live && <span className="live" aria-hidden="true" />}
|
|
76
|
+
</div>
|
|
77
|
+
{subtitle && <p className="ap-sub">{subtitle}</p>}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function AppPage({
|
|
83
|
+
crumb,
|
|
84
|
+
aside,
|
|
85
|
+
icon,
|
|
86
|
+
title,
|
|
87
|
+
live,
|
|
88
|
+
subtitle,
|
|
89
|
+
summary,
|
|
90
|
+
toolbar,
|
|
91
|
+
rail,
|
|
92
|
+
footer,
|
|
93
|
+
children,
|
|
94
|
+
}: AppPageProps) {
|
|
95
|
+
const { isRailCollapsed, toggleRail } = useRail();
|
|
96
|
+
|
|
97
|
+
// The collapse toggle lives in the header and only exists when the page has a
|
|
98
|
+
// rail. It is desktop-only (hidden below lg via CSS), since the rail stacks
|
|
99
|
+
// inline on mobile where collapsing is meaningless.
|
|
100
|
+
const railToggle = rail ? (
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
className="ap-rail-toggle"
|
|
104
|
+
onClick={toggleRail}
|
|
105
|
+
aria-label={isRailCollapsed ? "Expand rail" : "Collapse rail"}
|
|
106
|
+
aria-expanded={!isRailCollapsed}
|
|
107
|
+
aria-controls="ap-right-rail"
|
|
108
|
+
title={isRailCollapsed ? "Expand rail" : "Collapse rail"}
|
|
109
|
+
>
|
|
110
|
+
{isRailCollapsed ? <PanelRightOpen size={18} /> : <PanelRightClose size={18} />}
|
|
111
|
+
</button>
|
|
112
|
+
) : undefined;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="ap-page">
|
|
116
|
+
<div className="ap-col">
|
|
117
|
+
<div className="ap-scroll">
|
|
118
|
+
<PageHeader
|
|
119
|
+
crumb={crumb}
|
|
120
|
+
aside={aside}
|
|
121
|
+
icon={icon}
|
|
122
|
+
title={title}
|
|
123
|
+
live={live}
|
|
124
|
+
subtitle={subtitle}
|
|
125
|
+
railToggle={railToggle}
|
|
126
|
+
/>
|
|
127
|
+
{rail && <div className="ap-rail-inline">{rail}</div>}
|
|
128
|
+
{summary}
|
|
129
|
+
{toolbar}
|
|
130
|
+
{children}
|
|
131
|
+
{footer}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
{rail && (
|
|
135
|
+
<aside id="ap-right-rail" className={cn("ap-rail", isRailCollapsed && "is-collapsed")}>
|
|
136
|
+
{rail}
|
|
137
|
+
</aside>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ToolbarFilter {
|
|
144
|
+
value: string;
|
|
145
|
+
onChange: (value: string) => void;
|
|
146
|
+
options: { value: string; label: string; count?: number; disabled?: boolean }[];
|
|
147
|
+
ariaLabel?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ToolbarAction {
|
|
151
|
+
label?: string;
|
|
152
|
+
icon?: ReactNode;
|
|
153
|
+
/** Trailing muted timestamp (e.g. "9:38 am"). */
|
|
154
|
+
ago?: string;
|
|
155
|
+
onClick?: () => void;
|
|
156
|
+
variant?: "ghost" | "solid";
|
|
157
|
+
disabled?: boolean;
|
|
158
|
+
ariaLabel?: string;
|
|
159
|
+
/** Render the action as a router Link to this route instead of a button. */
|
|
160
|
+
to?: string;
|
|
161
|
+
/** Escape hatch: render this node verbatim in the action slot (e.g. a
|
|
162
|
+
* tooltip-wrapped control). Takes precedence over to / button. */
|
|
163
|
+
node?: ReactNode;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface AppPageToolbarProps {
|
|
167
|
+
search?: { value: string; onChange: (value: string) => void; placeholder?: string };
|
|
168
|
+
filters?: ToolbarFilter[];
|
|
169
|
+
actions?: ToolbarAction[];
|
|
170
|
+
/** Optional status line below the toolbar row (e.g. a sync-progress message). */
|
|
171
|
+
notice?: ReactNode;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* AppPageToolbar — search + filter dropdown(s) + action button(s), matching the
|
|
176
|
+
* prototype's calm `ap-tools` row. Filters are native selects carrying live
|
|
177
|
+
* counts; actions use the house buttons (ghost / solid).
|
|
178
|
+
*/
|
|
179
|
+
export function AppPageToolbar({
|
|
180
|
+
search,
|
|
181
|
+
filters = [],
|
|
182
|
+
actions = [],
|
|
183
|
+
notice,
|
|
184
|
+
}: AppPageToolbarProps) {
|
|
185
|
+
return (
|
|
186
|
+
<div className="ap-tools">
|
|
187
|
+
<div className="ap-toolrow">
|
|
188
|
+
{search && (
|
|
189
|
+
<label className="ap-search">
|
|
190
|
+
<Search size={17} aria-hidden="true" />
|
|
191
|
+
<input
|
|
192
|
+
type="search"
|
|
193
|
+
value={search.value}
|
|
194
|
+
onChange={(event) => search.onChange(event.target.value)}
|
|
195
|
+
placeholder={search.placeholder ?? "Search…"}
|
|
196
|
+
aria-label={search.placeholder ?? "Search"}
|
|
197
|
+
/>
|
|
198
|
+
{search.value && (
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
className="ap-search-clear"
|
|
202
|
+
onClick={() => search.onChange("")}
|
|
203
|
+
aria-label="Clear search"
|
|
204
|
+
>
|
|
205
|
+
<X size={15} />
|
|
206
|
+
</button>
|
|
207
|
+
)}
|
|
208
|
+
</label>
|
|
209
|
+
)}
|
|
210
|
+
{actions.length > 0 && (
|
|
211
|
+
<div className="ap-actions">
|
|
212
|
+
{actions.map((action, index) => {
|
|
213
|
+
const key = action.ariaLabel ?? action.label ?? action.to ?? index;
|
|
214
|
+
if (action.node) return <span key={key}>{action.node}</span>;
|
|
215
|
+
const className = `btn-${action.variant ?? "ghost"}`;
|
|
216
|
+
if (action.to) {
|
|
217
|
+
return (
|
|
218
|
+
<Link
|
|
219
|
+
key={key}
|
|
220
|
+
to={action.to}
|
|
221
|
+
disabled={action.disabled}
|
|
222
|
+
className={className}
|
|
223
|
+
aria-label={action.ariaLabel ?? action.label}
|
|
224
|
+
>
|
|
225
|
+
{action.icon}
|
|
226
|
+
{action.label}
|
|
227
|
+
</Link>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return (
|
|
231
|
+
<button
|
|
232
|
+
key={key}
|
|
233
|
+
type="button"
|
|
234
|
+
className={className}
|
|
235
|
+
onClick={action.onClick}
|
|
236
|
+
disabled={action.disabled}
|
|
237
|
+
aria-label={action.ariaLabel ?? action.label}
|
|
238
|
+
>
|
|
239
|
+
{action.icon}
|
|
240
|
+
{action.label}
|
|
241
|
+
{action.ago && <span className="ago">{action.ago}</span>}
|
|
242
|
+
</button>
|
|
243
|
+
);
|
|
244
|
+
})}
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
{notice && <div className="ap-toolnotice">{notice}</div>}
|
|
249
|
+
{filters.length > 0 && (
|
|
250
|
+
<div className="ap-selects">
|
|
251
|
+
{filters.map((filter) => (
|
|
252
|
+
<OptionSelect
|
|
253
|
+
key={filter.ariaLabel ?? filter.options[0]?.value}
|
|
254
|
+
value={filter.value}
|
|
255
|
+
onValueChange={filter.onChange}
|
|
256
|
+
ariaLabel={filter.ariaLabel ?? "Filter"}
|
|
257
|
+
className="w-full sm:w-44"
|
|
258
|
+
options={filter.options.map((option) => ({
|
|
259
|
+
value: option.value,
|
|
260
|
+
label: option.count != null ? `${option.label} · ${option.count}` : option.label,
|
|
261
|
+
}))}
|
|
262
|
+
/>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Link, type LinkProps } from "@tanstack/react-router";
|
|
2
|
+
import { ChevronLeft } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
export interface Crumb {
|
|
5
|
+
label: string;
|
|
6
|
+
/** Route to link to. Omit for the current (last) crumb. */
|
|
7
|
+
to?: LinkProps["to"];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The breadcrumb trail rendered in AppPage's `crumb` slot, above the title.
|
|
12
|
+
* Every page passes one: section and root pages a single self crumb ("Eat"),
|
|
13
|
+
* sub pages and detail pages a "Section / Page" trail.
|
|
14
|
+
*
|
|
15
|
+
* Two renderings, one per breakpoint, so the trail reads on a phone:
|
|
16
|
+
*
|
|
17
|
+
* - Below lg, a trail with a parent collapses to one thumb sized back link to
|
|
18
|
+
* the nearest linked ancestor ("‹ Recipes"). The current page is the title
|
|
19
|
+
* directly beneath, so repeating it in a tiny wrapped line bought nothing,
|
|
20
|
+
* and a root page's single self crumb shows nothing at all there.
|
|
21
|
+
* - At lg and up, the full "Eat / Recipes / Name" trail, the current page
|
|
22
|
+
* truncated rather than wrapped.
|
|
23
|
+
*/
|
|
24
|
+
export function Breadcrumbs({ items }: { items: Crumb[] }) {
|
|
25
|
+
if (items.length === 0) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The nearest ancestor with a route; the back link on small screens.
|
|
30
|
+
const parent = [...items.slice(0, -1)].reverse().find((crumb) => crumb.to);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<nav aria-label="Breadcrumb" className="crumbs">
|
|
34
|
+
{parent && (
|
|
35
|
+
<Link to={parent.to} className="crumbs-back" aria-label={`Back to ${parent.label}`}>
|
|
36
|
+
<ChevronLeft size={18} aria-hidden="true" />
|
|
37
|
+
<span>{parent.label}</span>
|
|
38
|
+
</Link>
|
|
39
|
+
)}
|
|
40
|
+
<ol className="crumbs-trail">
|
|
41
|
+
{items.map((crumb, index) => (
|
|
42
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a label may repeat in a trail, and a trail never reorders
|
|
43
|
+
<li key={`${crumb.label}-${index}`}>
|
|
44
|
+
{index > 0 && <span aria-hidden="true">/</span>}
|
|
45
|
+
{crumb.to ? (
|
|
46
|
+
<Link to={crumb.to} className="hover:underline">
|
|
47
|
+
{crumb.label}
|
|
48
|
+
</Link>
|
|
49
|
+
) : (
|
|
50
|
+
<span aria-current="page">{crumb.label}</span>
|
|
51
|
+
)}
|
|
52
|
+
</li>
|
|
53
|
+
))}
|
|
54
|
+
</ol>
|
|
55
|
+
</nav>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|
3
|
+
import { useAppConfig, useNav } from "../context/AppContext";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import type { AppUser } from "../types";
|
|
6
|
+
import { Button } from "../ui/button";
|
|
7
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
|
8
|
+
import { isActivePath, isActiveSection, type NavItem } from "./nav";
|
|
9
|
+
import { UserMenu } from "./UserMenu";
|
|
10
|
+
|
|
11
|
+
export const RAIL_WIDTH = 256;
|
|
12
|
+
export const RAIL_WIDTH_COLLAPSED = 80;
|
|
13
|
+
|
|
14
|
+
interface DesktopRailProps {
|
|
15
|
+
pathname: string;
|
|
16
|
+
collapsed: boolean;
|
|
17
|
+
onToggle: () => void;
|
|
18
|
+
user: AppUser | null;
|
|
19
|
+
onLogout: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function RailLink({
|
|
23
|
+
item,
|
|
24
|
+
active,
|
|
25
|
+
collapsed,
|
|
26
|
+
}: {
|
|
27
|
+
item: NavItem;
|
|
28
|
+
active: boolean;
|
|
29
|
+
collapsed: boolean;
|
|
30
|
+
}) {
|
|
31
|
+
const Icon = item.icon;
|
|
32
|
+
const className = cn(
|
|
33
|
+
"flex items-center rounded-lg px-3 py-2.5 transition-colors duration-150 hover:bg-accent",
|
|
34
|
+
collapsed ? "justify-center" : "space-x-3 overflow-hidden",
|
|
35
|
+
active && "bg-primary/10 text-primary font-medium",
|
|
36
|
+
);
|
|
37
|
+
const link = (
|
|
38
|
+
<Link to={item.href} className={className} aria-current={active ? "page" : undefined}>
|
|
39
|
+
<Icon className="h-5 w-5 flex-shrink-0" />
|
|
40
|
+
{!collapsed && <span>{item.label}</span>}
|
|
41
|
+
</Link>
|
|
42
|
+
);
|
|
43
|
+
if (!collapsed) return link;
|
|
44
|
+
return (
|
|
45
|
+
<TooltipProvider>
|
|
46
|
+
<Tooltip delayDuration={0}>
|
|
47
|
+
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
|
48
|
+
<TooltipContent side="right" className="font-medium text-sm">
|
|
49
|
+
<p>{item.label}</p>
|
|
50
|
+
</TooltipContent>
|
|
51
|
+
</Tooltip>
|
|
52
|
+
</TooltipProvider>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The desktop navigation rail (lg and up): brand, the sections, the active
|
|
58
|
+
* section's sub pages nested beneath it, and the user block at the foot.
|
|
59
|
+
* Collapsed, it is icons with tooltips and the sub pages fold away; the
|
|
60
|
+
* section landing page carries them as cards, so nothing is lost.
|
|
61
|
+
*/
|
|
62
|
+
export function DesktopRail({ pathname, collapsed, onToggle, user, onLogout }: DesktopRailProps) {
|
|
63
|
+
const appConfig = useAppConfig();
|
|
64
|
+
const navItems = useNav();
|
|
65
|
+
return (
|
|
66
|
+
<aside
|
|
67
|
+
aria-label="Main navigation"
|
|
68
|
+
style={{ width: collapsed ? RAIL_WIDTH_COLLAPSED : RAIL_WIDTH }}
|
|
69
|
+
className={cn(
|
|
70
|
+
"fixed left-0 top-0 z-40 h-screen bg-background border-r border-border/40",
|
|
71
|
+
"hidden lg:block transition-[width] duration-200 ease-out",
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
<div className="flex h-full flex-col">
|
|
75
|
+
<div className="border-b border-border/40">
|
|
76
|
+
<div
|
|
77
|
+
className={cn(
|
|
78
|
+
"flex items-center px-6 py-5",
|
|
79
|
+
collapsed ? "flex-col space-y-3" : "justify-between",
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
<Link
|
|
83
|
+
to="/"
|
|
84
|
+
className={cn(
|
|
85
|
+
"flex items-center overflow-hidden transition-opacity hover:opacity-80",
|
|
86
|
+
collapsed ? "justify-center" : "space-x-2",
|
|
87
|
+
)}
|
|
88
|
+
aria-label={`${appConfig.name} home`}
|
|
89
|
+
>
|
|
90
|
+
<span className="inline-block size-2 rounded-full bg-brand" aria-hidden="true" />
|
|
91
|
+
{!collapsed && (
|
|
92
|
+
<span className="text-lg font-medium tracking-tight">{appConfig.mark}</span>
|
|
93
|
+
)}
|
|
94
|
+
</Link>
|
|
95
|
+
|
|
96
|
+
<TooltipProvider>
|
|
97
|
+
<Tooltip delayDuration={0}>
|
|
98
|
+
<TooltipTrigger asChild>
|
|
99
|
+
<Button
|
|
100
|
+
variant="ghost"
|
|
101
|
+
size="icon"
|
|
102
|
+
onClick={onToggle}
|
|
103
|
+
className="flex-shrink-0 rounded-lg p-2 hover:bg-accent"
|
|
104
|
+
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
105
|
+
>
|
|
106
|
+
{collapsed ? (
|
|
107
|
+
<PanelLeftOpen className="h-5 w-5" />
|
|
108
|
+
) : (
|
|
109
|
+
<PanelLeftClose className="h-5 w-5" />
|
|
110
|
+
)}
|
|
111
|
+
</Button>
|
|
112
|
+
</TooltipTrigger>
|
|
113
|
+
<TooltipContent
|
|
114
|
+
side={collapsed ? "right" : "bottom"}
|
|
115
|
+
className="font-medium text-sm"
|
|
116
|
+
>
|
|
117
|
+
<p>{collapsed ? "Expand sidebar" : "Collapse sidebar"}</p>
|
|
118
|
+
</TooltipContent>
|
|
119
|
+
</Tooltip>
|
|
120
|
+
</TooltipProvider>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<nav className="flex-1 space-y-1 px-4 py-6" aria-label="Primary navigation">
|
|
125
|
+
{navItems
|
|
126
|
+
.filter((item) => !item.mobileOnly)
|
|
127
|
+
.map((item) => {
|
|
128
|
+
const active = isActiveSection(pathname, item);
|
|
129
|
+
const showChildren = active && !collapsed && item.children?.length;
|
|
130
|
+
return (
|
|
131
|
+
<div key={item.href}>
|
|
132
|
+
<RailLink item={item} active={active} collapsed={collapsed} />
|
|
133
|
+
{showChildren ? (
|
|
134
|
+
<ul className="mt-1 space-y-0.5 pb-1" aria-label={`${item.label} pages`}>
|
|
135
|
+
{item.children?.map((child) => {
|
|
136
|
+
const childActive = isActivePath(pathname, child.href);
|
|
137
|
+
const ChildIcon = child.icon;
|
|
138
|
+
return (
|
|
139
|
+
<li key={child.href}>
|
|
140
|
+
<Link
|
|
141
|
+
to={child.href}
|
|
142
|
+
className={cn(
|
|
143
|
+
"flex items-center space-x-2.5 rounded-lg py-2 pl-11 pr-3 text-sm",
|
|
144
|
+
"text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground",
|
|
145
|
+
childActive && "bg-primary/10 text-primary font-medium",
|
|
146
|
+
)}
|
|
147
|
+
aria-current={childActive ? "page" : undefined}
|
|
148
|
+
>
|
|
149
|
+
<ChildIcon className="h-4 w-4 flex-shrink-0" />
|
|
150
|
+
<span>{child.label}</span>
|
|
151
|
+
</Link>
|
|
152
|
+
</li>
|
|
153
|
+
);
|
|
154
|
+
})}
|
|
155
|
+
</ul>
|
|
156
|
+
) : null}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
</nav>
|
|
161
|
+
|
|
162
|
+
<div className="border-t border-border/40 p-3">
|
|
163
|
+
<UserMenu user={user} onLogout={onLogout} collapsed={collapsed} />
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</aside>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Loader2 } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { Button } from "../ui/button";
|
|
4
|
+
|
|
5
|
+
interface FormActionButtonsProps {
|
|
6
|
+
/** Submit handler (if not using form submission) */
|
|
7
|
+
onSave?: () => void;
|
|
8
|
+
/** Cancel handler */
|
|
9
|
+
onCancel: () => void;
|
|
10
|
+
/** Loading/submitting state */
|
|
11
|
+
isLoading?: boolean;
|
|
12
|
+
/** Disabled state */
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
/** Save button text */
|
|
15
|
+
saveText?: string;
|
|
16
|
+
/** Cancel button text */
|
|
17
|
+
cancelText?: string;
|
|
18
|
+
/** Additional className for the container */
|
|
19
|
+
className?: string;
|
|
20
|
+
/** Whether to show the cancel button */
|
|
21
|
+
showCancel?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Standardized form action buttons for save/cancel operations.
|
|
26
|
+
* Responsive layout that stacks on mobile.
|
|
27
|
+
*/
|
|
28
|
+
export function FormActionButtons({
|
|
29
|
+
onSave,
|
|
30
|
+
onCancel,
|
|
31
|
+
isLoading = false,
|
|
32
|
+
disabled = false,
|
|
33
|
+
saveText = "Save",
|
|
34
|
+
cancelText = "Cancel",
|
|
35
|
+
className,
|
|
36
|
+
showCancel = true,
|
|
37
|
+
}: FormActionButtonsProps) {
|
|
38
|
+
return (
|
|
39
|
+
<div className={cn("flex flex-col-reverse gap-3 sm:flex-row sm:justify-end", className)}>
|
|
40
|
+
{showCancel && (
|
|
41
|
+
<Button
|
|
42
|
+
type="button"
|
|
43
|
+
variant="outline"
|
|
44
|
+
onClick={onCancel}
|
|
45
|
+
disabled={isLoading}
|
|
46
|
+
className="w-full sm:w-auto"
|
|
47
|
+
>
|
|
48
|
+
{cancelText}
|
|
49
|
+
</Button>
|
|
50
|
+
)}
|
|
51
|
+
<Button
|
|
52
|
+
type={onSave ? "button" : "submit"}
|
|
53
|
+
variant="default"
|
|
54
|
+
disabled={disabled || isLoading}
|
|
55
|
+
onClick={onSave}
|
|
56
|
+
className="w-full sm:w-auto"
|
|
57
|
+
>
|
|
58
|
+
{isLoading ? (
|
|
59
|
+
<>
|
|
60
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
61
|
+
Saving...
|
|
62
|
+
</>
|
|
63
|
+
) : (
|
|
64
|
+
saveText
|
|
65
|
+
)}
|
|
66
|
+
</Button>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|