@burtson-labs/ui 0.11.0 → 0.12.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.
package/README.md CHANGED
@@ -58,9 +58,9 @@ npx shadcn@latest add https://ui.burtson.ai/r/button.json
58
58
 
59
59
  ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://icons.burtson.ai/svg-white/panel-grid.svg"/><img src="https://icons.burtson.ai/svg-black/panel-grid.svg" align="center" alt=""/></picture> Components
60
60
 
61
- Accordion · Alert · Alert Dialog · App Shell · Attachment · Avatar · Badge · Breadcrumb · Button · Card · Checkbox · Collapsible · Combobox · Command · Connection Status · Context Menu · Copy Button · Data Table · Dialog · Dropdown Menu · Editor Tabs · Empty State · Field · Icon Button · Input · Kbd · Label · Menubar · Mobile Nav · Navigation Menu · Onboarding Checklist · Page Header · Pagination · Popover · Progress · Radio Group · Reasoning · Resizable · Scroll Area · Secret Input · Select · Separator · Sheet · Skeleton · Slider · Source · Spinner · Stat Card · Status · Steps · Switch · Table · Tabs · Textarea · Toast · Toaster · Toolbar · Tooltip · Tour · Tree View
61
+ Accordion · Alert · Alert Dialog · App Shell · Attachment · Audio Player · Avatar · Badge · Breadcrumb · Button · Card · Chat History · Chat Layout · Checkbox · Collapsible · Combobox · Command · Composer · Connection Status · Context Menu · Conversation · Copy Button · Data Table · Dialog · Dropdown Menu · Editor Tabs · Empty State · Field · Icon Button · Input · Kbd · Label · Markdown · Menubar · Message · Message Actions · Mobile Nav · Navigation Menu · Onboarding Checklist · Page Header · Pagination · Popover · Progress · Radio Group · Reasoning · Resizable · Scroll Area · Secret Input · Select · Separator · Sheet · Skeleton · Slider · Source · Spinner · Stat Card · Status · Steps · Switch · Table · Tabs · Textarea · Toast · Toaster · Tool Call · Toolbar · Tooltip · Tour · Tree View · Voice Recorder
62
62
 
63
- Live previews and code for each one are at [ui.burtson.ai](https://ui.burtson.ai/docs/components/button).
63
+ Live previews and code for each one are at [ui.burtson.ai](https://ui.burtson.ai/docs/components/button). Building a chat app? The [chat recipe](https://ui.burtson.ai/docs/recipes/chat) puts history, attachments, streaming, voice notes and full screen together, with the complete source.
64
64
 
65
65
  ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://icons.burtson.ai/svg-white/palette.svg"/><img src="https://icons.burtson.ai/svg-black/palette.svg" align="center" alt=""/></picture> Theming
66
66
 
@@ -0,0 +1,39 @@
1
+ import * as React from 'react';
2
+ /** 83.4 → "1:23"; 3723 → "1:02:03". */
3
+ export declare function formatDuration(seconds: number): string;
4
+ /**
5
+ * Reduce decoded audio to `bars` peak heights between 0 and 1 (the loudest
6
+ * sample in each slice, normalised to the loudest slice).
7
+ */
8
+ export declare function peaksFromChannelData(data: Float32Array, bars?: number): number[];
9
+ /**
10
+ * Decode an audio file or URL with Web Audio and return its peaks for
11
+ * AudioPlayer. Browser only; for long files compute peaks on the server.
12
+ */
13
+ export declare function computePeaks(source: Blob | string, bars?: number): Promise<number[]>;
14
+ export interface AudioPlayerProps extends Omit<React.ComponentProps<'div'>, 'title'> {
15
+ src: string;
16
+ /** Waveform heights 0–1 (see computePeaks). Leave out for a plain progress bar. */
17
+ peaks?: number[];
18
+ /** Name read out with the controls, e.g. "Voice note from Dana". */
19
+ title?: string;
20
+ /** Known length in seconds, shown before the file has loaded. */
21
+ duration?: number;
22
+ /** Text of the recording; adds a Transcript toggle. */
23
+ transcript?: React.ReactNode;
24
+ /** Adds a download link with this file name. */
25
+ downloadName?: string;
26
+ /** `compact` fits inside a message bubble. */
27
+ variant?: 'default' | 'compact';
28
+ onPlayChange?: (playing: boolean) => void;
29
+ }
30
+ /**
31
+ * Plays one recording: play and pause, a waveform you can click or drive
32
+ * with the arrow keys (5 seconds a step, Home and End), elapsed and total
33
+ * time, speed (1×, 1.5×, 2×), download and a transcript. The waveform is a
34
+ * slider for screen readers.
35
+ */
36
+ declare function AudioPlayer({ src, peaks, title, duration: knownDuration, transcript, downloadName, variant, onPlayChange, className, ...props }: AudioPlayerProps): React.JSX.Element;
37
+ /** A voice note inside a message: the compact AudioPlayer. */
38
+ declare function VoiceMessage(props: Omit<AudioPlayerProps, 'variant'>): React.JSX.Element;
39
+ export { AudioPlayer, VoiceMessage };
@@ -0,0 +1,250 @@
1
+ import { cn } from "../lib/utils.js";
2
+ import { Button } from "./button.js";
3
+ import * as React from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import FileText from "@burtson-labs/icons/react/file-text";
6
+ import Download from "@burtson-labs/icons/react/download";
7
+ import Pause from "@burtson-labs/icons/react/pause";
8
+ import Play from "@burtson-labs/icons/react/play";
9
+ //#region src/components/audio-player.tsx
10
+ /** 83.4 → "1:23"; 3723 → "1:02:03". */
11
+ function formatDuration(seconds) {
12
+ if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
13
+ const s = Math.floor(seconds);
14
+ const h = Math.floor(s / 3600);
15
+ const m = Math.floor(s % 3600 / 60);
16
+ const sec = String(s % 60).padStart(2, "0");
17
+ return h ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
18
+ }
19
+ /**
20
+ * Reduce decoded audio to `bars` peak heights between 0 and 1 (the loudest
21
+ * sample in each slice, normalised to the loudest slice).
22
+ */
23
+ function peaksFromChannelData(data, bars = 48) {
24
+ if (!data.length || bars < 1) return [];
25
+ const size = Math.max(1, Math.floor(data.length / bars));
26
+ const peaks = [];
27
+ for (let b = 0; b < bars; b++) {
28
+ let max = 0;
29
+ const end = Math.min(data.length, (b + 1) * size);
30
+ for (let i = b * size; i < end; i++) {
31
+ const v = Math.abs(data[i] ?? 0);
32
+ if (v > max) max = v;
33
+ }
34
+ peaks.push(max);
35
+ }
36
+ const top = Math.max(...peaks) || 1;
37
+ return peaks.map((p) => p / top);
38
+ }
39
+ /**
40
+ * Decode an audio file or URL with Web Audio and return its peaks for
41
+ * AudioPlayer. Browser only; for long files compute peaks on the server.
42
+ */
43
+ async function computePeaks(source, bars = 48) {
44
+ const Ctx = globalThis.AudioContext ?? globalThis.webkitAudioContext;
45
+ if (!Ctx) throw new Error("Web Audio is not available in this browser.");
46
+ const buf = typeof source === "string" ? await (await fetch(source)).arrayBuffer() : await source.arrayBuffer();
47
+ const ctx = new Ctx();
48
+ try {
49
+ return peaksFromChannelData((await ctx.decodeAudioData(buf)).getChannelData(0), bars);
50
+ } finally {
51
+ ctx.close();
52
+ }
53
+ }
54
+ var SPEEDS = [
55
+ 1,
56
+ 1.5,
57
+ 2
58
+ ];
59
+ /**
60
+ * Plays one recording: play and pause, a waveform you can click or drive
61
+ * with the arrow keys (5 seconds a step, Home and End), elapsed and total
62
+ * time, speed (1×, 1.5×, 2×), download and a transcript. The waveform is a
63
+ * slider for screen readers.
64
+ */
65
+ function AudioPlayer({ src, peaks, title = "Audio", duration: knownDuration, transcript, downloadName, variant = "default", onPlayChange, className, ...props }) {
66
+ const audio = React.useRef(null);
67
+ const [playing, setPlaying] = React.useState(false);
68
+ const [current, setCurrent] = React.useState(0);
69
+ const [duration, setDuration] = React.useState(knownDuration ?? 0);
70
+ const [speed, setSpeed] = React.useState(1);
71
+ const [showTranscript, setShowTranscript] = React.useState(false);
72
+ const [failed, setFailed] = React.useState(false);
73
+ const transcriptId = React.useId();
74
+ const compact = variant === "compact";
75
+ const total = duration || knownDuration || 0;
76
+ const ratio = total ? Math.min(1, current / total) : 0;
77
+ React.useEffect(() => {
78
+ if (audio.current) audio.current.playbackRate = speed;
79
+ }, [speed]);
80
+ const seek = (t) => {
81
+ const el = audio.current;
82
+ const clamped = Math.max(0, Math.min(total || t, t));
83
+ if (el) el.currentTime = clamped;
84
+ setCurrent(clamped);
85
+ };
86
+ const togglePlay = async () => {
87
+ const el = audio.current;
88
+ if (!el) return;
89
+ if (el.paused) try {
90
+ await el.play();
91
+ } catch {
92
+ setFailed(true);
93
+ }
94
+ else el.pause();
95
+ };
96
+ const onKeyDown = (e) => {
97
+ const step = {
98
+ ArrowRight: 5,
99
+ ArrowUp: 5,
100
+ ArrowLeft: -5,
101
+ ArrowDown: -5
102
+ }[e.key];
103
+ if (step !== void 0) {
104
+ e.preventDefault();
105
+ seek(current + step);
106
+ } else if (e.key === "Home") {
107
+ e.preventDefault();
108
+ seek(0);
109
+ } else if (e.key === "End") {
110
+ e.preventDefault();
111
+ seek(total);
112
+ } else if (e.key === " " || e.key === "Enter") {
113
+ e.preventDefault();
114
+ togglePlay();
115
+ }
116
+ };
117
+ const bars = peaks && peaks.length ? peaks : null;
118
+ return /* @__PURE__ */ jsxs("div", {
119
+ "data-slot": "audio-player",
120
+ "data-variant": variant,
121
+ className: cn("grid min-w-0 gap-2 rounded-lg border bg-surface text-foreground", compact ? "w-full max-w-sm p-2" : "p-3", className),
122
+ ...props,
123
+ children: [
124
+ /* @__PURE__ */ jsx("audio", {
125
+ ref: audio,
126
+ src,
127
+ preload: "metadata",
128
+ onLoadedMetadata: (e) => {
129
+ const d = e.currentTarget.duration;
130
+ if (Number.isFinite(d)) setDuration(d);
131
+ },
132
+ onTimeUpdate: (e) => setCurrent(e.currentTarget.currentTime),
133
+ onPlay: () => {
134
+ setPlaying(true);
135
+ onPlayChange?.(true);
136
+ },
137
+ onPause: () => {
138
+ setPlaying(false);
139
+ onPlayChange?.(false);
140
+ },
141
+ onEnded: () => setCurrent(0),
142
+ onError: () => setFailed(true)
143
+ }),
144
+ /* @__PURE__ */ jsxs("div", {
145
+ className: "flex min-w-0 items-center gap-2",
146
+ children: [
147
+ /* @__PURE__ */ jsx(Button, {
148
+ type: "button",
149
+ size: compact ? "icon-sm" : "icon",
150
+ variant: playing ? "secondary" : "default",
151
+ "aria-label": playing ? `Pause ${title}` : `Play ${title}`,
152
+ onClick: () => void togglePlay(),
153
+ disabled: failed,
154
+ className: "shrink-0 rounded-full pointer-coarse:size-11",
155
+ children: playing ? /* @__PURE__ */ jsx(Pause, { className: "fill-current" }) : /* @__PURE__ */ jsx(Play, { className: "fill-current" })
156
+ }),
157
+ /* @__PURE__ */ jsx("div", {
158
+ role: "slider",
159
+ tabIndex: failed ? -1 : 0,
160
+ "aria-label": `Seek ${title}`,
161
+ "aria-valuemin": 0,
162
+ "aria-valuemax": Math.round(total),
163
+ "aria-valuenow": Math.round(current),
164
+ "aria-valuetext": `${formatDuration(current)} of ${formatDuration(total)}`,
165
+ onKeyDown,
166
+ onClick: (e) => {
167
+ const rect = e.currentTarget.getBoundingClientRect();
168
+ if (!rect.width || !total) return;
169
+ seek((e.clientX - rect.left) / rect.width * total);
170
+ },
171
+ className: cn("relative flex min-w-0 flex-1 cursor-pointer items-center rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20", compact ? "h-7" : "h-9"),
172
+ children: bars ? /* @__PURE__ */ jsx("div", {
173
+ "aria-hidden": true,
174
+ className: "flex h-full w-full items-center gap-[2px]",
175
+ children: bars.map((p, i) => /* @__PURE__ */ jsx("span", {
176
+ className: cn("min-w-[2px] flex-1 rounded-full transition-colors duration-[var(--duration-fast)] ease-[var(--ease-standard)]", (i + .5) / bars.length <= ratio ? "bg-brand" : "bg-border-strong"),
177
+ style: { height: `${Math.max(12, Math.round(p * 100))}%` }
178
+ }, i))
179
+ }) : /* @__PURE__ */ jsx("div", {
180
+ "aria-hidden": true,
181
+ className: "h-1.5 w-full overflow-hidden rounded-full bg-muted",
182
+ children: /* @__PURE__ */ jsx("div", {
183
+ className: "h-full rounded-full bg-brand",
184
+ style: { width: `${ratio * 100}%` }
185
+ })
186
+ })
187
+ }),
188
+ /* @__PURE__ */ jsx("span", {
189
+ className: "shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums",
190
+ children: playing || current ? formatDuration(current) : formatDuration(total)
191
+ })
192
+ ]
193
+ }),
194
+ failed ? /* @__PURE__ */ jsx("p", {
195
+ role: "alert",
196
+ className: "text-xs text-destructive",
197
+ children: "This recording could not be played."
198
+ }) : /* @__PURE__ */ jsxs("div", {
199
+ className: "flex flex-wrap items-center gap-1",
200
+ children: [
201
+ /* @__PURE__ */ jsxs(Button, {
202
+ type: "button",
203
+ variant: "ghost",
204
+ size: "xs",
205
+ "aria-label": `Playback speed ${speed}×`,
206
+ onClick: () => setSpeed(SPEEDS[(SPEEDS.indexOf(speed) + 1) % SPEEDS.length] ?? 1),
207
+ className: "font-mono tabular-nums",
208
+ children: [speed, "×"]
209
+ }),
210
+ transcript && /* @__PURE__ */ jsxs(Button, {
211
+ type: "button",
212
+ variant: "ghost",
213
+ size: "xs",
214
+ "aria-expanded": showTranscript,
215
+ "aria-controls": transcriptId,
216
+ onClick: () => setShowTranscript((v) => !v),
217
+ children: [/* @__PURE__ */ jsx(FileText, {}), " Transcript"]
218
+ }),
219
+ downloadName && /* @__PURE__ */ jsx(Button, {
220
+ variant: "ghost",
221
+ size: "xs",
222
+ asChild: true,
223
+ children: /* @__PURE__ */ jsxs("a", {
224
+ href: src,
225
+ download: downloadName,
226
+ children: [/* @__PURE__ */ jsx(Download, {}), " Download"]
227
+ })
228
+ })
229
+ ]
230
+ }),
231
+ transcript && showTranscript && /* @__PURE__ */ jsx("div", {
232
+ id: transcriptId,
233
+ className: "animate-in rounded-md bg-muted/60 p-2.5 text-sm leading-6 text-muted-foreground",
234
+ children: transcript
235
+ })
236
+ ]
237
+ });
238
+ }
239
+ /** A voice note inside a message: the compact AudioPlayer. */
240
+ function VoiceMessage(props) {
241
+ return /* @__PURE__ */ jsx(AudioPlayer, {
242
+ title: "Voice message",
243
+ ...props,
244
+ variant: "compact"
245
+ });
246
+ }
247
+ //#endregion
248
+ export { AudioPlayer, VoiceMessage, computePeaks, formatDuration, peaksFromChannelData };
249
+
250
+ //# sourceMappingURL=audio-player.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio-player.js","names":[],"sources":["../../src/components/audio-player.tsx"],"sourcesContent":["import Download from '@burtson-labs/icons/react/download';\nimport FileText from '@burtson-labs/icons/react/file-text';\nimport Pause from '@burtson-labs/icons/react/pause';\nimport Play from '@burtson-labs/icons/react/play';\nimport * as React from 'react';\n\nimport { cn } from '../lib/utils';\n\nimport { Button } from './button';\n\n/** 83.4 → \"1:23\"; 3723 → \"1:02:03\". */\nexport function formatDuration(seconds: number): string {\n if (!Number.isFinite(seconds) || seconds < 0) return '0:00';\n const s = Math.floor(seconds);\n const h = Math.floor(s / 3600);\n const m = Math.floor((s % 3600) / 60);\n const sec = String(s % 60).padStart(2, '0');\n return h ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`;\n}\n\n/**\n * Reduce decoded audio to `bars` peak heights between 0 and 1 (the loudest\n * sample in each slice, normalised to the loudest slice).\n */\nexport function peaksFromChannelData(data: Float32Array, bars = 48): number[] {\n if (!data.length || bars < 1) return [];\n const size = Math.max(1, Math.floor(data.length / bars));\n const peaks: number[] = [];\n for (let b = 0; b < bars; b++) {\n let max = 0;\n const end = Math.min(data.length, (b + 1) * size);\n for (let i = b * size; i < end; i++) {\n const v = Math.abs(data[i] ?? 0);\n if (v > max) max = v;\n }\n peaks.push(max);\n }\n const top = Math.max(...peaks) || 1;\n return peaks.map((p) => p / top);\n}\n\n/**\n * Decode an audio file or URL with Web Audio and return its peaks for\n * AudioPlayer. Browser only; for long files compute peaks on the server.\n */\nexport async function computePeaks(source: Blob | string, bars = 48): Promise<number[]> {\n const Ctx =\n globalThis.AudioContext ??\n (globalThis as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n if (!Ctx) throw new Error('Web Audio is not available in this browser.');\n const buf =\n typeof source === 'string'\n ? await (await fetch(source)).arrayBuffer()\n : await source.arrayBuffer();\n const ctx = new Ctx();\n try {\n const audio = await ctx.decodeAudioData(buf);\n return peaksFromChannelData(audio.getChannelData(0), bars);\n } finally {\n void ctx.close();\n }\n}\n\nconst SPEEDS = [1, 1.5, 2] as const;\n\nexport interface AudioPlayerProps extends Omit<React.ComponentProps<'div'>, 'title'> {\n src: string;\n /** Waveform heights 0–1 (see computePeaks). Leave out for a plain progress bar. */\n peaks?: number[];\n /** Name read out with the controls, e.g. \"Voice note from Dana\". */\n title?: string;\n /** Known length in seconds, shown before the file has loaded. */\n duration?: number;\n /** Text of the recording; adds a Transcript toggle. */\n transcript?: React.ReactNode;\n /** Adds a download link with this file name. */\n downloadName?: string;\n /** `compact` fits inside a message bubble. */\n variant?: 'default' | 'compact';\n onPlayChange?: (playing: boolean) => void;\n}\n\n/**\n * Plays one recording: play and pause, a waveform you can click or drive\n * with the arrow keys (5 seconds a step, Home and End), elapsed and total\n * time, speed (1×, 1.5×, 2×), download and a transcript. The waveform is a\n * slider for screen readers.\n */\nfunction AudioPlayer({\n src,\n peaks,\n title = 'Audio',\n duration: knownDuration,\n transcript,\n downloadName,\n variant = 'default',\n onPlayChange,\n className,\n ...props\n}: AudioPlayerProps) {\n const audio = React.useRef<HTMLAudioElement>(null);\n const [playing, setPlaying] = React.useState(false);\n const [current, setCurrent] = React.useState(0);\n const [duration, setDuration] = React.useState(knownDuration ?? 0);\n const [speed, setSpeed] = React.useState<(typeof SPEEDS)[number]>(1);\n const [showTranscript, setShowTranscript] = React.useState(false);\n const [failed, setFailed] = React.useState(false);\n const transcriptId = React.useId();\n const compact = variant === 'compact';\n const total = duration || knownDuration || 0;\n const ratio = total ? Math.min(1, current / total) : 0;\n\n React.useEffect(() => {\n if (audio.current) audio.current.playbackRate = speed;\n }, [speed]);\n\n const seek = (t: number) => {\n const el = audio.current;\n const clamped = Math.max(0, Math.min(total || t, t));\n if (el) el.currentTime = clamped;\n setCurrent(clamped);\n };\n\n const togglePlay = async () => {\n const el = audio.current;\n if (!el) return;\n if (el.paused) {\n try {\n await el.play();\n } catch {\n setFailed(true);\n }\n } else el.pause();\n };\n\n const onKeyDown = (e: React.KeyboardEvent) => {\n const step = { ArrowRight: 5, ArrowUp: 5, ArrowLeft: -5, ArrowDown: -5 }[e.key];\n if (step !== undefined) {\n e.preventDefault();\n seek(current + step);\n } else if (e.key === 'Home') {\n e.preventDefault();\n seek(0);\n } else if (e.key === 'End') {\n e.preventDefault();\n seek(total);\n } else if (e.key === ' ' || e.key === 'Enter') {\n e.preventDefault();\n void togglePlay();\n }\n };\n\n const bars = peaks && peaks.length ? peaks : null;\n\n return (\n <div\n data-slot=\"audio-player\"\n data-variant={variant}\n className={cn(\n 'grid min-w-0 gap-2 rounded-lg border bg-surface text-foreground',\n compact ? 'w-full max-w-sm p-2' : 'p-3',\n className,\n )}\n {...props}\n >\n {/* eslint-disable-next-line jsx-a11y/media-has-caption -- the transcript prop is the text alternative */}\n <audio\n ref={audio}\n src={src}\n preload=\"metadata\"\n onLoadedMetadata={(e) => {\n const d = e.currentTarget.duration;\n if (Number.isFinite(d)) setDuration(d);\n }}\n onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}\n onPlay={() => {\n setPlaying(true);\n onPlayChange?.(true);\n }}\n onPause={() => {\n setPlaying(false);\n onPlayChange?.(false);\n }}\n onEnded={() => setCurrent(0)}\n onError={() => setFailed(true)}\n />\n <div className=\"flex min-w-0 items-center gap-2\">\n <Button\n type=\"button\"\n size={compact ? 'icon-sm' : 'icon'}\n variant={playing ? 'secondary' : 'default'}\n aria-label={playing ? `Pause ${title}` : `Play ${title}`}\n onClick={() => void togglePlay()}\n disabled={failed}\n className=\"shrink-0 rounded-full pointer-coarse:size-11\"\n >\n {playing ? <Pause className=\"fill-current\" /> : <Play className=\"fill-current\" />}\n </Button>\n <div\n role=\"slider\"\n tabIndex={failed ? -1 : 0}\n aria-label={`Seek ${title}`}\n aria-valuemin={0}\n aria-valuemax={Math.round(total)}\n aria-valuenow={Math.round(current)}\n aria-valuetext={`${formatDuration(current)} of ${formatDuration(total)}`}\n onKeyDown={onKeyDown}\n onClick={(e) => {\n const rect = e.currentTarget.getBoundingClientRect();\n if (!rect.width || !total) return;\n seek(((e.clientX - rect.left) / rect.width) * total);\n }}\n className={cn(\n 'relative flex min-w-0 flex-1 cursor-pointer items-center rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20',\n compact ? 'h-7' : 'h-9',\n )}\n >\n {bars ? (\n <div aria-hidden className=\"flex h-full w-full items-center gap-[2px]\">\n {bars.map((p, i) => (\n <span\n key={i}\n className={cn(\n 'min-w-[2px] flex-1 rounded-full transition-colors duration-[var(--duration-fast)] ease-[var(--ease-standard)]',\n (i + 0.5) / bars.length <= ratio ? 'bg-brand' : 'bg-border-strong',\n )}\n style={{ height: `${Math.max(12, Math.round(p * 100))}%` }}\n />\n ))}\n </div>\n ) : (\n <div aria-hidden className=\"h-1.5 w-full overflow-hidden rounded-full bg-muted\">\n <div className=\"h-full rounded-full bg-brand\" style={{ width: `${ratio * 100}%` }} />\n </div>\n )}\n </div>\n <span className=\"shrink-0 font-mono text-[11px] text-muted-foreground tabular-nums\">\n {playing || current ? formatDuration(current) : formatDuration(total)}\n </span>\n </div>\n {failed ? (\n <p role=\"alert\" className=\"text-xs text-destructive\">\n This recording could not be played.\n </p>\n ) : (\n <div className=\"flex flex-wrap items-center gap-1\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"xs\"\n aria-label={`Playback speed ${speed}×`}\n onClick={() => setSpeed(SPEEDS[(SPEEDS.indexOf(speed) + 1) % SPEEDS.length] ?? 1)}\n className=\"font-mono tabular-nums\"\n >\n {speed}×\n </Button>\n {transcript && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"xs\"\n aria-expanded={showTranscript}\n aria-controls={transcriptId}\n onClick={() => setShowTranscript((v) => !v)}\n >\n <FileText /> Transcript\n </Button>\n )}\n {downloadName && (\n <Button variant=\"ghost\" size=\"xs\" asChild>\n <a href={src} download={downloadName}>\n <Download /> Download\n </a>\n </Button>\n )}\n </div>\n )}\n {transcript && showTranscript && (\n <div\n id={transcriptId}\n className=\"animate-in rounded-md bg-muted/60 p-2.5 text-sm leading-6 text-muted-foreground\"\n >\n {transcript}\n </div>\n )}\n </div>\n );\n}\n\n/** A voice note inside a message: the compact AudioPlayer. */\nfunction VoiceMessage(props: Omit<AudioPlayerProps, 'variant'>) {\n return <AudioPlayer title=\"Voice message\" {...props} variant=\"compact\" />;\n}\n\nexport { AudioPlayer, VoiceMessage };\n"],"mappings":"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"}
@@ -0,0 +1,64 @@
1
+ import * as React from 'react';
2
+ export interface ChatHistoryItem {
3
+ id: string;
4
+ title: string;
5
+ /** When the conversation last changed; decides its group. */
6
+ updatedAt: Date | string | number;
7
+ pinned?: boolean;
8
+ /** A line of the latest message, searched along with the title. */
9
+ preview?: string;
10
+ }
11
+ export type ChatHistoryGroupKey = 'pinned' | 'today' | 'yesterday' | 'week' | 'older';
12
+ export interface ChatHistoryGroup {
13
+ key: ChatHistoryGroupKey;
14
+ label: string;
15
+ items: ChatHistoryItem[];
16
+ }
17
+ /**
18
+ * Pinned first, then Today, Yesterday, Previous 7 days and Older by the
19
+ * local calendar, newest first in each. Empty groups are left out.
20
+ */
21
+ export declare function groupConversations(items: ChatHistoryItem[], now?: Date): ChatHistoryGroup[];
22
+ /** Case-insensitive match on every word, across title and preview. */
23
+ export declare function matchesConversation(item: ChatHistoryItem, query: string): boolean;
24
+ export interface ChatHistoryProps extends Omit<React.ComponentProps<'div'>, 'onSelect'> {
25
+ items: ChatHistoryItem[];
26
+ activeId?: string;
27
+ onSelect: (id: string) => void;
28
+ /** Shows a "New chat" button at the top. */
29
+ onNewChat?: () => void;
30
+ /** Adds Rename to the row menu; the title is edited in place. */
31
+ onRename?: (id: string, title: string) => void;
32
+ /** Adds Delete to the row menu. Confirm or offer undo in your app. */
33
+ onDelete?: (id: string) => void;
34
+ /** Adds Pin / Unpin to the row menu. */
35
+ onPinChange?: (id: string, pinned: boolean) => void;
36
+ /** Controlled search text; leave out to let the list keep its own. */
37
+ query?: string;
38
+ onQueryChange?: (query: string) => void;
39
+ /** Hides the search box, e.g. when the app searches on the server. */
40
+ searchable?: boolean;
41
+ loading?: boolean;
42
+ /** Why the list could not load. Shown with Retry when `onRetry` is set. */
43
+ error?: React.ReactNode;
44
+ onRetry?: () => void;
45
+ /** Shown when there are no conversations at all. */
46
+ empty?: React.ReactNode;
47
+ /** The clock grouping uses; pass it in tests. */
48
+ now?: Date;
49
+ /**
50
+ * Virtualisation hook for long histories: receives a group's items and the
51
+ * row renderer, returns what to render (e.g. a windowed list). Default
52
+ * renders every row.
53
+ */
54
+ renderItems?: (items: ChatHistoryItem[], renderItem: (item: ChatHistoryItem) => React.ReactNode) => React.ReactNode;
55
+ label?: string;
56
+ }
57
+ /**
58
+ * The conversation list beside a chat: a New chat action, search, and the
59
+ * history grouped by day with pinned chats on top. Up and Down move between
60
+ * rows, Home and End jump, Enter opens; each row has a menu for rename, pin
61
+ * and delete. The app owns the data; this only reports what the person did.
62
+ */
63
+ declare function ChatHistory({ items, activeId, onSelect, onNewChat, onRename, onDelete, onPinChange, query: controlledQuery, onQueryChange, searchable, loading, error, onRetry, empty, now, renderItems, label, className, ...props }: ChatHistoryProps): React.JSX.Element;
64
+ export { ChatHistory };