@lindaui/blocks 0.4.2 → 0.5.1

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 (255) hide show
  1. package/README.md +67 -0
  2. package/dist/ai-cost-card-f403ypsg.js +99 -0
  3. package/dist/ai-performance-card-cggm09nc.js +191 -0
  4. package/dist/analytics/ai-cost-card.d.ts +3 -0
  5. package/dist/analytics/ai-cost-card.d.ts.map +1 -0
  6. package/dist/analytics/ai-performance-card.d.ts +3 -0
  7. package/dist/analytics/ai-performance-card.d.ts.map +1 -0
  8. package/dist/analytics/analytics-card.d.ts +3 -0
  9. package/dist/analytics/analytics-card.d.ts.map +1 -0
  10. package/dist/analytics/analytics-skeleton.d.ts +5 -0
  11. package/dist/analytics/analytics-skeleton.d.ts.map +1 -0
  12. package/dist/analytics/entity-health-card.d.ts +3 -0
  13. package/dist/analytics/entity-health-card.d.ts.map +1 -0
  14. package/dist/analytics/status-distribution-chart.d.ts +3 -0
  15. package/dist/analytics/status-distribution-chart.d.ts.map +1 -0
  16. package/dist/analytics/time-range-picker.d.ts +3 -0
  17. package/dist/analytics/time-range-picker.d.ts.map +1 -0
  18. package/dist/analytics/top-entities-table.d.ts +3 -0
  19. package/dist/analytics/top-entities-table.d.ts.map +1 -0
  20. package/dist/analytics/types.d.ts +104 -0
  21. package/dist/analytics/types.d.ts.map +1 -0
  22. package/dist/analytics-card-bcmijpt6.js +38 -0
  23. package/dist/analytics-skeleton-imve37du.js +164 -0
  24. package/dist/analytics.d.ts +11 -0
  25. package/dist/analytics.d.ts.map +1 -0
  26. package/dist/analytics.js +8 -0
  27. package/dist/app-shell/drawer-focus.d.ts +8 -0
  28. package/dist/app-shell/drawer-focus.d.ts.map +1 -0
  29. package/dist/app-shell/footer.d.ts +39 -0
  30. package/dist/app-shell/footer.d.ts.map +1 -0
  31. package/dist/app-shell/logout-dialog.d.ts +6 -0
  32. package/dist/app-shell/logout-dialog.d.ts.map +1 -0
  33. package/dist/app-shell/navigation.d.ts +15 -0
  34. package/dist/app-shell/navigation.d.ts.map +1 -0
  35. package/dist/app-shell/types.d.ts +55 -0
  36. package/dist/app-shell/types.d.ts.map +1 -0
  37. package/dist/app-shell.d.ts +7 -0
  38. package/dist/app-shell.d.ts.map +1 -0
  39. package/dist/app-shell.js +254 -0
  40. package/dist/channel-settings.d.ts +24 -0
  41. package/dist/channel-settings.d.ts.map +1 -0
  42. package/dist/channel-settings.js +60 -0
  43. package/dist/chat-avatar-jdmeyf2o.js +30 -0
  44. package/dist/chat-layout-fwx3x26g.js +25 -0
  45. package/dist/chat.d.ts +22 -0
  46. package/dist/chat.d.ts.map +1 -0
  47. package/dist/chat.js +66 -0
  48. package/dist/command-palette.d.ts +22 -0
  49. package/dist/command-palette.d.ts.map +1 -0
  50. package/dist/command-palette.js +141 -0
  51. package/dist/compat-chat/chat-avatar.d.ts +16 -0
  52. package/dist/compat-chat/chat-avatar.d.ts.map +1 -0
  53. package/dist/compat-chat/chat-header.d.ts +9 -0
  54. package/dist/compat-chat/chat-header.d.ts.map +1 -0
  55. package/dist/compat-chat/chat-layout.d.ts +10 -0
  56. package/dist/compat-chat/chat-layout.d.ts.map +1 -0
  57. package/dist/compat-chat/conversation-list-item.d.ts +10 -0
  58. package/dist/compat-chat/conversation-list-item.d.ts.map +1 -0
  59. package/dist/compat-chat/conversation-list.d.ts +24 -0
  60. package/dist/compat-chat/conversation-list.d.ts.map +1 -0
  61. package/dist/compat-chat/conversation-thread.d.ts +18 -0
  62. package/dist/compat-chat/conversation-thread.d.ts.map +1 -0
  63. package/dist/compat-chat/date-divider.d.ts +5 -0
  64. package/dist/compat-chat/date-divider.d.ts.map +1 -0
  65. package/dist/compat-chat/empty-chat-view.d.ts +6 -0
  66. package/dist/compat-chat/empty-chat-view.d.ts.map +1 -0
  67. package/dist/compat-chat/image-lightbox.d.ts +7 -0
  68. package/dist/compat-chat/image-lightbox.d.ts.map +1 -0
  69. package/dist/compat-chat/message-bubble.d.ts +6 -0
  70. package/dist/compat-chat/message-bubble.d.ts.map +1 -0
  71. package/dist/compat-chat/message-composer.d.ts +11 -0
  72. package/dist/compat-chat/message-composer.d.ts.map +1 -0
  73. package/dist/compat-chat/time-divider.d.ts +5 -0
  74. package/dist/compat-chat/time-divider.d.ts.map +1 -0
  75. package/dist/compat-chat/time-grouping.d.ts +5 -0
  76. package/dist/compat-chat/time-grouping.d.ts.map +1 -0
  77. package/dist/compat-chat/types.d.ts +35 -0
  78. package/dist/compat-chat/types.d.ts.map +1 -0
  79. package/dist/compat-chat/whatsapp-audio-player.d.ts +7 -0
  80. package/dist/compat-chat/whatsapp-audio-player.d.ts.map +1 -0
  81. package/dist/compat.d.ts +9 -0
  82. package/dist/compat.d.ts.map +1 -0
  83. package/dist/compat.js +4 -0
  84. package/dist/connected-panel-lhwy25yk.js +132 -0
  85. package/dist/connection-panel/connected-panel.d.ts +26 -0
  86. package/dist/connection-panel/connected-panel.d.ts.map +1 -0
  87. package/dist/connection-panel/disconnected-panel.d.ts +12 -0
  88. package/dist/connection-panel/disconnected-panel.d.ts.map +1 -0
  89. package/dist/connection-panel/instructions.d.ts +5 -0
  90. package/dist/connection-panel/instructions.d.ts.map +1 -0
  91. package/dist/connection-panel/qr-panel.d.ts +14 -0
  92. package/dist/connection-panel/qr-panel.d.ts.map +1 -0
  93. package/dist/connection-panel/use-qr-refresh.d.ts +18 -0
  94. package/dist/connection-panel/use-qr-refresh.d.ts.map +1 -0
  95. package/dist/connection-panel.d.ts +39 -0
  96. package/dist/connection-panel.d.ts.map +1 -0
  97. package/dist/connection-panel.js +328 -0
  98. package/dist/conversation-list-ekq2dzca.js +133 -0
  99. package/dist/conversation-list-item-fy6t5af2.js +55 -0
  100. package/dist/conversation-thread-c60431oa.js +205 -0
  101. package/dist/entity-card.d.ts +17 -0
  102. package/dist/entity-card.d.ts.map +1 -0
  103. package/dist/entity-card.js +69 -0
  104. package/dist/entity-health-card-ggnmrtrq.js +192 -0
  105. package/dist/entity-switcher.d.ts +33 -0
  106. package/dist/entity-switcher.d.ts.map +1 -0
  107. package/dist/entity-switcher.js +137 -0
  108. package/dist/filter-bar.d.ts +41 -0
  109. package/dist/filter-bar.d.ts.map +1 -0
  110. package/dist/filter-bar.js +155 -0
  111. package/dist/footer-f7r5e42b.js +192 -0
  112. package/dist/image-lightbox-ci3arb6z.js +62 -0
  113. package/dist/kanban/drop-utils.d.ts +18 -0
  114. package/dist/kanban/drop-utils.d.ts.map +1 -0
  115. package/dist/kanban/navigation-buttons.d.ts +9 -0
  116. package/dist/kanban/navigation-buttons.d.ts.map +1 -0
  117. package/dist/kanban/scroll-indicators.d.ts +6 -0
  118. package/dist/kanban/scroll-indicators.d.ts.map +1 -0
  119. package/dist/kanban/use-keyboard-navigation.d.ts +4 -0
  120. package/dist/kanban/use-keyboard-navigation.d.ts.map +1 -0
  121. package/dist/kanban-board.d.ts +31 -0
  122. package/dist/kanban-board.d.ts.map +1 -0
  123. package/dist/kanban-board.js +128 -0
  124. package/dist/kanban-column.d.ts +13 -0
  125. package/dist/kanban-column.d.ts.map +1 -0
  126. package/dist/kanban-column.js +58 -0
  127. package/dist/legacy-kpi-card.d.ts +16 -0
  128. package/dist/legacy-kpi-card.d.ts.map +1 -0
  129. package/dist/legacy-kpi-card.js +4 -0
  130. package/dist/logout-dialog-bbv59tbi.js +93 -0
  131. package/dist/message-bubble-odjvhroq.js +110 -0
  132. package/dist/message-composer-m2n6soo7.js +59 -0
  133. package/dist/navigation-buttons-e77mshh0.js +65 -0
  134. package/dist/navigation-hotcrqoq.js +115 -0
  135. package/dist/save-bar.d.ts +15 -0
  136. package/dist/save-bar.d.ts.map +1 -0
  137. package/dist/save-bar.js +38 -0
  138. package/dist/scroll-indicators-gvxapogd.js +39 -0
  139. package/dist/settings-layout.d.ts +20 -0
  140. package/dist/settings-layout.d.ts.map +1 -0
  141. package/dist/settings-layout.js +56 -0
  142. package/dist/source-selector.d.ts +18 -0
  143. package/dist/source-selector.d.ts.map +1 -0
  144. package/dist/source-selector.js +59 -0
  145. package/dist/status-distribution-chart-oucedzv7.js +87 -0
  146. package/dist/status-filters.d.ts +12 -0
  147. package/dist/status-filters.d.ts.map +1 -0
  148. package/dist/status-filters.js +31 -0
  149. package/dist/stepper.d.ts +15 -0
  150. package/dist/stepper.d.ts.map +1 -0
  151. package/dist/stepper.js +42 -0
  152. package/dist/time-range-picker-mu38hul4.js +123 -0
  153. package/dist/top-entities-table-o7xzpq96.js +78 -0
  154. package/dist/use-keyboard-navigation-he1xmriz.js +52 -0
  155. package/dist/whatsapp-audio-player-nooty6jb.js +277 -0
  156. package/llms.txt +98 -4
  157. package/package.json +191 -46
  158. package/src/analytics/ai-cost-card.tsx +31 -0
  159. package/src/analytics/ai-performance-card.tsx +48 -0
  160. package/src/analytics/analytics-card.tsx +14 -0
  161. package/src/analytics/analytics-skeleton.tsx +15 -0
  162. package/src/analytics/entity-health-card.tsx +50 -0
  163. package/src/analytics/status-distribution-chart.tsx +20 -0
  164. package/src/analytics/time-range-picker.tsx +43 -0
  165. package/src/analytics/top-entities-table.tsx +24 -0
  166. package/src/analytics/types.ts +43 -0
  167. package/src/analytics.ts +10 -0
  168. package/src/app-shell/drawer-focus.ts +42 -0
  169. package/src/app-shell/footer.tsx +160 -0
  170. package/src/app-shell/logout-dialog.tsx +68 -0
  171. package/src/app-shell/navigation.tsx +147 -0
  172. package/src/app-shell/types.ts +57 -0
  173. package/src/app-shell.tsx +166 -0
  174. package/src/area-chart.tsx +85 -0
  175. package/src/audio-player.tsx +74 -0
  176. package/src/auth-layout.tsx +132 -0
  177. package/src/auth-provider.tsx +29 -0
  178. package/src/bar-chart.tsx +78 -0
  179. package/src/channel-settings.tsx +76 -0
  180. package/src/chart-card.tsx +45 -0
  181. package/src/chat-avatar.tsx +57 -0
  182. package/src/chat-date-divider.tsx +18 -0
  183. package/src/chat-header.tsx +24 -0
  184. package/src/chat-layout.tsx +41 -0
  185. package/src/chat-time-divider.tsx +20 -0
  186. package/src/chat-types.ts +32 -0
  187. package/src/chat.ts +21 -0
  188. package/src/command-palette.tsx +110 -0
  189. package/src/compat-chat/chat-avatar.tsx +23 -0
  190. package/src/compat-chat/chat-header.tsx +12 -0
  191. package/src/compat-chat/chat-layout.tsx +14 -0
  192. package/src/compat-chat/conversation-list-item.tsx +27 -0
  193. package/src/compat-chat/conversation-list.tsx +49 -0
  194. package/src/compat-chat/conversation-thread.tsx +92 -0
  195. package/src/compat-chat/date-divider.tsx +7 -0
  196. package/src/compat-chat/empty-chat-view.tsx +9 -0
  197. package/src/compat-chat/image-lightbox.tsx +22 -0
  198. package/src/compat-chat/message-bubble.tsx +28 -0
  199. package/src/compat-chat/message-composer.tsx +26 -0
  200. package/src/compat-chat/time-divider.tsx +5 -0
  201. package/src/compat-chat/time-grouping.ts +19 -0
  202. package/src/compat-chat/types.ts +31 -0
  203. package/src/compat-chat/whatsapp-audio-player.tsx +100 -0
  204. package/src/compat.ts +8 -0
  205. package/src/confirm-dialog.tsx +85 -0
  206. package/src/connection-panel/connected-panel.tsx +106 -0
  207. package/src/connection-panel/disconnected-panel.tsx +48 -0
  208. package/src/connection-panel/instructions.tsx +9 -0
  209. package/src/connection-panel/qr-panel.tsx +62 -0
  210. package/src/connection-panel/use-qr-refresh.ts +52 -0
  211. package/src/connection-panel.tsx +141 -0
  212. package/src/conversation-list-item.tsx +53 -0
  213. package/src/conversation-list.tsx +67 -0
  214. package/src/conversation-thread.tsx +64 -0
  215. package/src/detail-panel.tsx +123 -0
  216. package/src/document-panel.tsx +109 -0
  217. package/src/document-reader.tsx +29 -0
  218. package/src/empty-chat-view.tsx +25 -0
  219. package/src/entity-card.tsx +74 -0
  220. package/src/entity-health-card.tsx +53 -0
  221. package/src/entity-switcher.tsx +126 -0
  222. package/src/fetching-indicator.tsx +16 -0
  223. package/src/field-array-form.tsx +166 -0
  224. package/src/filter-bar.tsx +203 -0
  225. package/src/image-viewer.tsx +129 -0
  226. package/src/kanban/drop-utils.ts +31 -0
  227. package/src/kanban/navigation-buttons.tsx +80 -0
  228. package/src/kanban/scroll-indicators.tsx +47 -0
  229. package/src/kanban/use-keyboard-navigation.ts +43 -0
  230. package/src/kanban-board.tsx +160 -0
  231. package/src/kanban-column.tsx +62 -0
  232. package/src/legacy-kpi-card.tsx +38 -0
  233. package/src/line-chart.tsx +58 -0
  234. package/src/list-item.tsx +63 -0
  235. package/src/list-pane.tsx +143 -0
  236. package/src/login-form.tsx +128 -0
  237. package/src/message-bubble.tsx +70 -0
  238. package/src/message-composer.tsx +52 -0
  239. package/src/pie-chart.tsx +70 -0
  240. package/src/radar-chart.tsx +55 -0
  241. package/src/radial-chart.tsx +103 -0
  242. package/src/recording-overlay.tsx +159 -0
  243. package/src/save-bar.tsx +35 -0
  244. package/src/settings-layout.tsx +79 -0
  245. package/src/source-selector.tsx +71 -0
  246. package/src/split-workspace.tsx +74 -0
  247. package/src/stat-card.tsx +166 -0
  248. package/src/stats-grid.tsx +23 -0
  249. package/src/status-filters.tsx +28 -0
  250. package/src/stepper.tsx +47 -0
  251. package/src/table-pane.tsx +194 -0
  252. package/src/time-range-picker.tsx +89 -0
  253. package/src/workspace-back-button.tsx +32 -0
  254. package/dist/chat-types.test.d.ts +0 -2
  255. package/dist/chat-types.test.d.ts.map +0 -1
@@ -0,0 +1,24 @@
1
+ "use client";
2
+
3
+ import { BarChart2 } from "lucide-react";
4
+ import { Badge } from "@lindaui/ui/compat";
5
+ import type { EntityCell, TopEntitiesTableProps } from "./types";
6
+
7
+ export function TopEntitiesTable({ columns, rows, emptyLabel, showRank = true }: TopEntitiesTableProps) {
8
+ if (!rows.length) return <div className="flex flex-col items-center justify-center gap-2 py-12"><BarChart2 className="h-8 w-8 text-muted" /><p className="text-muted text-sm">{emptyLabel}</p></div>;
9
+ return <div className="overflow-x-auto"><table className="w-full text-sm">
10
+ <thead><tr className="border-border border-b">
11
+ {showRank ? <th className="pr-3 pb-3 text-left font-medium text-muted text-xs uppercase tracking-wider">#</th> : null}
12
+ {columns.map((col) => <th className={`pr-3 pb-3 font-medium text-muted text-xs uppercase tracking-wider ${col.align === "right" ? "text-right" : "text-left"}`} key={col.key}>{col.label}</th>)}
13
+ </tr></thead>
14
+ <tbody className="divide-y divide-border">{rows.map((row, i) => <tr className="group transition-colors duration-100 hover:bg-surface-secondary/60" key={row.id}>
15
+ {showRank ? <td className="py-3 pr-3"><span className="font-mono text-muted text-xs tabular-nums">{String(i + 1).padStart(2, "0")}</span></td> : null}
16
+ {row.cells.map((cell, ci) => <Cell cell={cell} key={columns[ci]?.key ?? String(ci)} />)}
17
+ </tr>)}</tbody>
18
+ </table></div>;
19
+ }
20
+ function Cell({ cell }: { cell: EntityCell }) {
21
+ const isRight = cell.align === "right";
22
+ if (cell.badge) return <td className={`py-3 ${isRight ? "text-right" : "pr-3"}`}><Badge color={cell.badge.color} size="sm" variant="flat">{cell.badge.label}</Badge></td>;
23
+ return <td className={`py-3 pr-3 text-sm ${isRight ? "text-right text-muted tabular-nums" : "font-medium text-foreground"}`}>{cell.value}</td>;
24
+ }
@@ -0,0 +1,43 @@
1
+ import type { DateValue } from "@internationalized/date";
2
+ import type { RangeValue } from "@react-types/shared";
3
+ import type { LucideIcon } from "lucide-react";
4
+ import type { ReactNode } from "react";
5
+
6
+ /** Presentation-only analytics types. Domain mapping stays in the consumer. */
7
+ export interface StatusDatum { color: string; count: number; key: string; label: string; }
8
+ export interface StatusDistributionChartProps { data: StatusDatum[]; emptyLabel: string; height?: number; valueLabel: string; }
9
+ export interface EntityCell {
10
+ align?: "left" | "right";
11
+ badge?: { color: "default" | "primary" | "secondary" | "success" | "warning" | "danger"; label: string };
12
+ value: string;
13
+ }
14
+ export interface TopEntityRow { cells: EntityCell[]; id: string; name: string; }
15
+ export interface TopEntitiesColumn { align?: "left" | "right"; key: string; label: string; }
16
+ export interface TopEntitiesTableProps { columns: TopEntitiesColumn[]; emptyLabel: string; rows: TopEntityRow[]; showRank?: boolean; }
17
+ export type HealthStatus = "at_risk" | "critical" | "healthy";
18
+ export interface HealthEntity { id: string; name: string; score: number; status: HealthStatus; }
19
+ export interface HealthCardLabels {
20
+ allHealthy: string;
21
+ atRisk: string;
22
+ critical: string;
23
+ criticalBadge: (count: number) => string;
24
+ healthy: string;
25
+ idleNote: string;
26
+ scoreLabel: (score: number) => string;
27
+ statusLabel: (status: HealthStatus) => string;
28
+ title: string;
29
+ }
30
+ export interface EntityHealthCardProps { entities: HealthEntity[]; labels: HealthCardLabels; renderEntityLink?: (entity: HealthEntity, children: ReactNode) => ReactNode; }
31
+ export interface AiVsHumanData { aiConverted: number; aiLeads: number; aiRate: number; humanConverted: number; humanLeads: number; humanRate: number; }
32
+ export interface AiPerformanceCardProps { aiVsHuman: AiVsHumanData; avgResponseTimeMinutes: number; containmentRate: number; t: (key: string, opts?: Record<string, unknown>) => string; }
33
+ export interface AiCostData { available: boolean; conversionsCount: number; costPerConversion: number; totalCostUsd: number; }
34
+ export interface AiCostCardProps { aiCost: AiCostData | undefined; isLoading: boolean; t: (key: string, opts?: Record<string, unknown>) => string; }
35
+ export type Preset = "24h" | "7d" | "30d" | "custom";
36
+ export interface TimeRangePickerProps {
37
+ customRange: RangeValue<DateValue> | null;
38
+ onCustomRangeChange: (v: RangeValue<DateValue> | null) => void;
39
+ onPresetChange: (p: Preset) => void;
40
+ preset: Preset;
41
+ t: (key: string) => string;
42
+ }
43
+ export interface AnalyticsCardProps { action?: ReactNode; children: ReactNode; icon: LucideIcon; title: string; }
@@ -0,0 +1,10 @@
1
+ /** Generic analytics migration API; the existing individual blocks remain unchanged. */
2
+ export { AiCostCard } from "./analytics/ai-cost-card";
3
+ export { AiPerformanceCard } from "./analytics/ai-performance-card";
4
+ export { AnalyticsCard } from "./analytics/analytics-card";
5
+ export { AnalyticsSkeleton, FetchingIndicator } from "./analytics/analytics-skeleton";
6
+ export { EntityHealthCard } from "./analytics/entity-health-card";
7
+ export { StatusDistributionChart } from "./analytics/status-distribution-chart";
8
+ export { TimeRangePicker } from "./analytics/time-range-picker";
9
+ export { TopEntitiesTable } from "./analytics/top-entities-table";
10
+ export type { AiCostCardProps, AiCostData, AiPerformanceCardProps, AiVsHumanData, AnalyticsCardProps, EntityCell, EntityHealthCardProps, HealthCardLabels, HealthEntity, HealthStatus, Preset, StatusDatum, StatusDistributionChartProps, TimeRangePickerProps, TopEntitiesColumn, TopEntitiesTableProps, TopEntityRow } from "./analytics/types";
@@ -0,0 +1,42 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { KeyboardEvent } from "react";
3
+
4
+ /** Add keyboard focus management without changing the original drawer DOM or animations. */
5
+ export function useDrawerFocus(isOpen: boolean, onClose: () => void) {
6
+ const drawerRef = useRef<HTMLElement>(null);
7
+ const triggerRef = useRef<HTMLButtonElement>(null);
8
+ const wasOpen = useRef(false);
9
+
10
+ useEffect(() => {
11
+ if (isOpen) {
12
+ drawerRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
13
+ } else if (wasOpen.current) {
14
+ triggerRef.current?.focus();
15
+ }
16
+ wasOpen.current = isOpen;
17
+ }, [isOpen]);
18
+
19
+ const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
20
+ // React portals bubble through their owner: do not dismiss a drawer behind a menu/modal.
21
+ if (!isOpen || !event.currentTarget.contains(event.target as Node)) return;
22
+ if (event.key === "Escape") {
23
+ event.preventDefault();
24
+ onClose();
25
+ return;
26
+ }
27
+ if (event.key !== "Tab") return;
28
+ const focusable = Array.from(event.currentTarget.querySelectorAll<HTMLElement>(
29
+ 'a[href], button:not(:disabled), input:not(:disabled), [tabindex="0"]'
30
+ )).filter((element) => !element.closest('[inert], [aria-hidden="true"]')
31
+ && element.getAttribute("aria-disabled") !== "true");
32
+ const first = focusable[0];
33
+ const last = focusable[focusable.length - 1];
34
+ const activeElement = event.currentTarget.ownerDocument.activeElement;
35
+ if ((event.shiftKey && activeElement === first)
36
+ || (!event.shiftKey && activeElement === last)) {
37
+ event.preventDefault();
38
+ (event.shiftKey ? last : first)?.focus();
39
+ }
40
+ };
41
+ return { drawerRef, triggerRef, onKeyDown };
42
+ }
@@ -0,0 +1,160 @@
1
+ "use client";
2
+
3
+ import { Avatar, Button, Tooltip } from "@lindaui/ui/compound";
4
+ import { cn } from "@lindaui/ui/cn";
5
+ import { Download, LogOut, Moon, Sun, Wifi, WifiOff } from "lucide-react";
6
+ import { useLogoutDialog } from "./logout-dialog";
7
+
8
+ export interface AppShellUser {
9
+ displayName?: string;
10
+ avatarSrc?: string;
11
+ avatarAlt?: string;
12
+ initials?: string;
13
+ }
14
+
15
+ export interface AppShellFooterLabels {
16
+ connected: string;
17
+ disconnected: string;
18
+ installApp: string;
19
+ lightTheme: string;
20
+ darkTheme: string;
21
+ lightThemeTooltip: string;
22
+ darkThemeTooltip: string;
23
+ logoutTrigger: string;
24
+ logoutTitle: string;
25
+ logoutDescription: string;
26
+ logoutCancel: string;
27
+ logoutConfirm: string;
28
+ logoutPending: string;
29
+ logoutError: string;
30
+ }
31
+
32
+ export interface AppShellFooterProps {
33
+ className?: string;
34
+ isCollapsed?: boolean;
35
+ isConnected?: boolean;
36
+ showConnectionStatus?: boolean;
37
+ isInstallable?: boolean;
38
+ onInstall?: () => void;
39
+ mounted?: boolean;
40
+ theme?: string;
41
+ onToggleTheme?: () => void;
42
+ user?: AppShellUser | null;
43
+ onSignOut: () => void | Promise<void>;
44
+ labels?: Partial<AppShellFooterLabels>;
45
+ }
46
+
47
+ const defaultLabels: AppShellFooterLabels = {
48
+ connected: "Connected",
49
+ disconnected: "Disconnected",
50
+ installApp: "Install app",
51
+ lightTheme: "Light mode",
52
+ darkTheme: "Dark mode",
53
+ lightThemeTooltip: "Light mode",
54
+ darkThemeTooltip: "Dark mode",
55
+ logoutTrigger: "Sign out",
56
+ logoutTitle: "Sign out?",
57
+ logoutDescription: "You will need to sign in again to access your account.",
58
+ logoutCancel: "Cancel",
59
+ logoutConfirm: "Sign out",
60
+ logoutPending: "Signing out…",
61
+ logoutError: "Unable to sign out. Please try again.",
62
+ };
63
+
64
+ /** Connection, installation, theme and account actions; all business effects are injected. */
65
+ export function AppShellFooter({
66
+ className, isCollapsed = false, isConnected = false, showConnectionStatus = true,
67
+ isInstallable = false, onInstall, mounted = false, theme, onToggleTheme, user,
68
+ onSignOut, labels: labelOverrides,
69
+ }: AppShellFooterProps) {
70
+ const labels = { ...defaultLabels, ...labelOverrides };
71
+ const logout = useLogoutDialog(onSignOut, labels);
72
+ const statusLabel = isConnected ? labels.connected : labels.disconnected;
73
+ const themeLabel = theme === "dark" ? labels.lightTheme : labels.darkTheme;
74
+ const themeTooltip = theme === "dark" ? labels.lightThemeTooltip : labels.darkThemeTooltip;
75
+
76
+ return (
77
+ <div className={cn("space-y-2 border-border border-t px-2 py-4", className)}>
78
+ {showConnectionStatus ? (
79
+ isCollapsed ? (
80
+ <Tooltip>
81
+ <Tooltip.Trigger aria-label={statusLabel}>
82
+ <div className={cn(
83
+ "mx-1 flex justify-center rounded-lg py-2",
84
+ isConnected ? "bg-success/10" : "bg-danger/10"
85
+ )}>
86
+ {isConnected ? <Wifi className="text-success" size={16} /> : <WifiOff className="text-danger" size={16} />}
87
+ </div>
88
+ </Tooltip.Trigger>
89
+ <Tooltip.Content placement="right">{statusLabel}</Tooltip.Content>
90
+ </Tooltip>
91
+ ) : (
92
+ <div className={cn(
93
+ "flex items-center justify-center gap-2 rounded-lg px-3 py-2.5",
94
+ isConnected ? "bg-success/10" : "bg-danger/10"
95
+ )}>
96
+ {isConnected ? (
97
+ <><Wifi className="text-success" size={16} /><span className="font-medium text-success text-xs">{statusLabel}</span></>
98
+ ) : (
99
+ <><WifiOff className="text-danger" size={16} /><span className="font-medium text-danger text-xs">{statusLabel}</span></>
100
+ )}
101
+ </div>
102
+ )
103
+ ) : null}
104
+
105
+ {isInstallable && !isCollapsed ? (
106
+ <Button className="w-full text-xs" onPress={onInstall} size="sm" variant="tertiary">
107
+ <Download size={14} />{labels.installApp}
108
+ </Button>
109
+ ) : null}
110
+
111
+ {mounted ? (
112
+ isCollapsed ? (
113
+ <Tooltip>
114
+ <Tooltip.Trigger>
115
+ <Button
116
+ aria-label={themeTooltip}
117
+ className="w-full text-muted hover:text-foreground"
118
+ isIconOnly onPress={onToggleTheme} size="sm" variant="ghost"
119
+ >{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}</Button>
120
+ </Tooltip.Trigger>
121
+ <Tooltip.Content placement="right">{themeTooltip}</Tooltip.Content>
122
+ </Tooltip>
123
+ ) : (
124
+ <Button className="w-full text-xs" onPress={onToggleTheme} size="sm" variant="tertiary">
125
+ {theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}{themeLabel}
126
+ </Button>
127
+ )
128
+ ) : null}
129
+
130
+ {isCollapsed ? (
131
+ <Tooltip>
132
+ <Tooltip.Trigger>
133
+ <Button
134
+ aria-label={labels.logoutTrigger}
135
+ className="w-full text-muted hover:text-danger"
136
+ isIconOnly onPress={logout.open} size="sm" variant="ghost"
137
+ ><LogOut size={16} /></Button>
138
+ </Tooltip.Trigger>
139
+ <Tooltip.Content placement="right">{labels.logoutTrigger}</Tooltip.Content>
140
+ </Tooltip>
141
+ ) : (
142
+ <div className="flex items-center gap-2 px-2">
143
+ <Avatar className="flex-shrink-0" color="default" size="sm">
144
+ <Avatar.Image alt={user?.avatarAlt ?? ""} src={user?.avatarSrc} />
145
+ <Avatar.Fallback>{user?.initials}</Avatar.Fallback>
146
+ </Avatar>
147
+ <div className="min-w-0 flex-1">
148
+ <p className="truncate font-medium text-xs">{user?.displayName}</p>
149
+ </div>
150
+ <Button
151
+ aria-label={labels.logoutTrigger}
152
+ className="text-muted hover:text-danger"
153
+ isIconOnly onPress={logout.open} size="sm" variant="ghost"
154
+ ><LogOut size={14} /></Button>
155
+ </div>
156
+ )}
157
+ {logout.dialog}
158
+ </div>
159
+ );
160
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { Button, Modal, Spinner } from "@lindaui/ui/compound";
4
+ import { useRef, useState } from "react";
5
+ import type { AppShellFooterLabels } from "./footer";
6
+
7
+ export function useLogoutDialog(onSignOut: () => void | Promise<void>, labels: AppShellFooterLabels) {
8
+ const [isOpen, setIsOpen] = useState(false);
9
+ const [isPending, setIsPending] = useState(false);
10
+ const [error, setError] = useState<string | null>(null);
11
+ const pending = useRef(false);
12
+
13
+ const onOpenChange = (open: boolean) => {
14
+ if (pending.current) return;
15
+ setError(null);
16
+ setIsOpen(open);
17
+ };
18
+ const confirm = async () => {
19
+ if (pending.current) return;
20
+ pending.current = true;
21
+ setIsPending(true);
22
+ setError(null);
23
+ try {
24
+ await onSignOut();
25
+ setIsOpen(false);
26
+ } catch {
27
+ setError(labels.logoutError);
28
+ } finally {
29
+ pending.current = false;
30
+ setIsPending(false);
31
+ }
32
+ };
33
+
34
+ return {
35
+ open: () => onOpenChange(true),
36
+ dialog: (
37
+ <Modal.Backdrop
38
+ isDismissable={!isPending}
39
+ isKeyboardDismissDisabled={isPending}
40
+ isOpen={isOpen}
41
+ onOpenChange={onOpenChange}
42
+ >
43
+ <Modal.Container>
44
+ <Modal.Dialog className="sm:max-w-md" role="alertdialog">
45
+ <Modal.Header><Modal.Heading>{labels.logoutTitle}</Modal.Heading></Modal.Header>
46
+ <Modal.Body>
47
+ <p className="text-muted text-sm leading-relaxed">{labels.logoutDescription}</p>
48
+ {error ? <p className="mt-3 text-danger text-sm" role="alert">{error}</p> : null}
49
+ </Modal.Body>
50
+ <Modal.Footer>
51
+ <Button autoFocus isDisabled={isPending} onPress={() => onOpenChange(false)} variant="tertiary">
52
+ {labels.logoutCancel}
53
+ </Button>
54
+ <Button isPending={isPending} onPress={confirm} variant="danger">
55
+ {({ isPending: buttonPending }) => (
56
+ <>
57
+ {buttonPending ? <Spinner color="current" size="sm" /> : null}
58
+ {buttonPending ? labels.logoutPending : labels.logoutConfirm}
59
+ </>
60
+ )}
61
+ </Button>
62
+ </Modal.Footer>
63
+ </Modal.Dialog>
64
+ </Modal.Container>
65
+ </Modal.Backdrop>
66
+ ),
67
+ };
68
+ }
@@ -0,0 +1,147 @@
1
+ "use client";
2
+
3
+ import { Tooltip } from "@lindaui/ui/compound";
4
+ import { cn } from "@lindaui/ui/cn";
5
+ import type { AppShellLinkProps, AppShellNavItem, AppShellProps } from "./types";
6
+
7
+ export function ShellLink({ renderLink, ...props }: AppShellLinkProps & Pick<AppShellProps, "renderLink">) {
8
+ return renderLink ? renderLink(props) : <a {...props} />;
9
+ }
10
+
11
+ export function SidebarNavLink({
12
+ item,
13
+ collapsed,
14
+ onClick,
15
+ renderLink,
16
+ }: {
17
+ item: AppShellNavItem;
18
+ collapsed?: boolean;
19
+ onClick?: () => void;
20
+ renderLink?: AppShellProps["renderLink"];
21
+ }) {
22
+ const Icon = item.icon;
23
+ const link = (
24
+ <ShellLink
25
+ aria-current={item.isActive ? "page" : "false"}
26
+ aria-label={item.label}
27
+ className={cn(
28
+ "relative flex items-center gap-3 rounded-lg px-3 py-2.5",
29
+ "transition-all duration-200 ease-out",
30
+ "text-muted hover:scale-[1.02] hover:bg-surface-secondary hover:text-foreground",
31
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2",
32
+ item.isActive ? "bg-accent/10 font-semibold text-accent hover:bg-accent/15" : "",
33
+ collapsed ? "justify-center px-2" : ""
34
+ )}
35
+ href={item.href}
36
+ onClick={(event) => {
37
+ if (item.onAction) {
38
+ event.preventDefault();
39
+ item.onAction();
40
+ }
41
+ onClick?.();
42
+ }}
43
+ renderLink={renderLink}
44
+ >
45
+ {item.isActive ? (
46
+ <div className="absolute top-1/2 left-0 h-5 w-1 -translate-y-1/2 rounded-r-full bg-accent" />
47
+ ) : null}
48
+ <Icon
49
+ className={cn("flex-shrink-0 transition-transform duration-200", "group-hover:scale-110")}
50
+ size={18}
51
+ />
52
+ {collapsed ? null : <span className="text-sm">{item.label}</span>}
53
+ </ShellLink>
54
+ );
55
+
56
+ return collapsed ? (
57
+ <Tooltip>
58
+ <Tooltip.Trigger aria-label={item.label}>{link}</Tooltip.Trigger>
59
+ <Tooltip.Content placement="right">{item.label}</Tooltip.Content>
60
+ </Tooltip>
61
+ ) : link;
62
+ }
63
+
64
+ function BottomNavLink({ item, floating, hasCenterButton, renderLink }: {
65
+ item: AppShellNavItem;
66
+ floating?: boolean;
67
+ hasCenterButton: boolean;
68
+ renderLink?: AppShellProps["renderLink"];
69
+ }) {
70
+ const Icon = item.icon;
71
+ return (
72
+ <ShellLink
73
+ aria-current={item.isActive ? "page" : undefined}
74
+ aria-label={item.label}
75
+ className={floating ? "absolute -top-5 left-1/2 -translate-x-1/2" : cn(
76
+ hasCenterButton
77
+ ? "flex flex-col items-center justify-center gap-1 px-4 py-2"
78
+ : "flex flex-col items-center justify-center gap-1 px-3 py-2",
79
+ "transition-all duration-200 ease-out",
80
+ hasCenterButton ? "min-w-[60px]" : "min-w-[52px]",
81
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent",
82
+ item.isActive ? "text-accent" : "text-muted hover:text-foreground active:scale-95"
83
+ )}
84
+ href={item.href}
85
+ renderLink={renderLink}
86
+ >
87
+ {floating ? (
88
+ <div className={cn(
89
+ "flex h-14 w-14 items-center justify-center rounded-full",
90
+ "bg-accent shadow-accent/40 shadow-lg",
91
+ "ring-4 ring-background",
92
+ "transition-all duration-200 ease-out",
93
+ "hover:shadow-accent/50 hover:shadow-xl active:scale-95",
94
+ item.isActive ? "scale-105" : ""
95
+ )}>
96
+ <Icon className="text-white" size={24} />
97
+ </div>
98
+ ) : (
99
+ <>
100
+ <Icon className="transition-all duration-200" size={item.isActive ? 22 : 20} />
101
+ <span className={cn(
102
+ "text-[10px] transition-all duration-200",
103
+ item.isActive ? "font-semibold" : "font-medium"
104
+ )}>{item.label}</span>
105
+ </>
106
+ )}
107
+ </ShellLink>
108
+ );
109
+ }
110
+
111
+ export function MobileBottomNavigation({ navItems, isVisiting, label, renderLink }: {
112
+ navItems: AppShellNavItem[];
113
+ isVisiting: boolean;
114
+ label: string;
115
+ renderLink?: AppShellProps["renderLink"];
116
+ }) {
117
+ const hasCenterButton = navItems.some((item) => item.isCenterButton);
118
+ const mobileItems = navItems.filter((item) => !item.hideOnMobile);
119
+ const centerItem = mobileItems.find((item) => item.isCenterButton);
120
+ const regularItems = mobileItems.filter((item) => !item.isCenterButton);
121
+ const link = (item: AppShellNavItem) => (
122
+ <BottomNavLink hasCenterButton={hasCenterButton} item={item} key={item.key} renderLink={renderLink} />
123
+ );
124
+
125
+ return (
126
+ <nav
127
+ aria-label={label}
128
+ className={hasCenterButton ? cn(
129
+ "fixed right-0 bottom-0 left-0 z-50 border-border border-t backdrop-blur backdrop-saturate-200 lg:hidden dark:border-white/10",
130
+ isVisiting ? "bg-warning-soft/80 dark:bg-warning/20" : "!bg-transparent"
131
+ ) : "fixed right-0 bottom-0 left-0 z-50 border-border border-t bg-background backdrop-blur backdrop-saturate-200 lg:hidden dark:border-white/10"}
132
+ >
133
+ <div className={hasCenterButton
134
+ ? "relative flex h-16 items-center justify-around px-2"
135
+ : "flex h-16 items-center justify-around px-2"}>
136
+ {hasCenterButton ? (
137
+ <>
138
+ {regularItems.slice(0, 2).map(link)}
139
+ {centerItem ? <BottomNavLink floating hasCenterButton item={centerItem} renderLink={renderLink} /> : null}
140
+ <div className="min-w-[60px]" />
141
+ {regularItems.slice(2, 4).map(link)}
142
+ </>
143
+ ) : mobileItems.map(link)}
144
+ </div>
145
+ </nav>
146
+ );
147
+ }
@@ -0,0 +1,57 @@
1
+ import type { AnchorHTMLAttributes, ComponentType, ReactNode } from "react";
2
+
3
+ export interface AppShellNavItem {
4
+ key: string;
5
+ label: string;
6
+ href: string;
7
+ icon: ComponentType<{ className?: string; size?: number }>;
8
+ isActive?: boolean;
9
+ hideOnMobile?: boolean;
10
+ isCenterButton?: boolean;
11
+ /** Intercept sidebar/drawer navigation, for example to open a settings dialog. */
12
+ onAction?: () => void;
13
+ }
14
+
15
+ export interface AppShellBrand {
16
+ label: string;
17
+ href: string;
18
+ logoSrc?: string;
19
+ }
20
+
21
+ export type AppShellLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
22
+ href: string;
23
+ children: ReactNode;
24
+ };
25
+
26
+ export interface AppShellImageProps {
27
+ alt: string;
28
+ className: string;
29
+ height: number;
30
+ src: string;
31
+ width: number;
32
+ }
33
+
34
+ export interface AppShellLabels {
35
+ collapseSidebar: string;
36
+ expandSidebar: string;
37
+ openMenu: string;
38
+ closeMenu: string;
39
+ mainNavigation: string;
40
+ bottomNavigation: string;
41
+ }
42
+
43
+ export interface AppShellProps {
44
+ brand: AppShellBrand;
45
+ children: ReactNode;
46
+ navItems: AppShellNavItem[];
47
+ contentTopSlot?: ReactNode;
48
+ headerRightSlot?: ReactNode;
49
+ sidebarTopSlot?: ReactNode | ((isCollapsed: boolean) => ReactNode);
50
+ sidebarFooterSlot?: ReactNode | ((isCollapsed: boolean) => ReactNode);
51
+ isVisiting?: boolean;
52
+ defaultCollapsed?: boolean;
53
+ labels?: Partial<AppShellLabels>;
54
+ /** Inject a router link without coupling the block to any framework. */
55
+ renderLink?: (props: AppShellLinkProps) => ReactNode;
56
+ renderImage?: (props: AppShellImageProps) => ReactNode;
57
+ }