@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.
- package/README.md +56 -0
- package/dist/ai-cost-card-f403ypsg.js +99 -0
- package/dist/ai-performance-card-cggm09nc.js +191 -0
- package/dist/analytics/ai-cost-card.d.ts +3 -0
- package/dist/analytics/ai-cost-card.d.ts.map +1 -0
- package/dist/analytics/ai-performance-card.d.ts +3 -0
- package/dist/analytics/ai-performance-card.d.ts.map +1 -0
- package/dist/analytics/analytics-card.d.ts +3 -0
- package/dist/analytics/analytics-card.d.ts.map +1 -0
- package/dist/analytics/analytics-skeleton.d.ts +5 -0
- package/dist/analytics/analytics-skeleton.d.ts.map +1 -0
- package/dist/analytics/entity-health-card.d.ts +3 -0
- package/dist/analytics/entity-health-card.d.ts.map +1 -0
- package/dist/analytics/status-distribution-chart.d.ts +3 -0
- package/dist/analytics/status-distribution-chart.d.ts.map +1 -0
- package/dist/analytics/time-range-picker.d.ts +3 -0
- package/dist/analytics/time-range-picker.d.ts.map +1 -0
- package/dist/analytics/top-entities-table.d.ts +3 -0
- package/dist/analytics/top-entities-table.d.ts.map +1 -0
- package/dist/analytics/types.d.ts +104 -0
- package/dist/analytics/types.d.ts.map +1 -0
- package/dist/analytics-card-bcmijpt6.js +38 -0
- package/dist/analytics-skeleton-imve37du.js +164 -0
- package/dist/analytics.d.ts +11 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +8 -0
- package/dist/app-shell/drawer-focus.d.ts +8 -0
- package/dist/app-shell/drawer-focus.d.ts.map +1 -0
- package/dist/app-shell/footer.d.ts +39 -0
- package/dist/app-shell/footer.d.ts.map +1 -0
- package/dist/app-shell/logout-dialog.d.ts +6 -0
- package/dist/app-shell/logout-dialog.d.ts.map +1 -0
- package/dist/app-shell/navigation.d.ts +15 -0
- package/dist/app-shell/navigation.d.ts.map +1 -0
- package/dist/app-shell/types.d.ts +55 -0
- package/dist/app-shell/types.d.ts.map +1 -0
- package/dist/app-shell.d.ts +7 -0
- package/dist/app-shell.d.ts.map +1 -0
- package/dist/app-shell.js +254 -0
- package/dist/channel-settings.d.ts +24 -0
- package/dist/channel-settings.d.ts.map +1 -0
- package/dist/channel-settings.js +60 -0
- package/dist/chat-avatar-jdmeyf2o.js +30 -0
- package/dist/chat-layout-fwx3x26g.js +25 -0
- package/dist/chat.d.ts +22 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +66 -0
- package/dist/command-palette.d.ts +22 -0
- package/dist/command-palette.d.ts.map +1 -0
- package/dist/command-palette.js +141 -0
- package/dist/compat-chat/chat-avatar.d.ts +16 -0
- package/dist/compat-chat/chat-avatar.d.ts.map +1 -0
- package/dist/compat-chat/chat-header.d.ts +9 -0
- package/dist/compat-chat/chat-header.d.ts.map +1 -0
- package/dist/compat-chat/chat-layout.d.ts +10 -0
- package/dist/compat-chat/chat-layout.d.ts.map +1 -0
- package/dist/compat-chat/conversation-list-item.d.ts +10 -0
- package/dist/compat-chat/conversation-list-item.d.ts.map +1 -0
- package/dist/compat-chat/conversation-list.d.ts +24 -0
- package/dist/compat-chat/conversation-list.d.ts.map +1 -0
- package/dist/compat-chat/conversation-thread.d.ts +18 -0
- package/dist/compat-chat/conversation-thread.d.ts.map +1 -0
- package/dist/compat-chat/date-divider.d.ts +5 -0
- package/dist/compat-chat/date-divider.d.ts.map +1 -0
- package/dist/compat-chat/empty-chat-view.d.ts +6 -0
- package/dist/compat-chat/empty-chat-view.d.ts.map +1 -0
- package/dist/compat-chat/image-lightbox.d.ts +7 -0
- package/dist/compat-chat/image-lightbox.d.ts.map +1 -0
- package/dist/compat-chat/message-bubble.d.ts +6 -0
- package/dist/compat-chat/message-bubble.d.ts.map +1 -0
- package/dist/compat-chat/message-composer.d.ts +11 -0
- package/dist/compat-chat/message-composer.d.ts.map +1 -0
- package/dist/compat-chat/time-divider.d.ts +5 -0
- package/dist/compat-chat/time-divider.d.ts.map +1 -0
- package/dist/compat-chat/time-grouping.d.ts +5 -0
- package/dist/compat-chat/time-grouping.d.ts.map +1 -0
- package/dist/compat-chat/types.d.ts +35 -0
- package/dist/compat-chat/types.d.ts.map +1 -0
- package/dist/compat-chat/whatsapp-audio-player.d.ts +7 -0
- package/dist/compat-chat/whatsapp-audio-player.d.ts.map +1 -0
- package/dist/compat.d.ts +9 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +4 -0
- package/dist/connected-panel-lhwy25yk.js +132 -0
- package/dist/connection-panel/connected-panel.d.ts +26 -0
- package/dist/connection-panel/connected-panel.d.ts.map +1 -0
- package/dist/connection-panel/disconnected-panel.d.ts +12 -0
- package/dist/connection-panel/disconnected-panel.d.ts.map +1 -0
- package/dist/connection-panel/instructions.d.ts +5 -0
- package/dist/connection-panel/instructions.d.ts.map +1 -0
- package/dist/connection-panel/qr-panel.d.ts +14 -0
- package/dist/connection-panel/qr-panel.d.ts.map +1 -0
- package/dist/connection-panel/use-qr-refresh.d.ts +18 -0
- package/dist/connection-panel/use-qr-refresh.d.ts.map +1 -0
- package/dist/connection-panel.d.ts +39 -0
- package/dist/connection-panel.d.ts.map +1 -0
- package/dist/connection-panel.js +328 -0
- package/dist/conversation-list-ekq2dzca.js +133 -0
- package/dist/conversation-list-item-fy6t5af2.js +55 -0
- package/dist/conversation-thread-c60431oa.js +205 -0
- package/dist/entity-card.d.ts +17 -0
- package/dist/entity-card.d.ts.map +1 -0
- package/dist/entity-card.js +69 -0
- package/dist/entity-health-card-ggnmrtrq.js +192 -0
- package/dist/entity-switcher.d.ts +33 -0
- package/dist/entity-switcher.d.ts.map +1 -0
- package/dist/entity-switcher.js +137 -0
- package/dist/filter-bar.d.ts +41 -0
- package/dist/filter-bar.d.ts.map +1 -0
- package/dist/filter-bar.js +155 -0
- package/dist/footer-f7r5e42b.js +192 -0
- package/dist/image-lightbox-ci3arb6z.js +62 -0
- package/dist/kanban/drop-utils.d.ts +18 -0
- package/dist/kanban/drop-utils.d.ts.map +1 -0
- package/dist/kanban/navigation-buttons.d.ts +9 -0
- package/dist/kanban/navigation-buttons.d.ts.map +1 -0
- package/dist/kanban/scroll-indicators.d.ts +6 -0
- package/dist/kanban/scroll-indicators.d.ts.map +1 -0
- package/dist/kanban/use-keyboard-navigation.d.ts +4 -0
- package/dist/kanban/use-keyboard-navigation.d.ts.map +1 -0
- package/dist/kanban-board.d.ts +31 -0
- package/dist/kanban-board.d.ts.map +1 -0
- package/dist/kanban-board.js +128 -0
- package/dist/kanban-column.d.ts +13 -0
- package/dist/kanban-column.d.ts.map +1 -0
- package/dist/kanban-column.js +58 -0
- package/dist/legacy-kpi-card.d.ts +16 -0
- package/dist/legacy-kpi-card.d.ts.map +1 -0
- package/dist/legacy-kpi-card.js +4 -0
- package/dist/logout-dialog-bbv59tbi.js +93 -0
- package/dist/message-bubble-odjvhroq.js +110 -0
- package/dist/message-composer-m2n6soo7.js +59 -0
- package/dist/navigation-buttons-e77mshh0.js +65 -0
- package/dist/navigation-hotcrqoq.js +115 -0
- package/dist/save-bar.d.ts +15 -0
- package/dist/save-bar.d.ts.map +1 -0
- package/dist/save-bar.js +38 -0
- package/dist/scroll-indicators-gvxapogd.js +39 -0
- package/dist/settings-layout.d.ts +20 -0
- package/dist/settings-layout.d.ts.map +1 -0
- package/dist/settings-layout.js +56 -0
- package/dist/source-selector.d.ts +18 -0
- package/dist/source-selector.d.ts.map +1 -0
- package/dist/source-selector.js +59 -0
- package/dist/status-distribution-chart-oucedzv7.js +87 -0
- package/dist/status-filters.d.ts +12 -0
- package/dist/status-filters.d.ts.map +1 -0
- package/dist/status-filters.js +31 -0
- package/dist/stepper.d.ts +15 -0
- package/dist/stepper.d.ts.map +1 -0
- package/dist/stepper.js +42 -0
- package/dist/time-range-picker-mu38hul4.js +123 -0
- package/dist/top-entities-table-o7xzpq96.js +78 -0
- package/dist/use-keyboard-navigation-he1xmriz.js +52 -0
- package/dist/whatsapp-audio-player-nooty6jb.js +277 -0
- package/llms.txt +87 -4
- package/package.json +191 -46
- package/src/analytics/ai-cost-card.tsx +31 -0
- package/src/analytics/ai-performance-card.tsx +48 -0
- package/src/analytics/analytics-card.tsx +14 -0
- package/src/analytics/analytics-skeleton.tsx +15 -0
- package/src/analytics/entity-health-card.tsx +50 -0
- package/src/analytics/status-distribution-chart.tsx +20 -0
- package/src/analytics/time-range-picker.tsx +43 -0
- package/src/analytics/top-entities-table.tsx +24 -0
- package/src/analytics/types.ts +43 -0
- package/src/analytics.ts +10 -0
- package/src/app-shell/drawer-focus.ts +42 -0
- package/src/app-shell/footer.tsx +160 -0
- package/src/app-shell/logout-dialog.tsx +68 -0
- package/src/app-shell/navigation.tsx +147 -0
- package/src/app-shell/types.ts +57 -0
- package/src/app-shell.tsx +166 -0
- package/src/area-chart.tsx +85 -0
- package/src/audio-player.tsx +74 -0
- package/src/auth-layout.tsx +132 -0
- package/src/auth-provider.tsx +29 -0
- package/src/bar-chart.tsx +78 -0
- package/src/channel-settings.tsx +76 -0
- package/src/chart-card.tsx +45 -0
- package/src/chat-avatar.tsx +57 -0
- package/src/chat-date-divider.tsx +18 -0
- package/src/chat-header.tsx +24 -0
- package/src/chat-layout.tsx +41 -0
- package/src/chat-time-divider.tsx +20 -0
- package/src/chat-types.ts +32 -0
- package/src/chat.ts +21 -0
- package/src/command-palette.tsx +110 -0
- package/src/compat-chat/chat-avatar.tsx +23 -0
- package/src/compat-chat/chat-header.tsx +12 -0
- package/src/compat-chat/chat-layout.tsx +14 -0
- package/src/compat-chat/conversation-list-item.tsx +27 -0
- package/src/compat-chat/conversation-list.tsx +49 -0
- package/src/compat-chat/conversation-thread.tsx +92 -0
- package/src/compat-chat/date-divider.tsx +7 -0
- package/src/compat-chat/empty-chat-view.tsx +9 -0
- package/src/compat-chat/image-lightbox.tsx +22 -0
- package/src/compat-chat/message-bubble.tsx +28 -0
- package/src/compat-chat/message-composer.tsx +26 -0
- package/src/compat-chat/time-divider.tsx +5 -0
- package/src/compat-chat/time-grouping.ts +19 -0
- package/src/compat-chat/types.ts +31 -0
- package/src/compat-chat/whatsapp-audio-player.tsx +100 -0
- package/src/compat.ts +8 -0
- package/src/confirm-dialog.tsx +85 -0
- package/src/connection-panel/connected-panel.tsx +106 -0
- package/src/connection-panel/disconnected-panel.tsx +48 -0
- package/src/connection-panel/instructions.tsx +9 -0
- package/src/connection-panel/qr-panel.tsx +62 -0
- package/src/connection-panel/use-qr-refresh.ts +52 -0
- package/src/connection-panel.tsx +141 -0
- package/src/conversation-list-item.tsx +53 -0
- package/src/conversation-list.tsx +67 -0
- package/src/conversation-thread.tsx +64 -0
- package/src/detail-panel.tsx +123 -0
- package/src/document-panel.tsx +109 -0
- package/src/document-reader.tsx +29 -0
- package/src/empty-chat-view.tsx +25 -0
- package/src/entity-card.tsx +74 -0
- package/src/entity-health-card.tsx +53 -0
- package/src/entity-switcher.tsx +126 -0
- package/src/fetching-indicator.tsx +16 -0
- package/src/field-array-form.tsx +166 -0
- package/src/filter-bar.tsx +203 -0
- package/src/image-viewer.tsx +129 -0
- package/src/kanban/drop-utils.ts +31 -0
- package/src/kanban/navigation-buttons.tsx +80 -0
- package/src/kanban/scroll-indicators.tsx +47 -0
- package/src/kanban/use-keyboard-navigation.ts +43 -0
- package/src/kanban-board.tsx +160 -0
- package/src/kanban-column.tsx +62 -0
- package/src/legacy-kpi-card.tsx +38 -0
- package/src/line-chart.tsx +58 -0
- package/src/list-item.tsx +63 -0
- package/src/list-pane.tsx +143 -0
- package/src/login-form.tsx +128 -0
- package/src/message-bubble.tsx +70 -0
- package/src/message-composer.tsx +52 -0
- package/src/pie-chart.tsx +70 -0
- package/src/radar-chart.tsx +55 -0
- package/src/radial-chart.tsx +103 -0
- package/src/recording-overlay.tsx +159 -0
- package/src/save-bar.tsx +35 -0
- package/src/settings-layout.tsx +79 -0
- package/src/source-selector.tsx +71 -0
- package/src/split-workspace.tsx +74 -0
- package/src/stat-card.tsx +166 -0
- package/src/stats-grid.tsx +23 -0
- package/src/status-filters.tsx +28 -0
- package/src/stepper.tsx +47 -0
- package/src/table-pane.tsx +194 -0
- package/src/time-range-picker.tsx +89 -0
- package/src/workspace-back-button.tsx +32 -0
- package/dist/chat-types.test.d.ts +0 -2
- package/dist/chat-types.test.d.ts.map +0 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Mic, AlertCircle } from "lucide-react";
|
|
3
|
+
import type { CSSProperties } from "react";
|
|
4
|
+
|
|
5
|
+
export interface RecordingOverlayProps {
|
|
6
|
+
label: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
elapsed: number;
|
|
9
|
+
error?: string | null;
|
|
10
|
+
onStop: () => void;
|
|
11
|
+
onCancel: () => void;
|
|
12
|
+
stopLabel?: string;
|
|
13
|
+
errorTitle?: string;
|
|
14
|
+
backLabel?: string;
|
|
15
|
+
/**
|
|
16
|
+
* `overlay` (default) covers its container on a fixed dark surface (`viewer-*`
|
|
17
|
+
* tokens) — for use over media. `inline` drops the absolute positioning and the
|
|
18
|
+
* forced dark background, filling its container and inheriting the panel's
|
|
19
|
+
* surface; its text is token-driven so it stays legible on a light or dark
|
|
20
|
+
* panel. Content is otherwise identical.
|
|
21
|
+
*/
|
|
22
|
+
variant?: "overlay" | "inline";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Per-variant surface/text tokens so both read on their respective background. */
|
|
26
|
+
function variantTokens(variant: "overlay" | "inline") {
|
|
27
|
+
if (variant === "inline") {
|
|
28
|
+
return {
|
|
29
|
+
root: "ti-fade flex h-full flex-col items-center justify-center gap-5 p-6 sm:gap-7",
|
|
30
|
+
text: "text-foreground",
|
|
31
|
+
muted: "text-muted-foreground",
|
|
32
|
+
// solid action button on an adaptive panel
|
|
33
|
+
solid: "bg-foreground text-background",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
root: "ti-fade absolute inset-0 z-[60] flex flex-col items-center justify-center gap-5 bg-viewer-bg p-8 text-viewer-foreground sm:gap-7",
|
|
38
|
+
text: "text-viewer-foreground",
|
|
39
|
+
muted: "text-viewer-foreground/70",
|
|
40
|
+
solid: "bg-white text-[oklch(0.16_0.02_240)]",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fmtTime(sec: number): string {
|
|
45
|
+
const m = Math.floor(sec / 60);
|
|
46
|
+
const s = sec % 60;
|
|
47
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function Waveform() {
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className="flex items-end justify-center gap-[3px]"
|
|
54
|
+
style={{ height: 48, width: "min(80%, 280px)" }}
|
|
55
|
+
>
|
|
56
|
+
{Array.from({ length: 40 }).map((_, i) => {
|
|
57
|
+
const seed = Math.abs(Math.sin(i * 12.9898) * 43758.5453) % 1;
|
|
58
|
+
const max = Math.max(6, (0.18 + seed * 0.82) * 48);
|
|
59
|
+
return (
|
|
60
|
+
<span
|
|
61
|
+
key={i}
|
|
62
|
+
className="rounded-full bg-destructive"
|
|
63
|
+
style={
|
|
64
|
+
{
|
|
65
|
+
width: 3,
|
|
66
|
+
animation: `ti-wf 0.9s ease-in-out ${(i % 11) * 0.07}s infinite`,
|
|
67
|
+
"--wfmax": `${max}px`,
|
|
68
|
+
} as CSSProperties
|
|
69
|
+
}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
})}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function RecordingOverlay({
|
|
78
|
+
label,
|
|
79
|
+
description,
|
|
80
|
+
elapsed,
|
|
81
|
+
error,
|
|
82
|
+
onStop,
|
|
83
|
+
onCancel,
|
|
84
|
+
stopLabel = "Detener",
|
|
85
|
+
errorTitle = "No se pudo acceder al micrófono",
|
|
86
|
+
backLabel = "Volver",
|
|
87
|
+
variant = "overlay",
|
|
88
|
+
}: RecordingOverlayProps) {
|
|
89
|
+
const t = variantTokens(variant);
|
|
90
|
+
|
|
91
|
+
if (error) {
|
|
92
|
+
return (
|
|
93
|
+
<div className={`${t.root} text-center`}>
|
|
94
|
+
<div className="grid h-16 w-16 place-items-center rounded-full bg-destructive/15 text-destructive">
|
|
95
|
+
<AlertCircle className="h-8 w-8" />
|
|
96
|
+
</div>
|
|
97
|
+
<div className="max-w-[280px]">
|
|
98
|
+
<p className={`text-base font-semibold ${t.text}`}>{errorTitle}</p>
|
|
99
|
+
<p className={`mt-2 text-sm ${t.muted}`}>{error}</p>
|
|
100
|
+
</div>
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={onCancel}
|
|
104
|
+
className={`h-12 rounded-2xl px-6 text-[15px] font-semibold ${t.solid}`}
|
|
105
|
+
>
|
|
106
|
+
{backLabel}
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className={t.root}>
|
|
114
|
+
<div className="relative grid h-[132px] w-[132px] place-items-center sm:h-[168px] sm:w-[168px]">
|
|
115
|
+
<span className="ti-rec-pulse absolute h-[132px] w-[132px] rounded-full bg-destructive/15 sm:h-[168px] sm:w-[168px]" />
|
|
116
|
+
<span
|
|
117
|
+
className="ti-rec-pulse absolute h-[132px] w-[132px] rounded-full bg-destructive/15 sm:h-[168px] sm:w-[168px]"
|
|
118
|
+
style={{ animationDelay: "0.9s" }}
|
|
119
|
+
/>
|
|
120
|
+
<div className="grid h-[84px] w-[84px] place-items-center rounded-full bg-destructive text-white shadow-[0_10px_30px_-6px_var(--destructive)] sm:h-[108px] sm:w-[108px]">
|
|
121
|
+
<Mic className="h-8 w-8 sm:h-[42px] sm:w-[42px]" />
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div className="text-center">
|
|
126
|
+
<div className="inline-flex items-center gap-2">
|
|
127
|
+
<span className="ti-rec-blink h-2.5 w-2.5 rounded-full bg-destructive" />
|
|
128
|
+
<span className="text-sm font-semibold tracking-wide text-destructive">
|
|
129
|
+
{label}
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
<div
|
|
133
|
+
className={`mt-2 font-mono text-4xl font-medium tabular-nums ${t.text} sm:text-[46px]`}
|
|
134
|
+
>
|
|
135
|
+
{fmtTime(elapsed)}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<Waveform />
|
|
140
|
+
|
|
141
|
+
{description && (
|
|
142
|
+
<p
|
|
143
|
+
className={`max-w-[270px] text-center text-sm leading-relaxed ${t.muted}`}
|
|
144
|
+
>
|
|
145
|
+
{description}
|
|
146
|
+
</p>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
onClick={onStop}
|
|
152
|
+
className={`mt-1.5 grid h-16 w-16 place-items-center rounded-full ${t.solid} shadow-[0_6px_20px_-4px_rgba(0,0,0,0.5)]`}
|
|
153
|
+
aria-label={stopLabel}
|
|
154
|
+
>
|
|
155
|
+
<span className="h-6 w-6 rounded-md bg-destructive" />
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
package/src/save-bar.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button, Spinner } from "@lindaui/ui/compound";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
export interface SaveBarProps {
|
|
7
|
+
dirty: boolean;
|
|
8
|
+
saving: boolean;
|
|
9
|
+
form: string;
|
|
10
|
+
embedded?: boolean;
|
|
11
|
+
labels: { pending: ReactNode; upToDate: ReactNode; submit: ReactNode };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Submit an existing form by id without owning its data or persistence. */
|
|
15
|
+
export function SaveBar({ dirty, saving, form, embedded = false, labels }: SaveBarProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div className={embedded
|
|
18
|
+
? "sticky bottom-0 z-10 border-border border-t bg-surface px-4 py-3 lg:px-6"
|
|
19
|
+
: "fixed inset-x-0 bottom-24 z-40 border-border border-y bg-surface px-4 py-3 lg:static lg:border-b-0 lg:px-8"}>
|
|
20
|
+
<div className="mx-auto flex max-w-4xl items-center justify-between gap-3">
|
|
21
|
+
<p aria-live="polite" className="text-muted text-sm">{dirty ? labels.pending : labels.upToDate}</p>
|
|
22
|
+
<Button
|
|
23
|
+
className="shrink-0 whitespace-nowrap"
|
|
24
|
+
form={form}
|
|
25
|
+
isDisabled={!dirty || saving}
|
|
26
|
+
isPending={saving}
|
|
27
|
+
type="submit"
|
|
28
|
+
variant="primary"
|
|
29
|
+
>
|
|
30
|
+
{({ isPending }) => <>{isPending && <Spinner color="current" size="sm" />}{labels.submit}</>}
|
|
31
|
+
</Button>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@lindaui/ui/compound";
|
|
4
|
+
import { cn } from "@lindaui/ui/cn";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
export interface SettingsLayoutSection<Id extends string = string> {
|
|
8
|
+
id: Id;
|
|
9
|
+
label: string;
|
|
10
|
+
icon?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SettingsLayoutProps<Id extends string = string> {
|
|
14
|
+
sections: readonly SettingsLayoutSection<Id>[];
|
|
15
|
+
selected: Id;
|
|
16
|
+
onSelect: (id: Id) => void;
|
|
17
|
+
panels: Record<Id, ReactNode>;
|
|
18
|
+
footer?: ReactNode;
|
|
19
|
+
navigationLabel: string;
|
|
20
|
+
title?: ReactNode;
|
|
21
|
+
compact?: boolean;
|
|
22
|
+
idPrefix?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Responsive section navigation with mounted, hidden panels and a footer slot. */
|
|
26
|
+
export function SettingsLayout<Id extends string>({
|
|
27
|
+
sections,
|
|
28
|
+
panels,
|
|
29
|
+
selected,
|
|
30
|
+
onSelect,
|
|
31
|
+
footer,
|
|
32
|
+
navigationLabel,
|
|
33
|
+
title,
|
|
34
|
+
compact = false,
|
|
35
|
+
idPrefix = "settings",
|
|
36
|
+
}: SettingsLayoutProps<Id>) {
|
|
37
|
+
return (
|
|
38
|
+
<div className={cn("mx-auto w-full max-w-5xl", compact ? "h-full p-0" : "p-4 pb-28 lg:p-8")}>
|
|
39
|
+
{title ? <h1 className="mb-6 font-semibold text-2xl tracking-tight">{title}</h1> : null}
|
|
40
|
+
<div
|
|
41
|
+
className={cn(
|
|
42
|
+
"overflow-hidden rounded-2xl border border-border bg-surface lg:grid lg:grid-cols-[220px_minmax(0,1fr)]",
|
|
43
|
+
compact ? "h-full min-h-0 rounded-none border-0" : "lg:min-h-[620px]",
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
<nav
|
|
47
|
+
aria-label={navigationLabel}
|
|
48
|
+
className="min-h-0 min-w-0 overflow-x-auto border-border border-b bg-surface-secondary p-2 lg:overflow-y-auto lg:border-r lg:border-b-0 lg:p-3"
|
|
49
|
+
>
|
|
50
|
+
<div className="flex w-max gap-1 lg:w-full lg:flex-col">
|
|
51
|
+
{sections.map(({ id, icon, label }) => (
|
|
52
|
+
<Button
|
|
53
|
+
aria-controls={`${idPrefix}-${id}`}
|
|
54
|
+
aria-pressed={selected === id}
|
|
55
|
+
className={`shrink-0 justify-start gap-2 whitespace-nowrap rounded-lg px-3 text-sm lg:w-full ${selected === id ? "bg-default font-semibold text-foreground" : "text-muted"}`}
|
|
56
|
+
key={id}
|
|
57
|
+
onPress={() => onSelect(id)}
|
|
58
|
+
variant="ghost"
|
|
59
|
+
>
|
|
60
|
+
{icon}
|
|
61
|
+
{label}
|
|
62
|
+
</Button>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
</nav>
|
|
66
|
+
<div className="flex min-h-0 min-w-0 flex-col">
|
|
67
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-5 lg:p-8">
|
|
68
|
+
{sections.map(({ id, label }) => (
|
|
69
|
+
<section aria-label={label} hidden={selected !== id} id={`${idPrefix}-${id}`} key={id}>
|
|
70
|
+
{panels[id]}
|
|
71
|
+
</section>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
{footer}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button, Dropdown, Label, Spinner } from "@lindaui/ui/compound";
|
|
4
|
+
import { ChevronDown } from "lucide-react";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
export interface SourceSelectorOption<Source extends string = string> {
|
|
8
|
+
key: Source;
|
|
9
|
+
label: string;
|
|
10
|
+
triggerLabel?: string;
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
triggerIcon?: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SourceSelectorProps<Source extends string = string> {
|
|
16
|
+
source: Source;
|
|
17
|
+
options: readonly SourceSelectorOption<Source>[];
|
|
18
|
+
onSourceChange: (source: Source) => void;
|
|
19
|
+
isLoading?: boolean;
|
|
20
|
+
ariaLabel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Controlled selection; option labels and icons are supplied by the product. */
|
|
24
|
+
export function SourceSelector<Source extends string = string>({
|
|
25
|
+
source,
|
|
26
|
+
options,
|
|
27
|
+
onSourceChange,
|
|
28
|
+
isLoading,
|
|
29
|
+
ariaLabel = "Change source",
|
|
30
|
+
}: SourceSelectorProps<Source>) {
|
|
31
|
+
const current = options.find((option) => option.key === source);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Dropdown>
|
|
35
|
+
<Button
|
|
36
|
+
className="h-6 min-w-0 gap-1 px-2 text-[10px]"
|
|
37
|
+
isPending={isLoading}
|
|
38
|
+
size="sm"
|
|
39
|
+
variant="tertiary"
|
|
40
|
+
>
|
|
41
|
+
{({ isPending }) => (
|
|
42
|
+
<>
|
|
43
|
+
{isPending ? <Spinner color="current" size="sm" /> : current?.triggerIcon ?? current?.icon}
|
|
44
|
+
{current?.triggerLabel ?? current?.label ?? source}
|
|
45
|
+
<ChevronDown size={12} />
|
|
46
|
+
</>
|
|
47
|
+
)}
|
|
48
|
+
</Button>
|
|
49
|
+
<Dropdown.Popover className="rounded-[14px]">
|
|
50
|
+
<Dropdown.Menu
|
|
51
|
+
aria-label={ariaLabel}
|
|
52
|
+
className="[&_[data-slot=menu-item]]:rounded-lg"
|
|
53
|
+
onAction={(key) => {
|
|
54
|
+
const option = options.find((item) => item.key === key);
|
|
55
|
+
if (option) onSourceChange(option.key);
|
|
56
|
+
}}
|
|
57
|
+
selectedKeys={[source]}
|
|
58
|
+
selectionMode="single"
|
|
59
|
+
>
|
|
60
|
+
{options.map((option) => (
|
|
61
|
+
<Dropdown.Item className="text-sm" id={option.key} key={option.key} textValue={option.label}>
|
|
62
|
+
<Dropdown.ItemIndicator />
|
|
63
|
+
{option.icon}
|
|
64
|
+
<Label>{option.label}</Label>
|
|
65
|
+
</Dropdown.Item>
|
|
66
|
+
))}
|
|
67
|
+
</Dropdown.Menu>
|
|
68
|
+
</Dropdown.Popover>
|
|
69
|
+
</Dropdown>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { useIsDesktop } from "@lindaui/ui/use-media-query";
|
|
5
|
+
import { WorkspaceBackButton } from "./workspace-back-button";
|
|
6
|
+
|
|
7
|
+
export interface SplitWorkspaceProps {
|
|
8
|
+
media: ReactNode;
|
|
9
|
+
panel: ReactNode;
|
|
10
|
+
overlay?: ReactNode;
|
|
11
|
+
/**
|
|
12
|
+
* Custom back control (escape hatch). Wins over `backHref`/`backLabel`.
|
|
13
|
+
* Prefer `backHref`/`backLabel` — the default renders a legible pill over
|
|
14
|
+
* any media without the consumer having to guarantee contrast.
|
|
15
|
+
*/
|
|
16
|
+
back?: ReactNode;
|
|
17
|
+
backHref?: string;
|
|
18
|
+
backLabel?: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function SplitWorkspace({
|
|
22
|
+
media,
|
|
23
|
+
panel,
|
|
24
|
+
overlay,
|
|
25
|
+
back,
|
|
26
|
+
backHref,
|
|
27
|
+
backLabel = "Volver",
|
|
28
|
+
}: SplitWorkspaceProps) {
|
|
29
|
+
const isDesktop = useIsDesktop();
|
|
30
|
+
const backNode =
|
|
31
|
+
back ??
|
|
32
|
+
(backHref != null ? (
|
|
33
|
+
<WorkspaceBackButton href={backHref} label={backLabel} />
|
|
34
|
+
) : null);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="relative flex-1 min-h-0 overflow-hidden">
|
|
38
|
+
{isDesktop ? (
|
|
39
|
+
/* Desktop: two-column grid */
|
|
40
|
+
<div className="grid grid-cols-2 h-full">
|
|
41
|
+
<div
|
|
42
|
+
className="relative border-r overflow-hidden"
|
|
43
|
+
style={{ borderColor: "var(--viewer-border)" }}
|
|
44
|
+
>
|
|
45
|
+
{media}
|
|
46
|
+
</div>
|
|
47
|
+
<div className="flex flex-col bg-background h-full min-h-0 overflow-hidden px-0 sm:px-0">
|
|
48
|
+
{panel}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
) : (
|
|
52
|
+
/* Mobile: media top, panel stacked below */
|
|
53
|
+
<div className="flex flex-col h-full">
|
|
54
|
+
<div className="relative min-h-[40%] flex-1 min-h-0 overflow-hidden">{media}</div>
|
|
55
|
+
<div className="rounded-t-2xl bg-background shadow-lg overflow-y-auto max-h-[65%] sm:max-h-[60%]">
|
|
56
|
+
{panel}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
{/* back slot — legibility scrim + safe-area, top-left over the media */}
|
|
62
|
+
{backNode && (
|
|
63
|
+
<div className="pointer-events-none absolute inset-x-0 top-0 z-40 bg-linear-to-b from-black/25 to-transparent p-3 pt-[max(0.75rem,env(safe-area-inset-top))] pl-[max(0.875rem,env(safe-area-inset-left))]">
|
|
64
|
+
<div className="pointer-events-auto inline-block">{backNode}</div>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{/* overlay */}
|
|
69
|
+
{overlay && (
|
|
70
|
+
<div className="absolute inset-0 z-[60]">{overlay}</div>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
|
4
|
+
import { Card, CardHeader, CardTitle, CardContent } from "@lindaui/ui/card";
|
|
5
|
+
import { Badge } from "@lindaui/ui/badge";
|
|
6
|
+
import { Skeleton } from "@lindaui/ui/skeleton";
|
|
7
|
+
import { ChartContainer, type ChartConfig } from "@lindaui/ui/chart";
|
|
8
|
+
import { LineChart, Line } from "recharts";
|
|
9
|
+
|
|
10
|
+
export interface StatCardDelta {
|
|
11
|
+
value: string;
|
|
12
|
+
trend: "up" | "down" | "neutral";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StatCardSparkline {
|
|
16
|
+
data: Array<Record<string, number>>;
|
|
17
|
+
dataKey: string;
|
|
18
|
+
color?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type StatCardTone = "primary" | "success" | "warning" | "danger" | "neutral";
|
|
22
|
+
|
|
23
|
+
export interface StatCardProps {
|
|
24
|
+
label: ReactNode;
|
|
25
|
+
value: ReactNode;
|
|
26
|
+
delta?: StatCardDelta;
|
|
27
|
+
description?: ReactNode;
|
|
28
|
+
icon?: ReactNode;
|
|
29
|
+
sparkline?: StatCardSparkline;
|
|
30
|
+
/** "featured" = icono prominente arriba-izquierda + delta arriba-derecha +
|
|
31
|
+
* valor grande + label abajo (estilo dashboard). Default = icono inline. */
|
|
32
|
+
variant?: "default" | "featured";
|
|
33
|
+
/** Tinte del icon box (y del valor en featured). Solo aplica a featured. */
|
|
34
|
+
tone?: StatCardTone;
|
|
35
|
+
className?: string;
|
|
36
|
+
/** Reemplaza value/delta/description por skeletons; label e icon quedan visibles. */
|
|
37
|
+
loading?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const trendIcon = {
|
|
41
|
+
up: <TrendingUp className="h-3 w-3" />,
|
|
42
|
+
down: <TrendingDown className="h-3 w-3" />,
|
|
43
|
+
neutral: <Minus className="h-3 w-3" />,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const trendVariant: Record<StatCardDelta["trend"], "success" | "danger" | "default"> = {
|
|
47
|
+
up: "success",
|
|
48
|
+
down: "danger",
|
|
49
|
+
neutral: "default",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Tonos via arbitrary values sobre los tokens canónicos (robustos: no dependen
|
|
53
|
+
// de que HeroUI exponga text-success/text-warning como utilities).
|
|
54
|
+
const toneStyles: Record<StatCardTone, { box: string; value: string }> = {
|
|
55
|
+
primary: { box: "bg-[var(--primary)]/10 text-[var(--primary)]", value: "text-[var(--primary)]" },
|
|
56
|
+
success: { box: "bg-[var(--success)]/12 text-[var(--success)]", value: "text-[var(--success)]" },
|
|
57
|
+
warning: { box: "bg-[var(--warning)]/12 text-[var(--warning)]", value: "text-[var(--warning)]" },
|
|
58
|
+
danger: { box: "bg-[var(--destructive)]/10 text-[var(--destructive)]", value: "text-[var(--destructive)]" },
|
|
59
|
+
neutral: { box: "bg-secondary text-muted-foreground", value: "text-foreground" },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function DeltaBadge({ delta }: { delta: StatCardDelta }) {
|
|
63
|
+
return (
|
|
64
|
+
<Badge variant={trendVariant[delta.trend]}>
|
|
65
|
+
<span className="flex items-center gap-0.5">
|
|
66
|
+
{trendIcon[delta.trend]}
|
|
67
|
+
{delta.value}
|
|
68
|
+
</span>
|
|
69
|
+
</Badge>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Sparkline({ sparkline }: { sparkline: StatCardSparkline }) {
|
|
74
|
+
const config: ChartConfig = {
|
|
75
|
+
[sparkline.dataKey]: { color: sparkline.color ?? "var(--chart-1)" },
|
|
76
|
+
};
|
|
77
|
+
return (
|
|
78
|
+
<ChartContainer config={config} className="mt-3 h-12 w-full">
|
|
79
|
+
<LineChart data={sparkline.data}>
|
|
80
|
+
<Line
|
|
81
|
+
type="monotone"
|
|
82
|
+
dataKey={sparkline.dataKey}
|
|
83
|
+
stroke={sparkline.color ?? "var(--chart-1)"}
|
|
84
|
+
strokeWidth={2}
|
|
85
|
+
dot={false}
|
|
86
|
+
/>
|
|
87
|
+
</LineChart>
|
|
88
|
+
</ChartContainer>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function StatCard({
|
|
93
|
+
label,
|
|
94
|
+
value,
|
|
95
|
+
delta,
|
|
96
|
+
description,
|
|
97
|
+
icon,
|
|
98
|
+
sparkline,
|
|
99
|
+
variant = "default",
|
|
100
|
+
tone = "neutral",
|
|
101
|
+
className = "",
|
|
102
|
+
loading = false,
|
|
103
|
+
}: StatCardProps) {
|
|
104
|
+
if (variant === "featured") {
|
|
105
|
+
const t = toneStyles[tone];
|
|
106
|
+
return (
|
|
107
|
+
<Card className={className}>
|
|
108
|
+
<CardContent className="p-5">
|
|
109
|
+
<div className="flex items-start justify-between gap-2">
|
|
110
|
+
{icon && (
|
|
111
|
+
<div className={`grid size-11 shrink-0 place-items-center rounded-xl ${t.box}`}>
|
|
112
|
+
{icon}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
{loading ? <Skeleton className="h-5 w-14 rounded-full" /> : delta && <DeltaBadge delta={delta} />}
|
|
116
|
+
</div>
|
|
117
|
+
{loading ? (
|
|
118
|
+
<Skeleton className="mt-4 h-8 w-20" />
|
|
119
|
+
) : (
|
|
120
|
+
<div className={`mt-4 text-3xl font-semibold tabular-nums ${t.value}`}>{value}</div>
|
|
121
|
+
)}
|
|
122
|
+
<div className="mt-1 text-sm text-muted-foreground">{label}</div>
|
|
123
|
+
{loading ? (
|
|
124
|
+
<Skeleton className="mt-1.5 h-3 w-24" />
|
|
125
|
+
) : (
|
|
126
|
+
description && <p className="mt-1.5 text-xs text-muted-foreground">{description}</p>
|
|
127
|
+
)}
|
|
128
|
+
{sparkline && <Sparkline sparkline={sparkline} />}
|
|
129
|
+
</CardContent>
|
|
130
|
+
</Card>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Card className={className}>
|
|
136
|
+
<CardHeader className="pb-2">
|
|
137
|
+
<div className="flex items-center justify-between gap-2">
|
|
138
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
|
139
|
+
{icon && <div className="h-4 w-4 text-muted-foreground">{icon}</div>}
|
|
140
|
+
</div>
|
|
141
|
+
</CardHeader>
|
|
142
|
+
<CardContent className="pt-0">
|
|
143
|
+
{loading ? (
|
|
144
|
+
<Skeleton className="h-7 w-24" />
|
|
145
|
+
) : (
|
|
146
|
+
<div className="text-2xl font-semibold tabular-nums sm:text-3xl">{value}</div>
|
|
147
|
+
)}
|
|
148
|
+
{loading ? (
|
|
149
|
+
<Skeleton className="mt-1.5 h-4 w-14 rounded-full" />
|
|
150
|
+
) : (
|
|
151
|
+
delta && (
|
|
152
|
+
<div className="mt-1 flex items-center gap-1">
|
|
153
|
+
<DeltaBadge delta={delta} />
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
)}
|
|
157
|
+
{loading ? (
|
|
158
|
+
<Skeleton className="mt-1.5 h-3 w-28" />
|
|
159
|
+
) : (
|
|
160
|
+
description && <p className="mt-1.5 text-xs text-muted-foreground">{description}</p>
|
|
161
|
+
)}
|
|
162
|
+
{sparkline && <Sparkline sparkline={sparkline} />}
|
|
163
|
+
</CardContent>
|
|
164
|
+
</Card>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface StatsGridProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
columns?: 2 | 3 | 4;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const lgColsClass: Record<2 | 3 | 4, string> = {
|
|
10
|
+
2: "lg:grid-cols-2",
|
|
11
|
+
3: "lg:grid-cols-3",
|
|
12
|
+
4: "lg:grid-cols-4",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function StatsGrid({ children, columns = 4, className = "" }: StatsGridProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
className={`grid gap-4 grid-cols-1 sm:grid-cols-2 ${lgColsClass[columns]} ${className}`}
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge, type BadgeColor } from "@lindaui/ui/compat";
|
|
4
|
+
|
|
5
|
+
export interface StatusFiltersProps {
|
|
6
|
+
availableStatuses: string[];
|
|
7
|
+
colors?: Record<string, BadgeColor>;
|
|
8
|
+
counts?: Record<string, number>;
|
|
9
|
+
labels?: Record<string, string>;
|
|
10
|
+
onStatusToggle: (status: string) => void;
|
|
11
|
+
selectedStatuses: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Controlled status chips; labels, colors and counts belong to the consumer. */
|
|
15
|
+
export function StatusFilters({ availableStatuses, selectedStatuses, onStatusToggle, counts, labels, colors }: StatusFiltersProps) {
|
|
16
|
+
const handleToggle = (status: string) => () => onStatusToggle(status);
|
|
17
|
+
return <div className="flex flex-wrap gap-2">
|
|
18
|
+
{availableStatuses.map((status) => {
|
|
19
|
+
const isSelected = selectedStatuses.includes(status);
|
|
20
|
+
const count = counts?.[status] ?? 0;
|
|
21
|
+
return <button aria-pressed={isSelected} className="cursor-pointer transition-opacity hover:opacity-80" key={status} onClick={handleToggle(status)} type="button">
|
|
22
|
+
<Badge color={colors?.[status] ?? "default"} size="sm" variant={isSelected ? "solid" : "flat"}>
|
|
23
|
+
{labels?.[status] ?? status}{count > 0 ? ` (${count})` : ""}
|
|
24
|
+
</Badge>
|
|
25
|
+
</button>;
|
|
26
|
+
})}
|
|
27
|
+
</div>;
|
|
28
|
+
}
|
package/src/stepper.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ProgressBar } from "@lindaui/ui/compound";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
export interface StepperProps {
|
|
7
|
+
/** Presentation only: the app supplies its own step/completion calculation. */
|
|
8
|
+
value: number;
|
|
9
|
+
label: string;
|
|
10
|
+
stepLabel?: ReactNode;
|
|
11
|
+
description?: ReactNode;
|
|
12
|
+
complete?: boolean;
|
|
13
|
+
variant?: "steps" | "compact";
|
|
14
|
+
className?: string;
|
|
15
|
+
formatValue?: (value: number) => ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The progress presentation shared by onboarding steps and compact connection wizards. */
|
|
19
|
+
export function Stepper({
|
|
20
|
+
value,
|
|
21
|
+
label,
|
|
22
|
+
stepLabel,
|
|
23
|
+
description,
|
|
24
|
+
complete = false,
|
|
25
|
+
variant = "steps",
|
|
26
|
+
className,
|
|
27
|
+
formatValue = (progress) => `${Math.round(progress)}%`,
|
|
28
|
+
}: StepperProps) {
|
|
29
|
+
const progress = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
|
|
30
|
+
const compact = variant === "compact";
|
|
31
|
+
return (
|
|
32
|
+
<div className={className ?? (compact ? "mx-auto max-w-xs space-y-2" : "mb-6")}>
|
|
33
|
+
{!compact && (
|
|
34
|
+
<div className="mb-2 flex items-center justify-between font-bold text-muted text-xs uppercase tracking-widest">
|
|
35
|
+
<span>{stepLabel}</span>
|
|
36
|
+
<span>{formatValue(progress)}</span>
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
<ProgressBar aria-label={label} color={complete ? "success" : "accent"} size="sm" value={progress}>
|
|
40
|
+
<ProgressBar.Track className={compact ? undefined : "rounded-full"}>
|
|
41
|
+
<ProgressBar.Fill className={compact ? undefined : "rounded-full"} />
|
|
42
|
+
</ProgressBar.Track>
|
|
43
|
+
</ProgressBar>
|
|
44
|
+
{description && <p className="text-muted text-xs">{description}</p>}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|