@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.
Files changed (188) hide show
  1. package/README.md +432 -45
  2. package/bin/atelic-check-base-path.ts +5 -0
  3. package/bin/atelic-clean-sitemap.ts +5 -0
  4. package/bin/atelic-contact-canary.ts +5 -0
  5. package/bin/atelic-print-sheets.sh +1 -1
  6. package/bin/atelic-seal-document.mjs +1 -1
  7. package/bin/atelic-serve-static.ts +1 -1
  8. package/dist/gate/index.d.ts +1 -1
  9. package/dist/gate/index.js +1 -1
  10. package/dist/gate/middleware.d.ts +4 -1
  11. package/dist/gate/middleware.js +14 -5
  12. package/dist/gate/wall.d.ts +25 -0
  13. package/dist/gate/wall.js +41 -2
  14. package/dist/routing/basePath.d.ts +24 -0
  15. package/dist/routing/basePath.js +54 -0
  16. package/dist/vite/index.d.ts +3 -2
  17. package/dist/vite/index.js +3 -2
  18. package/dist/vite/marketing-config.d.ts +50 -0
  19. package/dist/vite/marketing-config.js +83 -0
  20. package/package.json +164 -5
  21. package/src/app/AppProvider.tsx +41 -0
  22. package/src/app/AuthGuard.tsx +27 -0
  23. package/src/app/CardErrorBoundary.tsx +39 -0
  24. package/src/app/ErrorFallback.tsx +39 -0
  25. package/src/app/GoogleGlyph.tsx +22 -0
  26. package/src/app/config.ts +54 -0
  27. package/src/app/configure.ts +18 -0
  28. package/src/app/context/AppContext.tsx +52 -0
  29. package/src/app/context/SidebarContext.tsx +124 -0
  30. package/src/app/context/UserContext.tsx +139 -0
  31. package/src/app/hooks/useDebounce.ts +12 -0
  32. package/src/app/hooks/useIsDesktop.ts +31 -0
  33. package/src/app/hooks/useWakeLock.ts +49 -0
  34. package/src/app/index.ts +64 -0
  35. package/src/app/layout/ActionButton.tsx +67 -0
  36. package/src/app/layout/AppLayout.tsx +110 -0
  37. package/src/app/layout/AppPage.tsx +268 -0
  38. package/src/app/layout/Breadcrumbs.tsx +57 -0
  39. package/src/app/layout/DesktopRail.tsx +168 -0
  40. package/src/app/layout/FormActionButtons.tsx +69 -0
  41. package/src/app/layout/MobileMenu.tsx +156 -0
  42. package/src/app/layout/MobileTabBar.tsx +43 -0
  43. package/src/app/layout/PageError.tsx +30 -0
  44. package/src/app/layout/PageSkeleton.tsx +65 -0
  45. package/src/app/layout/SectionCards.tsx +33 -0
  46. package/src/app/layout/UserMenu.tsx +104 -0
  47. package/src/app/layout/identity.ts +22 -0
  48. package/src/app/layout/index.ts +20 -0
  49. package/src/app/layout/nav.ts +52 -0
  50. package/src/app/lib/dates.ts +57 -0
  51. package/src/app/lib/env.ts +37 -0
  52. package/src/app/lib/format.ts +61 -0
  53. package/src/app/lib/http.ts +205 -0
  54. package/src/app/lib/lastAuth.ts +45 -0
  55. package/src/app/lib/oauth.ts +32 -0
  56. package/src/app/lib/publicRoutes.ts +33 -0
  57. package/src/app/lib/queryClient.ts +38 -0
  58. package/src/app/lib/search.ts +17 -0
  59. package/src/app/lib/session.ts +36 -0
  60. package/src/app/lib/tokenRefresh.ts +137 -0
  61. package/src/app/lib/transformKeys.ts +60 -0
  62. package/src/app/lib/units.ts +228 -0
  63. package/src/app/lib/unwrap.ts +48 -0
  64. package/src/app/lib/utils.ts +10 -0
  65. package/src/app/stores/authStore.ts +80 -0
  66. package/src/app/stores/storage.ts +40 -0
  67. package/src/app/stores/unitsStore.ts +54 -0
  68. package/src/app/types/index.ts +72 -0
  69. package/src/app/types/query.ts +11 -0
  70. package/src/app/ui/accordion.tsx +54 -0
  71. package/src/app/ui/alert-dialog.tsx +114 -0
  72. package/src/app/ui/alert.tsx +49 -0
  73. package/src/app/ui/avatar.tsx +45 -0
  74. package/src/app/ui/badge-list.tsx +51 -0
  75. package/src/app/ui/badge.tsx +33 -0
  76. package/src/app/ui/button.tsx +49 -0
  77. package/src/app/ui/callout.tsx +22 -0
  78. package/src/app/ui/card.tsx +55 -0
  79. package/src/app/ui/checkbox.tsx +26 -0
  80. package/src/app/ui/clear-button.tsx +42 -0
  81. package/src/app/ui/command.tsx +140 -0
  82. package/src/app/ui/country-dropdown.tsx +184 -0
  83. package/src/app/ui/dialog.tsx +104 -0
  84. package/src/app/ui/dropdown-menu.tsx +185 -0
  85. package/src/app/ui/form.tsx +172 -0
  86. package/src/app/ui/index.ts +36 -0
  87. package/src/app/ui/input.tsx +22 -0
  88. package/src/app/ui/label.tsx +19 -0
  89. package/src/app/ui/option-select.tsx +56 -0
  90. package/src/app/ui/pagination.tsx +90 -0
  91. package/src/app/ui/popover.tsx +29 -0
  92. package/src/app/ui/progress.tsx +25 -0
  93. package/src/app/ui/search-input.tsx +63 -0
  94. package/src/app/ui/select.tsx +151 -0
  95. package/src/app/ui/separator.tsx +24 -0
  96. package/src/app/ui/sheet.tsx +119 -0
  97. package/src/app/ui/slider.tsx +23 -0
  98. package/src/app/ui/switch.tsx +27 -0
  99. package/src/app/ui/textarea.tsx +21 -0
  100. package/src/app/ui/tooltip.tsx +32 -0
  101. package/src/artifact/ArtifactShell.tsx +2 -2
  102. package/src/artifact/Gate.tsx +2 -2
  103. package/src/artifact/Note.tsx +9 -39
  104. package/src/artifact/Paragraph.tsx +65 -0
  105. package/src/artifact/Read.tsx +406 -0
  106. package/src/artifact/Writeup.tsx +7 -7
  107. package/src/artifact/index.ts +1 -0
  108. package/src/chrome/SiteShell.tsx +141 -0
  109. package/src/chrome/index.ts +2 -0
  110. package/src/components/Disclosure.tsx +80 -14
  111. package/src/components/GateLogin.tsx +1 -1
  112. package/src/components/Watermark.tsx +1 -1
  113. package/src/components/index.ts +6 -2
  114. package/src/gate/index.ts +1 -1
  115. package/src/gate/middleware.ts +17 -6
  116. package/src/gate/wall.ts +60 -2
  117. package/src/lib/basePathAudit.ts +96 -0
  118. package/src/lib/displayDate.ts +25 -0
  119. package/src/lib/honeypot.ts +11 -0
  120. package/src/lib/hours.ts +28 -0
  121. package/src/lib/sitemap.ts +34 -0
  122. package/src/lib/trackEvent.ts +26 -0
  123. package/src/lib/useOpenHeight.ts +28 -0
  124. package/src/primitives/Button.tsx +3 -1
  125. package/src/primitives/Chip.tsx +3 -1
  126. package/src/primitives/ImageFrame.tsx +48 -0
  127. package/src/primitives/ProseBlocks.tsx +67 -0
  128. package/src/primitives/QuoteCard.tsx +20 -0
  129. package/src/primitives/StarRow.tsx +37 -0
  130. package/src/primitives/TickList.tsx +24 -0
  131. package/src/primitives/index.ts +5 -0
  132. package/src/routing/BasePathProvider.tsx +47 -0
  133. package/src/routing/basePath.ts +57 -0
  134. package/src/routing/index.ts +8 -0
  135. package/src/scripts/check-base-path.ts +130 -0
  136. package/src/scripts/clean-sitemap.ts +27 -0
  137. package/src/scripts/contact-canary.ts +98 -0
  138. package/src/scripts/serve-static.ts +29 -3
  139. package/src/sections/BlogIndex.tsx +57 -0
  140. package/src/sections/BlogPost.tsx +65 -0
  141. package/src/sections/CTABanner.tsx +93 -0
  142. package/src/sections/CTASection.tsx +60 -0
  143. package/src/sections/ComparisonTable.tsx +114 -0
  144. package/src/sections/ContactForm.tsx +163 -0
  145. package/src/sections/ContactInfoCard.tsx +92 -0
  146. package/src/sections/CredentialList.tsx +28 -0
  147. package/src/sections/DeviceShowcase.tsx +39 -0
  148. package/src/sections/ExpandList.tsx +58 -0
  149. package/src/sections/FAQBrowser.tsx +283 -0
  150. package/src/sections/FeatureDetail.tsx +60 -0
  151. package/src/sections/FeatureGrid.tsx +83 -0
  152. package/src/sections/Gallery.tsx +84 -0
  153. package/src/sections/Hero.tsx +110 -0
  154. package/src/sections/HoursTable.tsx +23 -0
  155. package/src/sections/JumpRail.tsx +35 -0
  156. package/src/sections/LegalPage.tsx +43 -0
  157. package/src/sections/LocationSection.tsx +66 -0
  158. package/src/sections/Locations.tsx +71 -0
  159. package/src/sections/LogoMarquee.tsx +27 -0
  160. package/src/sections/MapEmbed.tsx +75 -0
  161. package/src/sections/MediaBand.tsx +20 -0
  162. package/src/sections/MenuList.tsx +44 -0
  163. package/src/sections/PageHero.tsx +25 -0
  164. package/src/sections/PressQuotes.tsx +51 -0
  165. package/src/sections/PricingBand.tsx +43 -0
  166. package/src/sections/PricingCard.tsx +36 -0
  167. package/src/sections/ProcessSteps.tsx +45 -0
  168. package/src/sections/ProfileCards.tsx +73 -0
  169. package/src/sections/RevealSlider.tsx +156 -0
  170. package/src/sections/ReviewCarousel.tsx +223 -0
  171. package/src/sections/ReviewWall.tsx +220 -0
  172. package/src/sections/SocialStrip.tsx +83 -0
  173. package/src/sections/SplitSection.tsx +56 -0
  174. package/src/sections/index.ts +76 -0
  175. package/src/sections/siteConfig.ts +201 -0
  176. package/src/styles/app.css +673 -0
  177. package/src/styles/artifact.css +759 -401
  178. package/src/styles/base.css +49 -47
  179. package/src/styles/chrome.css +71 -71
  180. package/src/styles/components.css +150 -140
  181. package/src/styles/gate.css +29 -29
  182. package/src/styles/primitives.css +190 -25
  183. package/src/styles/sections.css +2652 -0
  184. package/src/styles/star-rating.css +2 -2
  185. package/src/vite/index.ts +9 -2
  186. package/src/vite/marketing-config.ts +128 -0
  187. package/src/components/Collapsible.tsx +0 -43
  188. /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
+ }