@lindaui/blocks 0.4.2 → 0.5.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 (255) hide show
  1. package/README.md +56 -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 +87 -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,53 @@
1
+ "use client";
2
+ import type { ReactNode } from "react";
3
+ import { ChartCard } from "./chart-card";
4
+ import { Badge } from "@lindaui/ui/badge";
5
+ import { Divider } from "@lindaui/ui/divider";
6
+
7
+ export type EntityHealthStatus = "default" | "success" | "warning" | "danger" | "info";
8
+
9
+ export interface EntityHealthItem {
10
+ id: string;
11
+ label: string;
12
+ value: ReactNode;
13
+ status?: EntityHealthStatus;
14
+ statusLabel?: string;
15
+ }
16
+
17
+ export interface EntityHealthCardProps {
18
+ title: ReactNode;
19
+ description?: ReactNode;
20
+ items: EntityHealthItem[];
21
+ emptyLabel?: string;
22
+ }
23
+
24
+ /** Lista de salud de entidades genérica (ex-GymHealthCard, generalizado). */
25
+ export function EntityHealthCard({
26
+ title,
27
+ description,
28
+ items,
29
+ emptyLabel = "Sin datos",
30
+ }: EntityHealthCardProps) {
31
+ return (
32
+ <ChartCard title={title} description={description}>
33
+ {items.length === 0 ? (
34
+ <p className="py-4 text-center text-sm text-muted-foreground">{emptyLabel}</p>
35
+ ) : (
36
+ <div className="flex flex-col">
37
+ {items.map((item, i) => (
38
+ <div key={item.id}>
39
+ {i > 0 && <Divider />}
40
+ <div className="flex items-center justify-between gap-2 py-2.5">
41
+ <span className="truncate text-sm">{item.label}</span>
42
+ <div className="flex shrink-0 items-center gap-2">
43
+ <span className="text-sm font-semibold tabular-nums">{item.value}</span>
44
+ {item.status && <Badge variant={item.status}>{item.statusLabel ?? item.status}</Badge>}
45
+ </div>
46
+ </div>
47
+ </div>
48
+ ))}
49
+ </div>
50
+ )}
51
+ </ChartCard>
52
+ );
53
+ }
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ import { Avatar, Chip, Description, Dropdown, Header, Label, Separator } from "@lindaui/ui/compound";
4
+ import { cn } from "@lindaui/ui/cn";
5
+ import { ChevronDown } from "lucide-react";
6
+ import type { ReactNode } from "react";
7
+
8
+ export interface EntitySwitcherItem {
9
+ identifier: string;
10
+ name: string;
11
+ description?: string | null;
12
+ }
13
+
14
+ export interface EntitySwitcherAction {
15
+ identifier: string;
16
+ label: string;
17
+ icon?: ReactNode;
18
+ className?: string;
19
+ onAction: () => void;
20
+ }
21
+
22
+ export interface EntitySwitcherLabels {
23
+ trigger: string;
24
+ menu: string;
25
+ section: string;
26
+ fallbackDescription?: string;
27
+ visiting?: string;
28
+ }
29
+
30
+ export interface EntitySwitcherProps<T extends EntitySwitcherItem = EntitySwitcherItem> {
31
+ currentEntity?: T | null;
32
+ entities: T[];
33
+ onSelect: (entity: T) => void;
34
+ labels: EntitySwitcherLabels;
35
+ isCollapsed?: boolean;
36
+ isVisiting?: boolean;
37
+ actions?: EntitySwitcherAction[];
38
+ renderAvatar?: (entity: T) => ReactNode;
39
+ }
40
+
41
+ /** Controlled, domain-neutral entity selector with the original compact sidebar anatomy. */
42
+ export function EntitySwitcher<T extends EntitySwitcherItem>({
43
+ currentEntity, entities, onSelect, labels, isCollapsed = false,
44
+ isVisiting = false, actions = [], renderAvatar,
45
+ }: EntitySwitcherProps<T>) {
46
+ if (!currentEntity) return null;
47
+
48
+ return (
49
+ <div className={cn("px-2 py-2", isCollapsed ? "flex justify-center" : "")}>
50
+ <Dropdown>
51
+ <Dropdown.Trigger aria-label={labels.trigger} className="w-full min-w-0">
52
+ <div className={cn(
53
+ "flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg p-2 transition-colors hover:bg-surface-secondary",
54
+ isVisiting ? "ring-2 ring-warning ring-offset-1" : "",
55
+ isCollapsed ? "justify-center" : ""
56
+ )}>
57
+ <Avatar className="flex-shrink-0 transition-transform" color={isVisiting ? "warning" : "default"} size="sm">
58
+ {renderAvatar ? renderAvatar(currentEntity) : (
59
+ <Avatar.Fallback>{currentEntity.name.charAt(0).toUpperCase()}</Avatar.Fallback>
60
+ )}
61
+ </Avatar>
62
+ {!isCollapsed && (
63
+ <>
64
+ <div className="min-w-0 flex-1 overflow-hidden">
65
+ <div className="flex min-w-0 items-center gap-1.5">
66
+ <p className="truncate font-semibold text-sm">{currentEntity.name}</p>
67
+ {isVisiting && labels.visiting ? (
68
+ <Chip className="shrink-0" color="warning" size="sm" variant="soft">{labels.visiting}</Chip>
69
+ ) : null}
70
+ </div>
71
+ <p className="truncate text-muted text-xs">{currentEntity.description || labels.fallbackDescription}</p>
72
+ </div>
73
+ <ChevronDown className="shrink-0 text-muted" size={16} />
74
+ </>
75
+ )}
76
+ </div>
77
+ </Dropdown.Trigger>
78
+ <Dropdown.Popover className="rounded-[14px]" placement="bottom start">
79
+ <Dropdown.Menu
80
+ aria-label={labels.menu}
81
+ className="w-60 [&_[data-slot=menu-item]]:rounded-lg"
82
+ onAction={(key) => {
83
+ const action = actions.find((item) => item.identifier === String(key));
84
+ if (action) {
85
+ action.onAction();
86
+ return;
87
+ }
88
+ const entity = entities.find((item) => item.identifier === String(key));
89
+ if (entity) onSelect(entity);
90
+ }}
91
+ selectedKeys={new Set([currentEntity.identifier])}
92
+ selectionMode="single"
93
+ >
94
+ <Dropdown.Section>
95
+ <Header>{labels.section}</Header>
96
+ {entities.map((entity) => (
97
+ <Dropdown.Item id={entity.identifier} key={entity.identifier} textValue={entity.name}>
98
+ <Dropdown.ItemIndicator />
99
+ <Avatar className="flex-shrink-0" size="sm">
100
+ {renderAvatar ? renderAvatar(entity) : (
101
+ <Avatar.Fallback>{entity.name.charAt(0).toUpperCase()}</Avatar.Fallback>
102
+ )}
103
+ </Avatar>
104
+ <div className="flex min-w-0 flex-col">
105
+ <Label>{entity.name}</Label>
106
+ <Description>{entity.description}</Description>
107
+ </div>
108
+ </Dropdown.Item>
109
+ ))}
110
+ </Dropdown.Section>
111
+ {actions.length > 0 ? <Separator /> : null}
112
+ {actions.length > 0 ? (
113
+ <Dropdown.Section>
114
+ {actions.map((action) => (
115
+ <Dropdown.Item className={action.className} id={action.identifier} key={action.identifier} textValue={action.label}>
116
+ {action.icon}<Label>{action.label}</Label>
117
+ </Dropdown.Item>
118
+ ))}
119
+ </Dropdown.Section>
120
+ ) : null}
121
+ </Dropdown.Menu>
122
+ </Dropdown.Popover>
123
+ </Dropdown>
124
+ </div>
125
+ );
126
+ }
@@ -0,0 +1,16 @@
1
+ "use client";
2
+ import { Spinner } from "@lindaui/ui/spinner";
3
+
4
+ export interface FetchingIndicatorProps {
5
+ label?: string;
6
+ }
7
+
8
+ /** Pill chico de "actualizando" para superponer sobre contenido ya cargado. */
9
+ export function FetchingIndicator({ label = "Actualizando…" }: FetchingIndicatorProps) {
10
+ return (
11
+ <div className="inline-flex items-center gap-1.5 rounded-full bg-secondary px-2.5 py-1 text-xs text-muted-foreground">
12
+ <Spinner aria-label={label} className="size-3" />
13
+ <span>{label}</span>
14
+ </div>
15
+ );
16
+ }
@@ -0,0 +1,166 @@
1
+ "use client";
2
+ import { type ReactNode } from "react";
3
+ import { useForm, useFieldArray, Controller } from "react-hook-form";
4
+ import { Plus, X } from "lucide-react";
5
+ import { Button } from "@lindaui/ui/button";
6
+ import { Input } from "@lindaui/ui/input";
7
+ import { Select } from "@lindaui/ui/select";
8
+
9
+ export interface FieldArrayFormValues {
10
+ category: string;
11
+ name: string;
12
+ entries: { key: string; value: string }[];
13
+ }
14
+
15
+ export interface FieldArrayFormProps {
16
+ onSubmit: (values: FieldArrayFormValues) => void | Promise<void>;
17
+ options: { value: string; label: string }[];
18
+ categoryLabel?: string;
19
+ nameLabel?: string;
20
+ namePlaceholder?: string;
21
+ entriesLabel?: string;
22
+ addLabel?: string;
23
+ removeLabel?: string;
24
+ keyPlaceholder?: string;
25
+ valuePlaceholder?: string;
26
+ emptyHint?: ReactNode;
27
+ submitLabel?: string;
28
+ loading?: boolean;
29
+ defaultValues?: Partial<FieldArrayFormValues>;
30
+ }
31
+
32
+ export function FieldArrayForm({
33
+ onSubmit,
34
+ options,
35
+ categoryLabel = "Categoría",
36
+ nameLabel = "Nombre",
37
+ namePlaceholder,
38
+ entriesLabel = "Entradas",
39
+ addLabel = "Agregar entrada",
40
+ removeLabel = "Eliminar entrada",
41
+ keyPlaceholder = "Clave",
42
+ valuePlaceholder = "Valor",
43
+ emptyHint,
44
+ submitLabel = "Guardar",
45
+ loading = false,
46
+ defaultValues,
47
+ }: FieldArrayFormProps) {
48
+ const { control, handleSubmit } = useForm<FieldArrayFormValues>({
49
+ defaultValues: {
50
+ category: defaultValues?.category ?? options[0]?.value ?? "",
51
+ name: defaultValues?.name ?? "",
52
+ entries: defaultValues?.entries ?? [],
53
+ },
54
+ });
55
+
56
+ const { fields, append, remove } = useFieldArray({
57
+ control,
58
+ name: "entries",
59
+ });
60
+
61
+ return (
62
+ <form onSubmit={handleSubmit(onSubmit)} noValidate>
63
+ <div className="flex flex-col gap-4 px-4 py-4 sm:px-6 sm:py-5">
64
+ {/* Category Select via Controller */}
65
+ <div>
66
+ <label className="text-sm font-medium">{categoryLabel}</label>
67
+ <Controller
68
+ control={control}
69
+ name="category"
70
+ render={({ field }) => (
71
+ <Select
72
+ items={options}
73
+ value={field.value}
74
+ onChange={(val) => field.onChange(val)}
75
+ />
76
+ )}
77
+ />
78
+ </div>
79
+
80
+ {/* Name Input via Controller */}
81
+ <Controller
82
+ control={control}
83
+ name="name"
84
+ render={({ field }) => (
85
+ <Input
86
+ label={nameLabel}
87
+ placeholder={namePlaceholder}
88
+ value={field.value}
89
+ onChange={field.onChange}
90
+ onBlur={field.onBlur}
91
+ name={field.name}
92
+ />
93
+ )}
94
+ />
95
+
96
+ {/* Entries section */}
97
+ <div className="flex flex-col gap-2">
98
+ <p className="text-sm font-medium">{entriesLabel}</p>
99
+
100
+ {fields.length === 0 && emptyHint && (
101
+ <div className="text-sm text-muted-foreground">{emptyHint}</div>
102
+ )}
103
+
104
+ {fields.map((field, index) => (
105
+ <div key={field.id} className="flex flex-col gap-2 sm:flex-row sm:items-start">
106
+ <Controller
107
+ control={control}
108
+ name={`entries.${index}.key`}
109
+ render={({ field: f }) => (
110
+ <div className="min-w-0 w-full">
111
+ <Input
112
+ placeholder={keyPlaceholder}
113
+ aria-label={`${keyPlaceholder} ${index + 1}`}
114
+ value={f.value}
115
+ onChange={f.onChange}
116
+ onBlur={f.onBlur}
117
+ name={f.name}
118
+ fullWidth
119
+ />
120
+ </div>
121
+ )}
122
+ />
123
+ <Controller
124
+ control={control}
125
+ name={`entries.${index}.value`}
126
+ render={({ field: f }) => (
127
+ <div className="min-w-0 w-full">
128
+ <Input
129
+ placeholder={valuePlaceholder}
130
+ aria-label={`${valuePlaceholder} ${index + 1}`}
131
+ value={f.value}
132
+ onChange={f.onChange}
133
+ onBlur={f.onBlur}
134
+ name={f.name}
135
+ fullWidth
136
+ />
137
+ </div>
138
+ )}
139
+ />
140
+ <Button
141
+ variant="ghost"
142
+ onPress={() => remove(index)}
143
+ aria-label={removeLabel}
144
+ className="w-full sm:w-auto"
145
+ >
146
+ <X size={16} />
147
+ </Button>
148
+ </div>
149
+ ))}
150
+
151
+ <Button
152
+ variant="secondary"
153
+ onPress={() => append({ key: "", value: "" })}
154
+ >
155
+ <Plus size={16} />
156
+ {addLabel}
157
+ </Button>
158
+ </div>
159
+
160
+ <Button type="submit" isDisabled={loading}>
161
+ {submitLabel}
162
+ </Button>
163
+ </div>
164
+ </form>
165
+ );
166
+ }
@@ -0,0 +1,203 @@
1
+ "use client";
2
+
3
+ import {
4
+ Button,
5
+ Checkbox,
6
+ CheckboxGroup,
7
+ CloseButton,
8
+ InputGroup,
9
+ Popover,
10
+ TextField,
11
+ } from "@lindaui/ui/compound";
12
+ import { cn } from "@lindaui/ui/cn";
13
+ import { Filter, Search, SlidersHorizontal, type LucideIcon } from "lucide-react";
14
+ import { useState, type ReactNode } from "react";
15
+
16
+ export interface FilterOption {
17
+ key: string;
18
+ label: string;
19
+ }
20
+
21
+ export interface SortOption {
22
+ key: string;
23
+ label: string;
24
+ }
25
+
26
+ export interface FilterBarSearchProps {
27
+ className: string;
28
+ onChange: (value: string) => void;
29
+ placeholder: string;
30
+ value: string;
31
+ }
32
+
33
+ export interface FilterBarIconButtonProps {
34
+ icon: LucideIcon;
35
+ isActive: boolean;
36
+ tooltip: string;
37
+ }
38
+
39
+ export interface FilterBarProps {
40
+ className?: string;
41
+ searchValue: string;
42
+ onSearchChange: (value: string) => void;
43
+ searchPlaceholder?: string;
44
+ filterOptions?: FilterOption[];
45
+ selectedFilters?: string[];
46
+ onFilterChange?: (filters: string[]) => void;
47
+ filterLabel?: string;
48
+ clearFiltersLabel?: string;
49
+ clearSearchLabel?: string;
50
+ sortOptions?: SortOption[];
51
+ selectedSort?: string;
52
+ onSortChange?: (sort: string) => void;
53
+ sortLabel?: string;
54
+ renderSearchInput?: (props: FilterBarSearchProps) => ReactNode;
55
+ renderIconButton?: (props: FilterBarIconButtonProps) => ReactNode;
56
+ }
57
+
58
+ export function FilterBar({
59
+ searchValue,
60
+ onSearchChange,
61
+ searchPlaceholder = "Search...",
62
+ filterOptions,
63
+ selectedFilters = [],
64
+ onFilterChange,
65
+ filterLabel = "Filter",
66
+ clearFiltersLabel = "Clear filters",
67
+ clearSearchLabel = "Clear search",
68
+ sortOptions,
69
+ selectedSort,
70
+ onSortChange,
71
+ sortLabel = "Sort",
72
+ className,
73
+ renderSearchInput,
74
+ renderIconButton = (props) => <FilterIconButton {...props} />,
75
+ }: FilterBarProps) {
76
+ const [isFilterOpen, setIsFilterOpen] = useState(false);
77
+ const [isSortOpen, setIsSortOpen] = useState(false);
78
+ const hasActiveFilters = selectedFilters.length > 0;
79
+ const searchProps = {
80
+ className: "min-w-0 flex-1",
81
+ onChange: onSearchChange,
82
+ placeholder: searchPlaceholder,
83
+ value: searchValue,
84
+ };
85
+
86
+ return (
87
+ <div className={cn("flex items-center gap-2", className)}>
88
+ {renderSearchInput ? (
89
+ renderSearchInput(searchProps)
90
+ ) : (
91
+ <FilterSearchInput {...searchProps} clearLabel={clearSearchLabel} />
92
+ )}
93
+ {filterOptions && filterOptions.length > 0 ? (
94
+ <Popover isOpen={isFilterOpen} onOpenChange={setIsFilterOpen}>
95
+ {renderIconButton({ icon: Filter, isActive: hasActiveFilters, tooltip: filterLabel })}
96
+ <Popover.Content className="min-w-[200px] p-4" placement="bottom end">
97
+ <Popover.Dialog className="space-y-3">
98
+ <p className="font-semibold text-sm">{filterLabel}</p>
99
+ <CheckboxGroup
100
+ aria-label={filterLabel}
101
+ onChange={(values) => onFilterChange?.(values)}
102
+ value={selectedFilters}
103
+ >
104
+ {filterOptions.map((option) => (
105
+ <Checkbox key={option.key} value={option.key}>
106
+ <Checkbox.Content>
107
+ <Checkbox.Control>
108
+ <Checkbox.Indicator />
109
+ </Checkbox.Control>
110
+ {option.label}
111
+ </Checkbox.Content>
112
+ </Checkbox>
113
+ ))}
114
+ </CheckboxGroup>
115
+ {hasActiveFilters ? (
116
+ <Button
117
+ className="w-full text-danger"
118
+ onPress={() => onFilterChange?.([])}
119
+ size="sm"
120
+ variant="ghost"
121
+ >
122
+ {clearFiltersLabel}
123
+ </Button>
124
+ ) : null}
125
+ </Popover.Dialog>
126
+ </Popover.Content>
127
+ </Popover>
128
+ ) : null}
129
+ {sortOptions && sortOptions.length > 0 ? (
130
+ <Popover isOpen={isSortOpen} onOpenChange={setIsSortOpen}>
131
+ {renderIconButton({ icon: SlidersHorizontal, isActive: !!selectedSort, tooltip: sortLabel })}
132
+ <Popover.Content className="min-w-[200px] p-4" placement="bottom end">
133
+ <Popover.Dialog className="space-y-3">
134
+ <p className="font-semibold text-sm">{sortLabel}</p>
135
+ <div className="space-y-1">
136
+ {sortOptions.map((option) => (
137
+ <Button
138
+ className={cn(
139
+ "w-full justify-start",
140
+ selectedSort === option.key && "bg-accent-soft text-accent",
141
+ )}
142
+ key={option.key}
143
+ onPress={() => {
144
+ onSortChange?.(option.key);
145
+ setIsSortOpen(false);
146
+ }}
147
+ size="sm"
148
+ variant={selectedSort === option.key ? "tertiary" : "ghost"}
149
+ >
150
+ {option.label}
151
+ </Button>
152
+ ))}
153
+ </div>
154
+ </Popover.Dialog>
155
+ </Popover.Content>
156
+ </Popover>
157
+ ) : null}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ function FilterSearchInput({
163
+ value,
164
+ onChange,
165
+ placeholder,
166
+ className,
167
+ clearLabel,
168
+ }: FilterBarSearchProps & { clearLabel: string }) {
169
+ return (
170
+ <TextField
171
+ aria-label={placeholder}
172
+ className={cn("w-full min-w-0", className)}
173
+ onChange={onChange}
174
+ value={value}
175
+ >
176
+ <InputGroup>
177
+ <InputGroup.Prefix>
178
+ <Search className="text-muted" size={18} />
179
+ </InputGroup.Prefix>
180
+ <InputGroup.Input className="text-sm" placeholder={placeholder} />
181
+ {value ? (
182
+ <InputGroup.Suffix>
183
+ <CloseButton aria-label={clearLabel} onPress={() => onChange("")} />
184
+ </InputGroup.Suffix>
185
+ ) : null}
186
+ </InputGroup>
187
+ </TextField>
188
+ );
189
+ }
190
+
191
+ function FilterIconButton({ icon: Icon, isActive, tooltip }: FilterBarIconButtonProps) {
192
+ return (
193
+ <Button
194
+ aria-label={tooltip}
195
+ className={cn("border border-border", isActive ? "border-accent" : "")}
196
+ isIconOnly
197
+ size="md"
198
+ variant={isActive ? "tertiary" : "ghost"}
199
+ >
200
+ <Icon size={20} />
201
+ </Button>
202
+ );
203
+ }
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import type { ReactNode } from "react";
5
+ import { Scan, ZoomIn, ZoomOut, Frame } from "lucide-react";
6
+
7
+ export interface ImageViewerProps {
8
+ src?: string;
9
+ alt?: string;
10
+ annotations?: ReactNode;
11
+ caption?: string;
12
+ placeholderLabel?: string;
13
+ dim?: number;
14
+ showControls?: boolean;
15
+ minZoom?: number;
16
+ maxZoom?: number;
17
+ }
18
+
19
+ export function ImageViewer({
20
+ src,
21
+ alt = "",
22
+ annotations,
23
+ caption,
24
+ placeholderLabel,
25
+ dim = 0,
26
+ showControls = false,
27
+ minZoom = 1,
28
+ maxZoom = 2.6,
29
+ }: ImageViewerProps) {
30
+ const [zoom, setZoom] = useState(1);
31
+ const [imgOk, setImgOk] = useState(false);
32
+
33
+ const showPlaceholder = !src || !imgOk;
34
+
35
+ return (
36
+ <div className="absolute inset-0 overflow-hidden bg-viewer-bg text-viewer-foreground">
37
+ <div
38
+ className="absolute inset-0 grid place-items-center transition-transform duration-300"
39
+ style={{ transform: `scale(${zoom})` }}
40
+ >
41
+ {src && (
42
+ <img
43
+ src={src}
44
+ alt={alt}
45
+ onLoad={() => setImgOk(true)}
46
+ onError={() => setImgOk(false)}
47
+ className="max-h-full max-w-full object-contain"
48
+ style={{ display: imgOk ? "block" : "none" }}
49
+ />
50
+ )}
51
+ {showPlaceholder && (
52
+ <div className="grid place-items-center rounded-lg border border-dashed">
53
+ <div className="text-center p-4">
54
+ <Scan
55
+ className="mx-auto opacity-50"
56
+ style={{ width: 40, height: 40 }}
57
+ strokeWidth={1.3}
58
+ />
59
+ {placeholderLabel && (
60
+ <div className="mt-3 font-mono text-xs tracking-widest opacity-70">
61
+ {placeholderLabel}
62
+ </div>
63
+ )}
64
+ </div>
65
+ </div>
66
+ )}
67
+ </div>
68
+
69
+ {/* annotations slot — top-right */}
70
+ {annotations && (
71
+ <div className="absolute right-4 top-4 z-10">{annotations}</div>
72
+ )}
73
+
74
+ {/* caption — bottom-left */}
75
+ {caption && (
76
+ <div className="absolute bottom-3.5 left-4 font-mono text-[9px] opacity-40 sm:text-[10px]">
77
+ {caption}
78
+ </div>
79
+ )}
80
+
81
+ {/* zoom controls */}
82
+ {showControls && (
83
+ <div className="absolute bottom-4 left-3 z-20 flex items-center gap-1.5 sm:bottom-5 sm:left-4 sm:gap-2">
84
+ <button
85
+ type="button"
86
+ onClick={() =>
87
+ setZoom((z) => Math.max(minZoom, +(z - 0.2).toFixed(2)))
88
+ }
89
+ className="h-10 w-10 rounded-[10px] flex items-center justify-center sm:h-9 sm:w-9"
90
+ aria-label="Reducir zoom"
91
+ >
92
+ <ZoomOut className="h-[17px] w-[17px]" />
93
+ </button>
94
+ <span className="self-center min-w-9 text-center font-mono text-[11px] opacity-75">
95
+ {Math.round(zoom * 100)}%
96
+ </span>
97
+ <button
98
+ type="button"
99
+ onClick={() =>
100
+ setZoom((z) => Math.min(maxZoom, +(z + 0.2).toFixed(2)))
101
+ }
102
+ className="h-10 w-10 rounded-[10px] flex items-center justify-center sm:h-9 sm:w-9"
103
+ aria-label="Ampliar zoom"
104
+ >
105
+ <ZoomIn className="h-[17px] w-[17px]" />
106
+ </button>
107
+ <button
108
+ type="button"
109
+ onClick={() => setZoom(1)}
110
+ className="h-10 w-10 rounded-[10px] flex items-center justify-center sm:h-9 sm:w-9"
111
+ aria-label="Encuadrar"
112
+ >
113
+ <Frame className="h-4 w-4" />
114
+ </button>
115
+ </div>
116
+ )}
117
+
118
+ {/* dim overlay */}
119
+ {dim > 0 && (
120
+ <div
121
+ className="absolute inset-0 transition-opacity duration-300"
122
+ style={{
123
+ background: `color-mix(in oklch, var(--viewer-bg) ${Math.round(dim * 100)}%, transparent)`,
124
+ }}
125
+ />
126
+ )}
127
+ </div>
128
+ );
129
+ }