@devxcrew/react-ui 0.0.0-stage → 0.1.7

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 (326) hide show
  1. package/README.md +85 -2
  2. package/package.json +105 -4
  3. package/src/blocks/agent-chat-workspace/agent-chat-workspace.tsx +162 -0
  4. package/src/blocks/agent-chat-workspace/index.ts +2 -0
  5. package/src/blocks/agent-provider-settings/agent-provider-settings.tsx +183 -0
  6. package/src/blocks/agent-provider-settings/index.ts +2 -0
  7. package/src/blocks/agent-task-workspace/agent-task-workspace.tsx +48 -0
  8. package/src/blocks/agent-task-workspace/index.ts +2 -0
  9. package/src/blocks/analysis-context/analysis-context.tsx +6 -0
  10. package/src/blocks/analysis-context/index.ts +1 -0
  11. package/src/blocks/app-header/app-header.tsx +159 -0
  12. package/src/blocks/app-header/index.ts +2 -0
  13. package/src/blocks/archived-chat-workspace/archived-chat-workspace.tsx +29 -0
  14. package/src/blocks/archived-chat-workspace/index.ts +2 -0
  15. package/src/blocks/auth/README.md +32 -0
  16. package/src/blocks/auth/admin-login-page.tsx +15 -0
  17. package/src/blocks/auth/admin-password-forgot-page.tsx +5 -0
  18. package/src/blocks/auth/auth-page-layout.tsx +90 -0
  19. package/src/blocks/auth/client-login-page.tsx +20 -0
  20. package/src/blocks/auth/client-password-forgot-page.tsx +5 -0
  21. package/src/blocks/auth/identity-management-desk.tsx +530 -0
  22. package/src/blocks/auth/index.ts +12 -0
  23. package/src/blocks/auth/login-page.tsx +206 -0
  24. package/src/blocks/auth/password-forgot-page.tsx +60 -0
  25. package/src/blocks/auth/portal-pages.tsx +32 -0
  26. package/src/blocks/auth/privileged-desk.tsx +55 -0
  27. package/src/blocks/auth/register-page.tsx +117 -0
  28. package/src/blocks/auth/session-boundary.tsx +296 -0
  29. package/src/blocks/auth/super-admin-login-page.tsx +15 -0
  30. package/src/blocks/auth/super-admin-password-forgot-page.tsx +5 -0
  31. package/src/blocks/blog/blog-card.tsx +156 -0
  32. package/src/blocks/blog/blog-reader.tsx +175 -0
  33. package/src/blocks/blog/blog-types.ts +47 -0
  34. package/src/blocks/blog/index.ts +3 -0
  35. package/src/blocks/chat-composer/chat-composer.tsx +140 -0
  36. package/src/blocks/chat-composer/index.ts +1 -0
  37. package/src/blocks/chat-history/chat-history.tsx +59 -0
  38. package/src/blocks/chat-history/index.ts +1 -0
  39. package/src/blocks/chat-response-progress/chat-response-progress.tsx +34 -0
  40. package/src/blocks/chat-response-progress/index.ts +2 -0
  41. package/src/blocks/chat-runtime-controls/chat-runtime-controls.tsx +56 -0
  42. package/src/blocks/chat-runtime-controls/index.ts +2 -0
  43. package/src/blocks/chat-runtime-trace/chat-runtime-trace.tsx +71 -0
  44. package/src/blocks/chat-runtime-trace/index.ts +2 -0
  45. package/src/blocks/codex-connection-settings/codex-connection-settings.tsx +42 -0
  46. package/src/blocks/codex-connection-settings/index.ts +1 -0
  47. package/src/blocks/dropzone/dropzone-file-item.tsx +113 -0
  48. package/src/blocks/dropzone/dropzone-types.ts +32 -0
  49. package/src/blocks/dropzone/dropzone.tsx +216 -0
  50. package/src/blocks/dropzone/index.ts +3 -0
  51. package/src/blocks/ecommerce/cart/cart-drawer.tsx +228 -0
  52. package/src/blocks/ecommerce/cart/cart-types.ts +27 -0
  53. package/src/blocks/ecommerce/cart/index.ts +2 -0
  54. package/src/blocks/ecommerce/categories/categories-types.ts +23 -0
  55. package/src/blocks/ecommerce/categories/category-showcase.tsx +125 -0
  56. package/src/blocks/ecommerce/categories/index.ts +2 -0
  57. package/src/blocks/ecommerce/checkout/checkout-types.ts +48 -0
  58. package/src/blocks/ecommerce/checkout/checkout-wizard.tsx +468 -0
  59. package/src/blocks/ecommerce/checkout/index.ts +2 -0
  60. package/src/blocks/ecommerce/comparison/comparison-types.ts +28 -0
  61. package/src/blocks/ecommerce/comparison/index.ts +2 -0
  62. package/src/blocks/ecommerce/comparison/product-comparison.tsx +174 -0
  63. package/src/blocks/ecommerce/coupon-wallet/coupon-wallet-types.ts +21 -0
  64. package/src/blocks/ecommerce/coupon-wallet/coupon-wallet.tsx +154 -0
  65. package/src/blocks/ecommerce/coupon-wallet/index.ts +2 -0
  66. package/src/blocks/ecommerce/delivery-tracker/delivery-tracker-types.ts +20 -0
  67. package/src/blocks/ecommerce/delivery-tracker/delivery-tracker.tsx +169 -0
  68. package/src/blocks/ecommerce/delivery-tracker/index.ts +2 -0
  69. package/src/blocks/ecommerce/index.ts +12 -0
  70. package/src/blocks/ecommerce/payment-methods/index.ts +2 -0
  71. package/src/blocks/ecommerce/payment-methods/payment-method-selector.tsx +224 -0
  72. package/src/blocks/ecommerce/payment-methods/payment-methods-types.ts +21 -0
  73. package/src/blocks/ecommerce/price-history/index.ts +2 -0
  74. package/src/blocks/ecommerce/price-history/price-history-chart.tsx +217 -0
  75. package/src/blocks/ecommerce/price-history/price-history-types.ts +16 -0
  76. package/src/blocks/ecommerce/reviews/index.ts +2 -0
  77. package/src/blocks/ecommerce/reviews/reviews-section.tsx +309 -0
  78. package/src/blocks/ecommerce/reviews/reviews-types.ts +26 -0
  79. package/src/blocks/ecommerce/wishlist/index.ts +2 -0
  80. package/src/blocks/ecommerce/wishlist/wishlist-grid.tsx +149 -0
  81. package/src/blocks/ecommerce/wishlist/wishlist-types.ts +19 -0
  82. package/src/blocks/execution-status/README.md +25 -0
  83. package/src/blocks/execution-status/execution-status.tsx +135 -0
  84. package/src/blocks/execution-status/index.ts +2 -0
  85. package/src/blocks/file-tree/file-icons.tsx +84 -0
  86. package/src/blocks/file-tree/file-tree-node.tsx +158 -0
  87. package/src/blocks/file-tree/file-tree-types.ts +33 -0
  88. package/src/blocks/file-tree/file-tree.tsx +163 -0
  89. package/src/blocks/file-tree/index.ts +5 -0
  90. package/src/blocks/filter-builder/filter-builder.tsx +169 -0
  91. package/src/blocks/filter-builder/filter-row.tsx +164 -0
  92. package/src/blocks/filter-builder/filter-types.ts +61 -0
  93. package/src/blocks/filter-builder/index.ts +12 -0
  94. package/src/blocks/footer/footer-types.ts +35 -0
  95. package/src/blocks/footer/index.ts +2 -0
  96. package/src/blocks/footer/site-footer.tsx +212 -0
  97. package/src/blocks/form/form-block.tsx +113 -0
  98. package/src/blocks/form/form-lookup-field.tsx +60 -0
  99. package/src/blocks/form/index.ts +4 -0
  100. package/src/blocks/handover-stack/handover-stack.tsx +41 -0
  101. package/src/blocks/handover-stack/index.ts +1 -0
  102. package/src/blocks/idea-handover/idea-handover-readiness.ts +41 -0
  103. package/src/blocks/idea-handover/idea-handover-workspace.tsx +150 -0
  104. package/src/blocks/idea-handover/index.ts +4 -0
  105. package/src/blocks/kanban/index.ts +11 -0
  106. package/src/blocks/kanban/kanban-board.tsx +146 -0
  107. package/src/blocks/kanban/kanban-card.tsx +149 -0
  108. package/src/blocks/kanban/kanban-column.tsx +102 -0
  109. package/src/blocks/kanban/kanban-types.ts +38 -0
  110. package/src/blocks/loader/global-loader.tsx +90 -0
  111. package/src/blocks/loader/index.ts +1 -0
  112. package/src/blocks/mascot/README.md +21 -0
  113. package/src/blocks/mascot/index.ts +2 -0
  114. package/src/blocks/mascot/mascot.tsx +202 -0
  115. package/src/blocks/master-list/README.md +21 -0
  116. package/src/blocks/master-list/index.ts +12 -0
  117. package/src/blocks/master-list/master-form.tsx +157 -0
  118. package/src/blocks/master-list/master-list-desk.tsx +370 -0
  119. package/src/blocks/master-list/master-list.tsx +189 -0
  120. package/src/blocks/master-list/types.ts +18 -0
  121. package/src/blocks/mermaid-preview/index.ts +1 -0
  122. package/src/blocks/mermaid-preview/mermaid-preview.tsx +164 -0
  123. package/src/blocks/notifications/README.md +7 -0
  124. package/src/blocks/notifications/index.ts +1 -0
  125. package/src/blocks/notifications/notification-center-page.tsx +73 -0
  126. package/src/blocks/pricing/index.ts +7 -0
  127. package/src/blocks/pricing/pricing-table.tsx +173 -0
  128. package/src/blocks/pricing/pricing-types.ts +34 -0
  129. package/src/blocks/product-card/index.ts +2 -0
  130. package/src/blocks/product-card/product-card-types.ts +35 -0
  131. package/src/blocks/product-card/product-card.tsx +183 -0
  132. package/src/blocks/table/data-table-block.tsx +313 -0
  133. package/src/blocks/table/data-table-filter-menu.tsx +48 -0
  134. package/src/blocks/table/data-table-icon-menu-trigger.tsx +44 -0
  135. package/src/blocks/table/data-table-menu-content.tsx +32 -0
  136. package/src/blocks/table/data-table-pagination.tsx +149 -0
  137. package/src/blocks/table/data-table-row-actions.tsx +53 -0
  138. package/src/blocks/table/data-table-status.tsx +42 -0
  139. package/src/blocks/table/data-table-toolbar.tsx +124 -0
  140. package/src/blocks/table/data-table-totals.tsx +31 -0
  141. package/src/blocks/table/index.ts +13 -0
  142. package/src/blocks/workspace/index.ts +19 -0
  143. package/src/blocks/workspace/workspace-action-card.tsx +73 -0
  144. package/src/blocks/workspace/workspace-application-header.tsx +67 -0
  145. package/src/blocks/workspace/workspace-entity-card.tsx +48 -0
  146. package/src/blocks/workspace/workspace-health-summary.tsx +15 -0
  147. package/src/blocks/workspace/workspace-metric-card.tsx +97 -0
  148. package/src/blocks/workspace/workspace-page-header.tsx +43 -0
  149. package/src/blocks/workspace/workspace-publish-status.tsx +15 -0
  150. package/src/blocks/workspace/workspace-route-checklist.tsx +28 -0
  151. package/src/blocks/workspace/workspace-runtime-status.tsx +48 -0
  152. package/src/blocks/workspace/workspace-section-card.tsx +42 -0
  153. package/src/components/accordion.tsx +65 -0
  154. package/src/components/alert-dialog.tsx +160 -0
  155. package/src/components/alert.tsx +68 -0
  156. package/src/components/aspect-ratio.tsx +22 -0
  157. package/src/components/attachment.tsx +195 -0
  158. package/src/components/avatar.tsx +90 -0
  159. package/src/components/badge.tsx +55 -0
  160. package/src/components/breadcrumb.tsx +100 -0
  161. package/src/components/bubble.tsx +123 -0
  162. package/src/components/button-group.tsx +79 -0
  163. package/src/components/button.tsx +71 -0
  164. package/src/components/calendar.tsx +185 -0
  165. package/src/components/card.tsx +100 -0
  166. package/src/components/carousel.tsx +229 -0
  167. package/src/components/chart.tsx +338 -0
  168. package/src/components/checkbox.tsx +27 -0
  169. package/src/components/collapsible.tsx +15 -0
  170. package/src/components/combobox.tsx +269 -0
  171. package/src/components/command.tsx +181 -0
  172. package/src/components/compact-model-switcher.tsx +128 -0
  173. package/src/components/context-menu.tsx +244 -0
  174. package/src/components/dialog.tsx +138 -0
  175. package/src/components/direction.tsx +1 -0
  176. package/src/components/drawer.tsx +208 -0
  177. package/src/components/dropdown-menu.tsx +257 -0
  178. package/src/components/empty.tsx +93 -0
  179. package/src/components/field.tsx +232 -0
  180. package/src/components/hover-card.tsx +45 -0
  181. package/src/components/input-group.tsx +146 -0
  182. package/src/components/input-otp.tsx +83 -0
  183. package/src/components/input.tsx +19 -0
  184. package/src/components/item.tsx +188 -0
  185. package/src/components/kbd.tsx +26 -0
  186. package/src/components/label.tsx +17 -0
  187. package/src/components/markdown-content.tsx +113 -0
  188. package/src/components/marker.tsx +67 -0
  189. package/src/components/menubar.tsx +267 -0
  190. package/src/components/message-scroller.tsx +91 -0
  191. package/src/components/message.tsx +84 -0
  192. package/src/components/native-select.tsx +54 -0
  193. package/src/components/navigation-menu.tsx +160 -0
  194. package/src/components/pagination.tsx +118 -0
  195. package/src/components/popover.tsx +74 -0
  196. package/src/components/progress.tsx +82 -0
  197. package/src/components/questionnaire.tsx +150 -0
  198. package/src/components/radio-group.tsx +35 -0
  199. package/src/components/resizable.tsx +41 -0
  200. package/src/components/rich-text-editor.tsx +604 -0
  201. package/src/components/scroll-area.tsx +47 -0
  202. package/src/components/select.tsx +189 -0
  203. package/src/components/separator.tsx +18 -0
  204. package/src/components/sheet.tsx +125 -0
  205. package/src/components/sidebar.tsx +693 -0
  206. package/src/components/skeleton.tsx +13 -0
  207. package/src/components/slider.tsx +51 -0
  208. package/src/components/sonner.tsx +45 -0
  209. package/src/components/sparkline.tsx +231 -0
  210. package/src/components/spinner.tsx +20 -0
  211. package/src/components/status-badge.tsx +114 -0
  212. package/src/components/switch.tsx +31 -0
  213. package/src/components/table.tsx +91 -0
  214. package/src/components/tabs.tsx +72 -0
  215. package/src/components/textarea.tsx +17 -0
  216. package/src/components/toast.tsx +222 -0
  217. package/src/components/toggle-group.tsx +85 -0
  218. package/src/components/toggle.tsx +42 -0
  219. package/src/components/tooltip.tsx +53 -0
  220. package/src/design-system/README.md +55 -0
  221. package/src/design-system/action-intents.ts +21 -0
  222. package/src/design-system/agent-manifest.ts +83 -0
  223. package/src/design-system/block-registry.ts +272 -0
  224. package/src/design-system/component-registry.ts +142 -0
  225. package/src/design-system/contracts.ts +69 -0
  226. package/src/design-system/defaults.ts +3 -0
  227. package/src/design-system/index.ts +29 -0
  228. package/src/design-system/mcp-server.ts +156 -0
  229. package/src/design-system/page-registry.ts +74 -0
  230. package/src/design-system/selection.ts +67 -0
  231. package/src/design-system/template-registry.ts +33 -0
  232. package/src/features/interface-topology/index.ts +14 -0
  233. package/src/features/interface-topology/interface-topology.types.ts +40 -0
  234. package/src/features/interface-topology/topology-control.tsx +18 -0
  235. package/src/features/interface-topology/topology-inspector.tsx +228 -0
  236. package/src/features/interface-topology/topology-marker.tsx +47 -0
  237. package/src/features/interface-topology/topology-region.tsx +44 -0
  238. package/src/features/interface-topology/use-interface-topology.ts +178 -0
  239. package/src/hooks/use-mobile.ts +19 -0
  240. package/src/hooks/use-scroll-follow.ts +22 -0
  241. package/src/index.ts +11 -0
  242. package/src/layouts/README.md +65 -0
  243. package/src/layouts/agent-workspace/agent-activity-rail.tsx +101 -0
  244. package/src/layouts/agent-workspace/agent-workspace.tsx +27 -0
  245. package/src/layouts/agent-workspace/agent-workspace.types.ts +28 -0
  246. package/src/layouts/agent-workspace/index.ts +7 -0
  247. package/src/layouts/blog-header/blog-header-types.ts +25 -0
  248. package/src/layouts/blog-header/blog-header.tsx +172 -0
  249. package/src/layouts/blog-header/index.ts +2 -0
  250. package/src/layouts/documentation-workspace/README.md +8 -0
  251. package/src/layouts/documentation-workspace/documentation-workspace.tsx +34 -0
  252. package/src/layouts/documentation-workspace/index.ts +1 -0
  253. package/src/layouts/ecommerce-header/ecommerce-actions.tsx +89 -0
  254. package/src/layouts/ecommerce-header/ecommerce-announcement.tsx +43 -0
  255. package/src/layouts/ecommerce-header/ecommerce-drawer.tsx +97 -0
  256. package/src/layouts/ecommerce-header/ecommerce-header-types.ts +59 -0
  257. package/src/layouts/ecommerce-header/ecommerce-header.tsx +47 -0
  258. package/src/layouts/ecommerce-header/ecommerce-navigation.tsx +98 -0
  259. package/src/layouts/ecommerce-header/ecommerce-search.tsx +86 -0
  260. package/src/layouts/ecommerce-header/index.ts +2 -0
  261. package/src/layouts/main-workspace/index.ts +28 -0
  262. package/src/layouts/mdi/index.ts +1 -0
  263. package/src/layouts/mdi/mdi.tsx +20 -0
  264. package/src/layouts/mdi-main/index.ts +27 -0
  265. package/src/layouts/mdi-main/mdi-app-catalog.generated.ts +159 -0
  266. package/src/layouts/mdi-main/mdi-app-catalog.ts +76 -0
  267. package/src/layouts/mdi-main/mdi-app-switcher.tsx +107 -0
  268. package/src/layouts/mdi-main/mdi-feature-settings.tsx +135 -0
  269. package/src/layouts/mdi-main/mdi-global-search.tsx +192 -0
  270. package/src/layouts/mdi-main/mdi-main.tsx +241 -0
  271. package/src/layouts/mdi-main/mdi-notifications-menu.tsx +197 -0
  272. package/src/layouts/mdi-main/mdi-profile-menu.tsx +79 -0
  273. package/src/layouts/mdi-main/mdi-sidebar-navigation.tsx +226 -0
  274. package/src/layouts/mdi-main/mdi-sidebar.tsx +110 -0
  275. package/src/layouts/mdi-main/mdi-status-bar.tsx +26 -0
  276. package/src/layouts/mdi-main/mdi-top-menu-control.ts +4 -0
  277. package/src/layouts/mdi-main/mdi-top-menu.tsx +117 -0
  278. package/src/layouts/mdi-main/mdi-topology.tsx +297 -0
  279. package/src/layouts/mdi-main/mdi-tweak-panel.tsx +104 -0
  280. package/src/layouts/mdi-main/mdi-types.ts +131 -0
  281. package/src/layouts/mdi-main/use-mdi-features.ts +36 -0
  282. package/src/layouts/mdi-main/use-mdi-sidebar-state.ts +59 -0
  283. package/src/layouts/site-header/announcement-bar.tsx +55 -0
  284. package/src/layouts/site-header/category-strip.tsx +41 -0
  285. package/src/layouts/site-header/index.ts +16 -0
  286. package/src/layouts/site-header/main-navbar.tsx +179 -0
  287. package/src/layouts/site-header/mobile-drawer.tsx +140 -0
  288. package/src/layouts/site-header/site-header-types.ts +63 -0
  289. package/src/layouts/site-header/site-header.tsx +58 -0
  290. package/src/lib/random-id.ts +19 -0
  291. package/src/lib/utils.ts +6 -0
  292. package/src/providers/pdf-provider.ts +137 -0
  293. package/src/styles/globals.css +374 -0
  294. package/src/templates/README.md +12 -0
  295. package/src/templates/dashboard-01/components/app-sidebar.tsx +177 -0
  296. package/src/templates/dashboard-01/components/chart-area-interactive.tsx +276 -0
  297. package/src/templates/dashboard-01/components/data-table-cell-viewer.tsx +213 -0
  298. package/src/templates/dashboard-01/components/data-table.tsx +577 -0
  299. package/src/templates/dashboard-01/components/nav-documents.tsx +79 -0
  300. package/src/templates/dashboard-01/components/nav-main.tsx +55 -0
  301. package/src/templates/dashboard-01/components/nav-secondary.tsx +39 -0
  302. package/src/templates/dashboard-01/components/nav-user.tsx +97 -0
  303. package/src/templates/dashboard-01/components/section-cards.tsx +99 -0
  304. package/src/templates/dashboard-01/components/site-header.tsx +14 -0
  305. package/src/templates/dashboard-01/data.json +614 -0
  306. package/src/templates/dashboard-01/index.ts +7 -0
  307. package/src/templates/documentation-sidebar/app-sidebar.tsx +95 -0
  308. package/src/templates/documentation-sidebar/index.ts +2 -0
  309. package/src/templates/sidebar-07/components/app-sidebar.tsx +175 -0
  310. package/src/templates/sidebar-07/components/nav-main.tsx +64 -0
  311. package/src/templates/sidebar-07/components/nav-projects.tsx +79 -0
  312. package/src/templates/sidebar-07/components/nav-user.tsx +105 -0
  313. package/src/templates/sidebar-07/components/team-switcher.tsx +94 -0
  314. package/src/templates/sidebar-07/index.ts +1 -0
  315. package/src/templates/ui-page/index.ts +7 -0
  316. package/src/templates/ui-page/ui-browser-frame.tsx +27 -0
  317. package/src/templates/ui-page/ui-template-code.tsx +48 -0
  318. package/src/templates/ui-page/ui-template-header.tsx +87 -0
  319. package/src/templates/ui-page/ui-template-navigation.tsx +60 -0
  320. package/src/templates/ui-page/ui-template-page.tsx +97 -0
  321. package/src/theme/index.ts +12 -0
  322. package/src/theme/theme-config.ts +30 -0
  323. package/src/theme/theme-provider.tsx +142 -0
  324. package/src/theme/theme-selector.tsx +75 -0
  325. package/src/tokens/index.ts +15 -0
  326. package/src/vite-env.d.ts +3 -0
@@ -0,0 +1,296 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { createRandomId } from "../../lib/random-id";
3
+ import { LoginPage } from "./login-page";
4
+
5
+ type Session = {
6
+ readonly expiresAt: string;
7
+ readonly roles: readonly string[];
8
+ readonly token: string;
9
+ };
10
+
11
+ export interface AuthenticatedRequest {
12
+ (input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
13
+ }
14
+
15
+ export interface AuthenticatedSession {
16
+ readonly fetch: AuthenticatedRequest;
17
+ readonly portal: "admin" | "super-admin" | "user";
18
+ readonly roles: readonly string[];
19
+ logout(): void;
20
+ }
21
+
22
+ export function SessionBoundary({
23
+ applicationId,
24
+ applicationName,
25
+ autoLoginPath,
26
+ children,
27
+ loginPath,
28
+ logoutPath,
29
+ onAuthenticated,
30
+ unauthenticated,
31
+ }: {
32
+ readonly applicationId: string;
33
+ readonly applicationName: string;
34
+ readonly autoLoginPath?: string;
35
+ readonly children: (session: AuthenticatedSession) => ReactNode;
36
+ readonly loginPath: string;
37
+ readonly logoutPath?: string;
38
+ readonly onAuthenticated?: () => void;
39
+ readonly unauthenticated?: ReactNode;
40
+ }) {
41
+ const portal = portalFromLocation();
42
+ const [browserSessionId] = useState(() => readBrowserSessionId(applicationId));
43
+ const [error, setError] = useState<string>();
44
+ const [session, setSession] = useState<Session | undefined>(() => readSession(applicationId));
45
+ const refreshSessionRef = useRef<Promise<Session | undefined> | null>(null);
46
+ const [busy, setBusy] = useState(Boolean(autoLoginPath));
47
+ const requiresPortalLogin = !session || !sessionCanAccessPortal(session.roles, portal);
48
+
49
+ useEffect(() => {
50
+ const restoreBrowserSession = (event: PageTransitionEvent) => {
51
+ if (event.persisted) setSession(readSession(applicationId));
52
+ };
53
+ window.addEventListener("pageshow", restoreBrowserSession);
54
+ return () => window.removeEventListener("pageshow", restoreBrowserSession);
55
+ }, [applicationId]);
56
+
57
+ useEffect(() => {
58
+ writeSession(applicationId, session);
59
+ }, [applicationId, session]);
60
+
61
+ useEffect(() => {
62
+ if (requiresPortalLogin) document.title = `${applicationName} | Login`;
63
+ }, [applicationName, requiresPortalLogin]);
64
+
65
+ useEffect(() => {
66
+ if (!autoLoginPath || session) return;
67
+ void authenticate(autoLoginPath, undefined, undefined, browserSessionId, setBusy, setError, setSession, onAuthenticated, true, portal);
68
+ }, [autoLoginPath, browserSessionId, onAuthenticated, session]);
69
+
70
+ useEffect(() => {
71
+ if (!session || sessionCanAccessPortal(session.roles, portal)) return;
72
+ window.location.replace(authenticatedRouteForRoles(session.roles));
73
+ }, [portal, session]);
74
+
75
+ useEffect(() => {
76
+ if (!requiresPortalLogin || isPortalLoginPath(portal)) return;
77
+ window.history.replaceState({}, "", portalLoginRoute(portal));
78
+ window.dispatchEvent(new PopStateEvent("popstate"));
79
+ }, [portal, requiresPortalLogin]);
80
+
81
+ if (requiresPortalLogin) {
82
+ if (unauthenticated) return <>{unauthenticated}</>;
83
+ return (
84
+ <LoginPage
85
+ busy={busy}
86
+ brandName={applicationName}
87
+ error={error}
88
+ registerHref={null}
89
+ description={portalDescription(portal)}
90
+ title={portalTitle(portal)}
91
+ onSubmit={(identifier, password) =>
92
+ void authenticate(loginPathForPortal(loginPath, portal), identifier, password, browserSessionId, setBusy, setError, setSession, () => completeAuthentication(portal, onAuthenticated), false)
93
+ }
94
+ />
95
+ );
96
+ }
97
+
98
+ return (
99
+ <>
100
+ {children({
101
+ fetch: async (input, init) => {
102
+ const response = await authenticatedFetch(session, browserSessionId, input, init);
103
+ if (response.status !== 401 || !autoLoginPath) return response;
104
+ const refresh = refreshSessionRef.current ?? requestDevelopmentSession(autoLoginPath, browserSessionId, portal);
105
+ refreshSessionRef.current = refresh;
106
+ const refreshed = await refresh.finally(() => {
107
+ if (refreshSessionRef.current === refresh) refreshSessionRef.current = null;
108
+ });
109
+ if (!refreshed) return response;
110
+ setSession(refreshed);
111
+ return authenticatedFetch(refreshed, browserSessionId, input, init);
112
+ },
113
+ portal: sessionPortal(session.roles),
114
+ roles: session.roles,
115
+ logout: () => logout(applicationId, session, browserSessionId, loginPath, setSession, logoutPath),
116
+ })}
117
+ </>
118
+ );
119
+ }
120
+
121
+ async function requestDevelopmentSession(path: string, browserSessionId: string, portal: AuthenticatedSession["portal"]): Promise<Session | undefined> {
122
+ try {
123
+ const response = await fetch(path, {
124
+ body: JSON.stringify({}),
125
+ headers: {
126
+ "content-type": "application/json",
127
+ "x-codexsun-auto-login-desk": portal,
128
+ "x-codexsun-browser-session": browserSessionId,
129
+ },
130
+ method: "POST",
131
+ });
132
+ const body = (await response.json().catch(() => undefined)) as { actor?: { roles?: unknown }; session?: { expiresAt?: string }; token?: string } | undefined;
133
+ const roles = Array.isArray(body?.actor?.roles) && body.actor.roles.every((role) => typeof role === "string") ? body.actor.roles as string[] : undefined;
134
+ if (!response.ok || !body?.token || !body.session?.expiresAt || !roles) return undefined;
135
+ return { expiresAt: body.session.expiresAt, roles, token: body.token };
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ }
140
+
141
+ async function authenticate(
142
+ path: string,
143
+ identifier: string | undefined,
144
+ password: string | undefined,
145
+ browserSessionId: string,
146
+ setBusy: (value: boolean) => void,
147
+ setError: (value: string | undefined) => void,
148
+ setSession: (value: Session) => void,
149
+ onAuthenticated: (() => void) | undefined,
150
+ ignoreUnavailable: boolean,
151
+ autoLoginDesk?: AuthenticatedSession["portal"],
152
+ ): Promise<void> {
153
+ setBusy(true);
154
+ setError(undefined);
155
+ try {
156
+ const hasCredentials = identifier !== undefined;
157
+ const response = await fetch(path, {
158
+ body: JSON.stringify(hasCredentials ? { identifier, password } : {}),
159
+ headers: {
160
+ "x-codexsun-browser-session": browserSessionId,
161
+ ...(autoLoginDesk ? { "x-codexsun-auto-login-desk": autoLoginDesk } : {}),
162
+ "content-type": "application/json",
163
+ },
164
+ method: "POST",
165
+ });
166
+ if (ignoreUnavailable && response.status === 404) return;
167
+ const body = (await response.json().catch(() => undefined)) as { actor?: { roles?: unknown }; error?: string; session?: { expiresAt?: string }; token?: string } | undefined;
168
+ const roles = Array.isArray(body?.actor?.roles) && body.actor.roles.every((role) => typeof role === "string") ? body.actor.roles : undefined;
169
+ if (!response.ok || !body?.token || !body.session?.expiresAt || !roles) throw new Error(body?.error ?? "Sign in failed.");
170
+ setSession({ expiresAt: body.session.expiresAt, roles, token: body.token });
171
+ if (ignoreUnavailable) completeAuthentication(sessionPortal(roles), onAuthenticated);
172
+ else onAuthenticated?.();
173
+ } catch (reason) {
174
+ if (!ignoreUnavailable) setError(reason instanceof Error ? reason.message : "Sign in failed.");
175
+ } finally {
176
+ setBusy(false);
177
+ }
178
+ }
179
+
180
+ function authenticatedFetch(
181
+ session: Session,
182
+ browserSessionId: string,
183
+ input: RequestInfo | URL,
184
+ init?: RequestInit,
185
+ ): Promise<Response> {
186
+ const headers = new Headers(init?.headers);
187
+ headers.set("authorization", `Bearer ${session.token}`);
188
+ headers.set("x-codexsun-browser-session", browserSessionId);
189
+ return fetch(input, { ...init, headers });
190
+ }
191
+
192
+ function logout(
193
+ applicationId: string,
194
+ session: Session,
195
+ browserSessionId: string,
196
+ loginPath: string,
197
+ setSession: (value: Session | undefined) => void,
198
+ logoutPath = "/login",
199
+ ): void {
200
+ const logoutPathname = loginPath.replace(/\/login$/u, "/logout");
201
+ void authenticatedFetch(session, browserSessionId, logoutPathname, { method: "POST" });
202
+ writeSession(applicationId, undefined);
203
+ window.history.replaceState({ loggedOut: true }, "", logoutPath);
204
+ setSession(undefined);
205
+ }
206
+
207
+ function readSession(applicationId: string): Session | undefined {
208
+ try {
209
+ const value = window.sessionStorage.getItem(sessionStorageKey(applicationId));
210
+ const session = value ? (JSON.parse(value) as Session) : undefined;
211
+ if (!session?.token || !session.expiresAt || !Array.isArray(session.roles) || Date.parse(session.expiresAt) <= Date.now()) return undefined;
212
+ return session;
213
+ } catch {
214
+ return undefined;
215
+ }
216
+ }
217
+
218
+ function writeSession(applicationId: string, session: Session | undefined): void {
219
+ const key = sessionStorageKey(applicationId);
220
+ if (session) window.sessionStorage.setItem(key, JSON.stringify(session));
221
+ else window.sessionStorage.removeItem(key);
222
+ }
223
+
224
+ function readBrowserSessionId(applicationId: string): string {
225
+ const key = `codexsun.${applicationId}.browser-session`;
226
+ const existing = window.sessionStorage.getItem(key);
227
+ if (existing) return existing;
228
+ const value = createRandomId();
229
+ window.sessionStorage.setItem(key, value);
230
+ return value;
231
+ }
232
+
233
+ function sessionStorageKey(applicationId: string): string {
234
+ return `codexsun.${applicationId}.identity-session`;
235
+ }
236
+
237
+ function loginPathForPortal(loginPath: string, portal: AuthenticatedSession["portal"]): string {
238
+ if (portal === "user") return loginPath;
239
+ return loginPath.replace(/\/login$/u, `/${portal}/login`);
240
+ }
241
+
242
+ function portalFromLocation(): AuthenticatedSession["portal"] {
243
+ if (window.location.pathname.startsWith("/sa/")) return "super-admin";
244
+ if (window.location.pathname.startsWith("/admin/")) return "admin";
245
+ return "user";
246
+ }
247
+
248
+ export function sessionCanAccessPortal(
249
+ roles: readonly string[],
250
+ portal: AuthenticatedSession["portal"],
251
+ ): boolean {
252
+ return sessionPortal(roles) === portal;
253
+ }
254
+
255
+ function isPortalLoginPath(portal: AuthenticatedSession["portal"]): boolean {
256
+ return window.location.pathname === portalLoginRoute(portal);
257
+ }
258
+
259
+ function portalLoginRoute(portal: AuthenticatedSession["portal"]): string {
260
+ if (portal === "super-admin") return "/sa/login";
261
+ if (portal === "admin") return "/admin/login";
262
+ return "/login";
263
+ }
264
+
265
+ function portalTitle(portal: AuthenticatedSession["portal"]): string {
266
+ if (portal === "super-admin") return "Super administrator sign in";
267
+ if (portal === "admin") return "Administrator sign in";
268
+ return "Sign in";
269
+ }
270
+
271
+ function portalDescription(portal: AuthenticatedSession["portal"]): string {
272
+ if (portal === "super-admin") return "Use your super-administrator username or email to access the maintenance desk.";
273
+ if (portal === "admin") return "Use your administrator username or email to access the administration desk.";
274
+ return "Use your username or email and password to access this application.";
275
+ }
276
+
277
+ function sessionPortal(roles: readonly string[]): AuthenticatedSession["portal"] {
278
+ if (roles.includes("super-admin")) return "super-admin";
279
+ if (roles.includes("admin")) return "admin";
280
+ return "user";
281
+ }
282
+
283
+ function completeAuthentication(portal: AuthenticatedSession["portal"], onAuthenticated: (() => void) | undefined): void {
284
+ if (onAuthenticated) return onAuthenticated();
285
+ window.history.replaceState({}, "", authenticatedRouteForPortal(portal));
286
+ }
287
+
288
+ export function authenticatedRouteForRoles(roles: readonly string[]): string {
289
+ return authenticatedRouteForPortal(sessionPortal(roles));
290
+ }
291
+
292
+ function authenticatedRouteForPortal(portal: AuthenticatedSession["portal"]): string {
293
+ if (portal === "super-admin") return "/sa/desk";
294
+ if (portal === "admin") return "/admin/desk";
295
+ return "/overview";
296
+ }
@@ -0,0 +1,15 @@
1
+ import { LoginPage, type LoginPageProps } from './login-page'
2
+
3
+ export function SuperAdminLoginPage(
4
+ props: Omit<LoginPageProps, 'description' | 'forgotHref' | 'registerHref' | 'title' | 'variant'>,
5
+ ) {
6
+ return (
7
+ <LoginPage
8
+ {...props}
9
+ description="This desk controls system-wide identity and policy."
10
+ forgotHref="/sa/password/forgot"
11
+ registerHref={null}
12
+ title="Super administrator sign in"
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,5 @@
1
+ import { PasswordForgotPage, type PasswordForgotPageProps } from './password-forgot-page'
2
+
3
+ export function SuperAdminPasswordForgotPage(props: Omit<PasswordForgotPageProps, 'backHref'>) {
4
+ return <PasswordForgotPage {...props} backHref="/sa/login" />
5
+ }
@@ -0,0 +1,156 @@
1
+ import { ClockIcon } from 'lucide-react'
2
+ import { cn } from '../../lib/utils'
3
+ import { Badge } from '../../components/badge'
4
+ import type { BlogCardProps } from './blog-types'
5
+
6
+ export function BlogCard({ className, onSelect, post, variant = 'standard' }: BlogCardProps) {
7
+ if (variant === 'compact') {
8
+ return (
9
+ <div
10
+ onClick={() => onSelect?.(post.id)}
11
+ className={cn(
12
+ 'group flex items-center gap-3 cursor-pointer rounded-xl p-2 transition-colors hover:bg-muted/40',
13
+ className,
14
+ )}
15
+ >
16
+ <img
17
+ src={post.coverImage}
18
+ alt={post.title}
19
+ className="size-16 rounded-lg object-cover bg-muted shrink-0 border border-border/40"
20
+ />
21
+ <div className="min-w-0 flex-1 space-y-1">
22
+ <Badge variant="outline" className="text-[9px] py-0 px-1 font-semibold">
23
+ {post.category}
24
+ </Badge>
25
+ <h5 className="text-xs font-bold text-foreground line-clamp-2 group-hover:text-primary transition-colors">
26
+ {post.title}
27
+ </h5>
28
+ <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
29
+ <span>{post.date}</span>
30
+ <span>•</span>
31
+ <span>{post.readingTime}</span>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ )
36
+ }
37
+
38
+ if (variant === 'horizontal') {
39
+ return (
40
+ <div
41
+ onClick={() => onSelect?.(post.id)}
42
+ className={cn(
43
+ 'group grid grid-cols-1 sm:grid-cols-12 gap-5 cursor-pointer rounded-2xl border border-border/80 bg-card p-4 shadow-xs transition-all hover:border-primary/50 hover:shadow-md',
44
+ className,
45
+ )}
46
+ >
47
+ <div className="sm:col-span-5 aspect-16/10 overflow-hidden rounded-xl bg-muted border border-border/40">
48
+ <img
49
+ src={post.coverImage}
50
+ alt={post.title}
51
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
52
+ />
53
+ </div>
54
+
55
+ <div className="sm:col-span-7 flex flex-col justify-between space-y-2 py-1">
56
+ <div className="space-y-2">
57
+ <div className="flex items-center gap-2">
58
+ <Badge variant="secondary" className="text-[10px] font-bold">
59
+ {post.category}
60
+ </Badge>
61
+ <div className="flex items-center gap-1 text-[11px] text-muted-foreground">
62
+ <ClockIcon className="size-3" />
63
+ <span>{post.readingTime}</span>
64
+ </div>
65
+ </div>
66
+
67
+ <h4 className="text-base font-bold text-foreground group-hover:text-primary transition-colors line-clamp-2">
68
+ {post.title}
69
+ </h4>
70
+
71
+ <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
72
+ {post.excerpt}
73
+ </p>
74
+ </div>
75
+
76
+ <div className="flex items-center gap-2.5 pt-2 border-t border-border/40">
77
+ {post.author.avatar ? (
78
+ <img
79
+ src={post.author.avatar}
80
+ alt={post.author.name}
81
+ className="size-6 rounded-full object-cover"
82
+ />
83
+ ) : (
84
+ <div className="size-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-bold">
85
+ {post.author.name.charAt(0)}
86
+ </div>
87
+ )}
88
+ <div className="text-[11px] font-semibold text-foreground">
89
+ {post.author.name}{' '}
90
+ <span className="font-normal text-muted-foreground">• {post.date}</span>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ )
96
+ }
97
+
98
+ // Standard Card
99
+ return (
100
+ <div
101
+ onClick={() => onSelect?.(post.id)}
102
+ className={cn(
103
+ 'group flex flex-col justify-between overflow-hidden rounded-2xl border border-border/80 bg-card p-3.5 shadow-xs transition-all duration-200 cursor-pointer hover:-translate-y-1 hover:border-primary/50 hover:shadow-md',
104
+ className,
105
+ )}
106
+ >
107
+ <div>
108
+ <div className="relative aspect-16/10 w-full overflow-hidden rounded-xl bg-muted mb-3 border border-border/40">
109
+ <img
110
+ src={post.coverImage}
111
+ alt={post.title}
112
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
113
+ />
114
+ <Badge
115
+ variant="secondary"
116
+ className="absolute top-2 left-2 text-[10px] font-bold bg-background/90 backdrop-blur-sm"
117
+ >
118
+ {post.category}
119
+ </Badge>
120
+ </div>
121
+
122
+ <div className="space-y-1.5">
123
+ <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
124
+ <ClockIcon className="size-3" />
125
+ <span>{post.readingTime}</span>
126
+ <span>•</span>
127
+ <span>{post.date}</span>
128
+ </div>
129
+
130
+ <h4 className="text-sm font-bold text-foreground group-hover:text-primary transition-colors line-clamp-2">
131
+ {post.title}
132
+ </h4>
133
+
134
+ <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
135
+ {post.excerpt}
136
+ </p>
137
+ </div>
138
+ </div>
139
+
140
+ <div className="flex items-center gap-2 pt-3 mt-3 border-t border-border/40">
141
+ {post.author.avatar ? (
142
+ <img
143
+ src={post.author.avatar}
144
+ alt={post.author.name}
145
+ className="size-6 rounded-full object-cover"
146
+ />
147
+ ) : (
148
+ <div className="size-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-[10px] font-bold">
149
+ {post.author.name.charAt(0)}
150
+ </div>
151
+ )}
152
+ <span className="text-[11px] font-medium text-foreground">{post.author.name}</span>
153
+ </div>
154
+ </div>
155
+ )
156
+ }
@@ -0,0 +1,175 @@
1
+ import { useState } from 'react'
2
+ import { CheckIcon, ClockIcon, CopyIcon } from 'lucide-react'
3
+ import { cn } from '../../lib/utils'
4
+ import { Button } from '../../components/button'
5
+ import { Badge } from '../../components/badge'
6
+ import { BlogCard } from './blog-card'
7
+ import type { BlogReaderProps } from './blog-types'
8
+
9
+ export function BlogReader({ article, children, className, onShare }: BlogReaderProps) {
10
+ const [copied, setCopied] = useState(false)
11
+
12
+ const handleCopyLink = () => {
13
+ navigator.clipboard?.writeText(window.location.href)
14
+ setCopied(true)
15
+ onShare?.('copy')
16
+ setTimeout(() => setCopied(false), 2000)
17
+ }
18
+
19
+ return (
20
+ <article className={cn('mx-auto max-w-5xl space-y-10 px-4 py-8', className)}>
21
+ {/* 1. Article Header */}
22
+ <header className="space-y-4 max-w-3xl">
23
+ <div className="flex items-center gap-2">
24
+ <Badge variant="secondary" className="font-bold text-xs">
25
+ {article.category}
26
+ </Badge>
27
+ <div className="flex items-center gap-1 text-xs text-muted-foreground">
28
+ <ClockIcon className="size-3.5" />
29
+ <span>{article.readingTime}</span>
30
+ <span>•</span>
31
+ <span>{article.date}</span>
32
+ </div>
33
+ </div>
34
+
35
+ <h1 className="text-3xl sm:text-4xl font-black tracking-tight text-foreground leading-tight">
36
+ {article.title}
37
+ </h1>
38
+
39
+ <p className="text-base text-muted-foreground leading-relaxed">{article.excerpt}</p>
40
+
41
+ {/* Author Byline */}
42
+ <div className="flex items-center justify-between gap-4 pt-4 border-t border-border/60">
43
+ <div className="flex items-center gap-3">
44
+ {article.author.avatar ? (
45
+ <img
46
+ src={article.author.avatar}
47
+ alt={article.author.name}
48
+ className="size-10 rounded-full object-cover"
49
+ />
50
+ ) : (
51
+ <div className="size-10 rounded-full bg-primary/10 text-primary flex items-center justify-center font-bold text-sm">
52
+ {article.author.name.charAt(0)}
53
+ </div>
54
+ )}
55
+ <div>
56
+ <div className="text-sm font-bold text-foreground">{article.author.name}</div>
57
+ {article.author.role && (
58
+ <div className="text-xs text-muted-foreground">{article.author.role}</div>
59
+ )}
60
+ </div>
61
+ </div>
62
+
63
+ {/* Share Actions */}
64
+ <div className="flex items-center gap-1.5">
65
+ <Button
66
+ size="sm"
67
+ variant="outline"
68
+ className="h-8 gap-1.5 text-xs"
69
+ onClick={handleCopyLink}
70
+ >
71
+ {copied ? (
72
+ <CheckIcon className="size-3.5 text-emerald-600" />
73
+ ) : (
74
+ <CopyIcon className="size-3.5" />
75
+ )}
76
+ <span>{copied ? 'Copied' : 'Share'}</span>
77
+ </Button>
78
+ </div>
79
+ </div>
80
+ </header>
81
+
82
+ {/* 2. Cover Hero Image */}
83
+ <div className="aspect-16/9 w-full overflow-hidden rounded-3xl bg-muted border border-border/60 shadow-xs">
84
+ <img src={article.coverImage} alt={article.title} className="h-full w-full object-cover" />
85
+ </div>
86
+
87
+ {/* 3. Main Reading Content + Table of Contents */}
88
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
89
+ {/* Table of Contents (Sticky on desktop) */}
90
+ {article.tableOfContents && article.tableOfContents.length > 0 && (
91
+ <aside className="lg:col-span-3 order-2 lg:order-1">
92
+ <div className="sticky top-24 space-y-2 rounded-2xl border border-border/80 bg-muted/20 p-4 text-xs">
93
+ <h4 className="font-bold text-foreground uppercase tracking-wider text-[11px]">
94
+ On This Page
95
+ </h4>
96
+ <nav className="space-y-1">
97
+ {article.tableOfContents.map((item) => (
98
+ <a
99
+ key={item.id}
100
+ href={item.href}
101
+ className="block py-1 text-muted-foreground hover:text-foreground transition-colors truncate"
102
+ style={{ paddingLeft: `${(item.level - 1) * 8}px` }}
103
+ >
104
+ {item.title}
105
+ </a>
106
+ ))}
107
+ </nav>
108
+ </div>
109
+ </aside>
110
+ )}
111
+
112
+ {/* Content Body */}
113
+ <div
114
+ className={cn(
115
+ 'space-y-6 text-foreground leading-relaxed text-sm order-1 lg:order-2',
116
+ article.tableOfContents && article.tableOfContents.length > 0
117
+ ? 'lg:col-span-9'
118
+ : 'lg:col-span-12',
119
+ )}
120
+ >
121
+ {children}
122
+
123
+ {/* Tags */}
124
+ {article.tags && article.tags.length > 0 && (
125
+ <div className="flex flex-wrap items-center gap-2 pt-6 border-t border-border/60">
126
+ <span className="text-xs font-semibold text-muted-foreground">Tags:</span>
127
+ {article.tags.map((tag) => (
128
+ <Badge key={tag} variant="outline" className="text-xs font-medium">
129
+ #{tag}
130
+ </Badge>
131
+ ))}
132
+ </div>
133
+ )}
134
+
135
+ {/* Author Bio Box */}
136
+ {article.author.bio && (
137
+ <div className="flex items-start gap-4 rounded-2xl border border-border/80 bg-muted/30 p-5 mt-8">
138
+ {article.author.avatar ? (
139
+ <img
140
+ src={article.author.avatar}
141
+ alt={article.author.name}
142
+ className="size-12 rounded-full object-cover shrink-0"
143
+ />
144
+ ) : (
145
+ <div className="size-12 rounded-full bg-primary/10 text-primary flex items-center justify-center font-bold text-base shrink-0">
146
+ {article.author.name.charAt(0)}
147
+ </div>
148
+ )}
149
+ <div className="space-y-1">
150
+ <div className="text-sm font-bold text-foreground">About {article.author.name}</div>
151
+ <p className="text-xs text-muted-foreground leading-relaxed">
152
+ {article.author.bio}
153
+ </p>
154
+ </div>
155
+ </div>
156
+ )}
157
+ </div>
158
+ </div>
159
+
160
+ {/* 4. Related Posts */}
161
+ {article.relatedPosts && article.relatedPosts.length > 0 && (
162
+ <section className="pt-10 border-t border-border/80 space-y-6">
163
+ <div className="flex items-center justify-between">
164
+ <h3 className="text-xl font-bold tracking-tight text-foreground">Related Stories</h3>
165
+ </div>
166
+ <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
167
+ {article.relatedPosts.map((post) => (
168
+ <BlogCard key={post.id} post={post} variant="standard" />
169
+ ))}
170
+ </div>
171
+ </section>
172
+ )}
173
+ </article>
174
+ )
175
+ }