@djangocfg/ui-tools 2.1.335 → 2.1.336
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 +68 -2
- package/dist/ChatRoot-IIYQEWUU.mjs +5 -0
- package/dist/ChatRoot-IIYQEWUU.mjs.map +1 -0
- package/dist/ChatRoot-PNNGQCYF.css +7 -0
- package/dist/ChatRoot-PNNGQCYF.css.map +1 -0
- package/dist/ChatRoot-UUKTYM4N.cjs +14 -0
- package/dist/ChatRoot-UUKTYM4N.cjs.map +1 -0
- package/dist/{CronScheduler.client-3O3VU4CI.mjs → CronScheduler.client-DLMXCPAJ.mjs} +4 -4
- package/dist/{CronScheduler.client-3O3VU4CI.mjs.map → CronScheduler.client-DLMXCPAJ.mjs.map} +1 -1
- package/dist/{CronScheduler.client-A4GO6YBY.cjs → CronScheduler.client-WEJF4PWQ.cjs} +14 -14
- package/dist/{CronScheduler.client-A4GO6YBY.cjs.map → CronScheduler.client-WEJF4PWQ.cjs.map} +1 -1
- package/dist/{DocsLayout-XLDB6CJ2.cjs → DocsLayout-N5ZJZPBY.cjs} +200 -199
- package/dist/DocsLayout-N5ZJZPBY.cjs.map +1 -0
- package/dist/{DocsLayout-CTJINVBM.mjs → DocsLayout-VFPPNKSQ.mjs} +7 -6
- package/dist/DocsLayout-VFPPNKSQ.mjs.map +1 -0
- package/dist/JsonSchemaForm-DD7CLRIG.cjs +13 -0
- package/dist/{JsonSchemaForm-6WMS4CIY.cjs.map → JsonSchemaForm-DD7CLRIG.cjs.map} +1 -1
- package/dist/JsonSchemaForm-XKUIVELK.mjs +4 -0
- package/dist/{JsonSchemaForm-KX4JT3M4.mjs.map → JsonSchemaForm-XKUIVELK.mjs.map} +1 -1
- package/dist/JsonTree-55625VVH.mjs +5 -0
- package/dist/{JsonTree-F27RMYSI.cjs.map → JsonTree-55625VVH.mjs.map} +1 -1
- package/dist/JsonTree-DCM5QGWF.cjs +11 -0
- package/dist/{JsonTree-QTJYSHCV.mjs.map → JsonTree-DCM5QGWF.cjs.map} +1 -1
- package/dist/{LottiePlayer.client-6WVWDO75.cjs → LottiePlayer.client-2S7ISJ2S.cjs} +6 -6
- package/dist/{LottiePlayer.client-6WVWDO75.cjs.map → LottiePlayer.client-2S7ISJ2S.cjs.map} +1 -1
- package/dist/{LottiePlayer.client-B4I6WNZM.mjs → LottiePlayer.client-5LDSSJWS.mjs} +4 -4
- package/dist/{LottiePlayer.client-B4I6WNZM.mjs.map → LottiePlayer.client-5LDSSJWS.mjs.map} +1 -1
- package/dist/{MapContainer-RYG4HPH4.cjs → MapContainer-76YL2JXL.cjs} +8 -8
- package/dist/{MapContainer-RYG4HPH4.cjs.map → MapContainer-76YL2JXL.cjs.map} +1 -1
- package/dist/{MapContainer-GXQLP5WY.mjs → MapContainer-7HXBI3OH.mjs} +3 -3
- package/dist/{MapContainer-GXQLP5WY.mjs.map → MapContainer-7HXBI3OH.mjs.map} +1 -1
- package/dist/{Mermaid.client-SXRRI2YW.mjs → Mermaid.client-NL4SVR7F.mjs} +4 -4
- package/dist/{Mermaid.client-SXRRI2YW.mjs.map → Mermaid.client-NL4SVR7F.mjs.map} +1 -1
- package/dist/{Mermaid.client-W76R5AKJ.cjs → Mermaid.client-NNTI6DFX.cjs} +26 -26
- package/dist/{Mermaid.client-W76R5AKJ.cjs.map → Mermaid.client-NNTI6DFX.cjs.map} +1 -1
- package/dist/Player-BRV7XTWR.mjs +4 -0
- package/dist/{Player-M3GC3VPE.mjs.map → Player-BRV7XTWR.mjs.map} +1 -1
- package/dist/Player-PM7F7DD7.cjs +13 -0
- package/dist/{Player-ZL2X5LGG.cjs.map → Player-PM7F7DD7.cjs.map} +1 -1
- package/dist/{PrettyCode.client-RPDIE5CH.cjs → PrettyCode.client-KOHDVPPN.cjs} +13 -13
- package/dist/{PrettyCode.client-RPDIE5CH.cjs.map → PrettyCode.client-KOHDVPPN.cjs.map} +1 -1
- package/dist/{PrettyCode.client-SPMTQEG4.mjs → PrettyCode.client-ZGYGKE7G.mjs} +4 -4
- package/dist/{PrettyCode.client-SPMTQEG4.mjs.map → PrettyCode.client-ZGYGKE7G.mjs.map} +1 -1
- package/dist/TreeRoot-N72OYKXU.cjs +19 -0
- package/dist/{TreeRoot-A3J65L6F.mjs.map → TreeRoot-N72OYKXU.cjs.map} +1 -1
- package/dist/TreeRoot-VGAIXCUA.mjs +4 -0
- package/dist/{TreeRoot-DSK5JILT.cjs.map → TreeRoot-VGAIXCUA.mjs.map} +1 -1
- package/dist/chunk-2ZLKZ5VR.mjs +631 -0
- package/dist/chunk-2ZLKZ5VR.mjs.map +1 -0
- package/dist/{chunk-LFWQ36LJ.mjs → chunk-5G5YBFS6.mjs} +4 -4
- package/dist/{chunk-LFWQ36LJ.mjs.map → chunk-5G5YBFS6.mjs.map} +1 -1
- package/dist/{chunk-IHAY6FO6.cjs → chunk-5I5QNGUG.cjs} +17 -17
- package/dist/{chunk-IHAY6FO6.cjs.map → chunk-5I5QNGUG.cjs.map} +1 -1
- package/dist/{chunk-F2CMIIOH.cjs → chunk-76NNDZH6.cjs} +42 -42
- package/dist/{chunk-F2CMIIOH.cjs.map → chunk-76NNDZH6.cjs.map} +1 -1
- package/dist/chunk-B5AWZOHJ.cjs +649 -0
- package/dist/chunk-B5AWZOHJ.cjs.map +1 -0
- package/dist/{chunk-KR6B3LVY.mjs → chunk-B6IR5KSC.mjs} +3 -3
- package/dist/{chunk-KR6B3LVY.mjs.map → chunk-B6IR5KSC.mjs.map} +1 -1
- package/dist/{chunk-5LBDYFWH.mjs → chunk-C6GXVH5J.mjs} +3 -3
- package/dist/{chunk-5LBDYFWH.mjs.map → chunk-C6GXVH5J.mjs.map} +1 -1
- package/dist/{chunk-NRKD4F5X.cjs → chunk-FEN5S772.cjs} +36 -36
- package/dist/{chunk-NRKD4F5X.cjs.map → chunk-FEN5S772.cjs.map} +1 -1
- package/dist/{chunk-2SMCH62O.cjs → chunk-FP2RLYQZ.cjs} +11 -11
- package/dist/{chunk-2SMCH62O.cjs.map → chunk-FP2RLYQZ.cjs.map} +1 -1
- package/dist/{chunk-MOME6KYD.mjs → chunk-G5IEC7SR.mjs} +3 -3
- package/dist/{chunk-MOME6KYD.mjs.map → chunk-G5IEC7SR.mjs.map} +1 -1
- package/dist/{chunk-SE5IERVH.mjs → chunk-GYIO7W7M.mjs} +3 -3
- package/dist/{chunk-SE5IERVH.mjs.map → chunk-GYIO7W7M.mjs.map} +1 -1
- package/dist/{chunk-3Z3A7FHA.cjs → chunk-IEEAENLX.cjs} +48 -48
- package/dist/{chunk-3Z3A7FHA.cjs.map → chunk-IEEAENLX.cjs.map} +1 -1
- package/dist/{chunk-DFTVB66S.cjs → chunk-KNDLV4PI.cjs} +85 -85
- package/dist/{chunk-DFTVB66S.cjs.map → chunk-KNDLV4PI.cjs.map} +1 -1
- package/dist/{chunk-SSUOENAZ.mjs → chunk-KNEQRUBA.mjs} +3 -3
- package/dist/{chunk-SSUOENAZ.mjs.map → chunk-KNEQRUBA.mjs.map} +1 -1
- package/dist/chunk-KRETIZU6.mjs +2218 -0
- package/dist/chunk-KRETIZU6.mjs.map +1 -0
- package/dist/{chunk-CGILA3WO.mjs → chunk-N2XQF2OL.mjs} +5 -3
- package/dist/{chunk-CGILA3WO.mjs.map → chunk-N2XQF2OL.mjs.map} +1 -1
- package/dist/{chunk-EUADAUBQ.mjs → chunk-N4MZYNR4.mjs} +4 -4
- package/dist/{chunk-EUADAUBQ.mjs.map → chunk-N4MZYNR4.mjs.map} +1 -1
- package/dist/chunk-NRXYYO5V.cjs +2257 -0
- package/dist/chunk-NRXYYO5V.cjs.map +1 -0
- package/dist/{chunk-GGKGH5PM.mjs → chunk-OBRSGM64.mjs} +4 -4
- package/dist/{chunk-GGKGH5PM.mjs.map → chunk-OBRSGM64.mjs.map} +1 -1
- package/dist/{chunk-6JTB2X72.mjs → chunk-ODO4GMW7.mjs} +3 -3
- package/dist/{chunk-6JTB2X72.mjs.map → chunk-ODO4GMW7.mjs.map} +1 -1
- package/dist/{chunk-WGEGR3DF.cjs → chunk-OLISEQHS.cjs} +5 -2
- package/dist/{chunk-WGEGR3DF.cjs.map → chunk-OLISEQHS.cjs.map} +1 -1
- package/dist/{chunk-PZKAH7WQ.mjs → chunk-PVAX67JG.mjs} +3 -3
- package/dist/{chunk-PZKAH7WQ.mjs.map → chunk-PVAX67JG.mjs.map} +1 -1
- package/dist/{chunk-PRPG2T2E.cjs → chunk-QJ6GTUCO.cjs} +6 -6
- package/dist/{chunk-PRPG2T2E.cjs.map → chunk-QJ6GTUCO.cjs.map} +1 -1
- package/dist/chunk-QW4RBGHN.cjs +961 -0
- package/dist/chunk-QW4RBGHN.cjs.map +1 -0
- package/dist/{chunk-33AMWFBZ.cjs → chunk-SGP7V2UW.cjs} +15 -15
- package/dist/{chunk-33AMWFBZ.cjs.map → chunk-SGP7V2UW.cjs.map} +1 -1
- package/dist/{chunk-FX2QFYWF.mjs → chunk-VWQ5WOIL.mjs} +3 -3
- package/dist/{chunk-FX2QFYWF.mjs.map → chunk-VWQ5WOIL.mjs.map} +1 -1
- package/dist/{chunk-ZLQHUZDU.cjs → chunk-YDPDTOSP.cjs} +139 -139
- package/dist/{chunk-ZLQHUZDU.cjs.map → chunk-YDPDTOSP.cjs.map} +1 -1
- package/dist/{chunk-77HQWEQ6.cjs → chunk-YW5IVWHQ.cjs} +33 -33
- package/dist/{chunk-77HQWEQ6.cjs.map → chunk-YW5IVWHQ.cjs.map} +1 -1
- package/dist/{chunk-YXBOAGIM.cjs → chunk-YXZ6GU7H.cjs} +7 -7
- package/dist/{chunk-YXBOAGIM.cjs.map → chunk-YXZ6GU7H.cjs.map} +1 -1
- package/dist/{chunk-62Y65TGK.mjs → chunk-ZUFTH5IR.mjs} +8 -631
- package/dist/chunk-ZUFTH5IR.mjs.map +1 -0
- package/dist/components-EHOGXATG.cjs +22 -0
- package/dist/{components-5UXYNAKR.cjs.map → components-EHOGXATG.cjs.map} +1 -1
- package/dist/components-MQ6DR7TX.cjs +26 -0
- package/dist/{components-CFXOEVPN.mjs.map → components-MQ6DR7TX.cjs.map} +1 -1
- package/dist/components-XRX7QGLB.mjs +5 -0
- package/dist/{components-WYEZL5TE.cjs.map → components-XRX7QGLB.mjs.map} +1 -1
- package/dist/components-YATKRWLH.mjs +5 -0
- package/dist/{components-ZAGG2PBO.mjs.map → components-YATKRWLH.mjs.map} +1 -1
- package/dist/file-icon/index.cjs +6 -6
- package/dist/file-icon/index.mjs +1 -1
- package/dist/index.cjs +735 -215
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +972 -39
- package/dist/index.d.ts +972 -39
- package/dist/index.mjs +387 -31
- package/dist/index.mjs.map +1 -1
- package/dist/tree/index.cjs +38 -38
- package/dist/tree/index.d.cts +2 -2
- package/dist/tree/index.d.ts +2 -2
- package/dist/tree/index.mjs +3 -3
- package/package.json +6 -6
- package/src/index.ts +5 -0
- package/src/stories/index.ts +3 -1
- package/src/tools/Chat/Chat.story.tsx +1006 -0
- package/src/tools/Chat/README.md +528 -0
- package/src/tools/Chat/components/Attachments.tsx +192 -0
- package/src/tools/Chat/components/ChatRoot.tsx +201 -0
- package/src/tools/Chat/components/Composer.tsx +134 -0
- package/src/tools/Chat/components/EmptyState.tsx +47 -0
- package/src/tools/Chat/components/ErrorBanner.tsx +47 -0
- package/src/tools/Chat/components/JumpToLatest.tsx +30 -0
- package/src/tools/Chat/components/MessageActions.tsx +72 -0
- package/src/tools/Chat/components/MessageBubble.tsx +228 -0
- package/src/tools/Chat/components/MessageList.tsx +82 -0
- package/src/tools/Chat/components/Sources.tsx +55 -0
- package/src/tools/Chat/components/StreamingIndicator.tsx +29 -0
- package/src/tools/Chat/components/ToolCalls.tsx +172 -0
- package/src/tools/Chat/components/index.ts +24 -0
- package/src/tools/Chat/config.ts +55 -0
- package/src/tools/Chat/context/ChatProvider.tsx +122 -0
- package/src/tools/Chat/context/index.ts +9 -0
- package/src/tools/Chat/core/audio/audioBus.ts +172 -0
- package/src/tools/Chat/core/audio/index.ts +8 -0
- package/src/tools/Chat/core/audio/preferences.ts +68 -0
- package/src/tools/Chat/core/audio/types.ts +49 -0
- package/src/tools/Chat/core/ids.ts +16 -0
- package/src/tools/Chat/core/index.ts +5 -0
- package/src/tools/Chat/core/markdown.ts +56 -0
- package/src/tools/Chat/core/payload-dispatch.ts +54 -0
- package/src/tools/Chat/core/persona.ts +35 -0
- package/src/tools/Chat/core/reducer.ts +335 -0
- package/src/tools/Chat/core/transport/http.ts +167 -0
- package/src/tools/Chat/core/transport/index.ts +13 -0
- package/src/tools/Chat/core/transport/mock.ts +134 -0
- package/src/tools/Chat/core/transport/sse.ts +116 -0
- package/src/tools/Chat/core/transport/types.ts +24 -0
- package/src/tools/Chat/hooks/index.ts +26 -0
- package/src/tools/Chat/hooks/useChat.ts +440 -0
- package/src/tools/Chat/hooks/useChatAudio.ts +191 -0
- package/src/tools/Chat/hooks/useChatComposer.ts +227 -0
- package/src/tools/Chat/hooks/useChatHistory.ts +59 -0
- package/src/tools/Chat/hooks/useChatLayout.ts +111 -0
- package/src/tools/Chat/hooks/useChatLightbox.ts +34 -0
- package/src/tools/Chat/hooks/useChatScroll.ts +132 -0
- package/src/tools/Chat/index.ts +158 -0
- package/src/tools/Chat/lazy.tsx +14 -0
- package/src/tools/Chat/types.ts +237 -0
- package/src/tools/Chat/utils/collectImageAttachments.ts +13 -0
- package/src/tools/Map/README.md +384 -0
- package/dist/DocsLayout-CTJINVBM.mjs.map +0 -1
- package/dist/DocsLayout-XLDB6CJ2.cjs.map +0 -1
- package/dist/JsonSchemaForm-6WMS4CIY.cjs +0 -13
- package/dist/JsonSchemaForm-KX4JT3M4.mjs +0 -4
- package/dist/JsonTree-F27RMYSI.cjs +0 -11
- package/dist/JsonTree-QTJYSHCV.mjs +0 -5
- package/dist/Player-M3GC3VPE.mjs +0 -4
- package/dist/Player-ZL2X5LGG.cjs +0 -13
- package/dist/TreeRoot-A3J65L6F.mjs +0 -4
- package/dist/TreeRoot-DSK5JILT.cjs +0 -19
- package/dist/chunk-62Y65TGK.mjs.map +0 -1
- package/dist/chunk-TKSFZHCG.cjs +0 -1597
- package/dist/chunk-TKSFZHCG.cjs.map +0 -1
- package/dist/components-5UXYNAKR.cjs +0 -22
- package/dist/components-CFXOEVPN.mjs +0 -5
- package/dist/components-WYEZL5TE.cjs +0 -26
- package/dist/components-ZAGG2PBO.mjs +0 -5
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode } from 'react';
|
|
4
|
+
import { File as FileIcon, X } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
|
|
8
|
+
import type { ChatAttachment } from '../types';
|
|
9
|
+
|
|
10
|
+
export interface AttachmentRendererArgs {
|
|
11
|
+
attachment: ChatAttachment;
|
|
12
|
+
/** True when shown inside the composer's staging tray (denser layout). */
|
|
13
|
+
isInComposer: boolean;
|
|
14
|
+
onClick?: () => void;
|
|
15
|
+
onRemove?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type AttachmentRenderer = (args: AttachmentRendererArgs) => ReactNode;
|
|
19
|
+
|
|
20
|
+
export interface AttachmentRendererMap {
|
|
21
|
+
image?: AttachmentRenderer;
|
|
22
|
+
audio?: AttachmentRenderer;
|
|
23
|
+
video?: AttachmentRenderer;
|
|
24
|
+
file?: AttachmentRenderer;
|
|
25
|
+
/** Fallback renderer when no per-type entry matched. */
|
|
26
|
+
default?: AttachmentRenderer;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CommonProps {
|
|
30
|
+
attachments: ChatAttachment[];
|
|
31
|
+
maxVisible?: number;
|
|
32
|
+
onClick?: (a: ChatAttachment) => void;
|
|
33
|
+
onRemove?: (a: ChatAttachment) => void;
|
|
34
|
+
isInComposer?: boolean;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// AttachmentsGrid — flex-wrap, ideal for thumbnails / file chips.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export interface AttachmentsGridProps extends CommonProps {
|
|
43
|
+
layout?: 'wrap' | 'grid';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function AttachmentsGrid({
|
|
47
|
+
attachments,
|
|
48
|
+
maxVisible,
|
|
49
|
+
onClick,
|
|
50
|
+
onRemove,
|
|
51
|
+
isInComposer = false,
|
|
52
|
+
layout = 'wrap',
|
|
53
|
+
className,
|
|
54
|
+
}: AttachmentsGridProps) {
|
|
55
|
+
if (!attachments?.length) return null;
|
|
56
|
+
const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
className={cn(
|
|
60
|
+
layout === 'grid' ? 'grid grid-cols-3 gap-2' : 'flex flex-wrap gap-2',
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
{visible.map((a) => (
|
|
65
|
+
<AttachmentTile
|
|
66
|
+
key={a.id}
|
|
67
|
+
attachment={a}
|
|
68
|
+
isInComposer={isInComposer}
|
|
69
|
+
onClick={onClick ? () => onClick(a) : undefined}
|
|
70
|
+
onRemove={onRemove ? () => onRemove(a) : undefined}
|
|
71
|
+
/>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// AttachmentsList — vertical stack, designed for rich custom renderers
|
|
79
|
+
// (LazyAudioPlayer, video players, document previews).
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export interface AttachmentsListProps extends CommonProps {
|
|
83
|
+
/** Per-type renderer overrides. Falls back to the default tile. */
|
|
84
|
+
renderers?: AttachmentRendererMap;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function AttachmentsList({
|
|
88
|
+
attachments,
|
|
89
|
+
maxVisible,
|
|
90
|
+
onClick,
|
|
91
|
+
onRemove,
|
|
92
|
+
renderers,
|
|
93
|
+
isInComposer = false,
|
|
94
|
+
className,
|
|
95
|
+
}: AttachmentsListProps) {
|
|
96
|
+
if (!attachments?.length) return null;
|
|
97
|
+
const visible = maxVisible ? attachments.slice(0, maxVisible) : attachments;
|
|
98
|
+
return (
|
|
99
|
+
<div className={cn('flex w-full flex-col gap-2', className)}>
|
|
100
|
+
{visible.map((a) => {
|
|
101
|
+
const renderer = renderers?.[a.type] ?? renderers?.default;
|
|
102
|
+
const args: AttachmentRendererArgs = {
|
|
103
|
+
attachment: a,
|
|
104
|
+
isInComposer,
|
|
105
|
+
onClick: onClick ? () => onClick(a) : undefined,
|
|
106
|
+
onRemove: onRemove ? () => onRemove(a) : undefined,
|
|
107
|
+
};
|
|
108
|
+
if (renderer) {
|
|
109
|
+
return (
|
|
110
|
+
<div key={a.id} className="relative w-full min-w-0">
|
|
111
|
+
{renderer(args)}
|
|
112
|
+
{args.onRemove ? <RemoveBtn onRemove={args.onRemove} /> : null}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return <AttachmentTile key={a.id} {...args} />;
|
|
117
|
+
})}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Attachments — backwards-compatible facade. Picks the right component based
|
|
124
|
+
// on whether `renderers` are supplied. Existing call-sites keep working.
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
export interface AttachmentsProps extends CommonProps {
|
|
128
|
+
layout?: 'grid' | 'row';
|
|
129
|
+
renderers?: AttachmentRendererMap;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function Attachments(props: AttachmentsProps) {
|
|
133
|
+
const { renderers, layout, ...rest } = props;
|
|
134
|
+
if (renderers) {
|
|
135
|
+
return <AttachmentsList {...rest} renderers={renderers} />;
|
|
136
|
+
}
|
|
137
|
+
return <AttachmentsGrid {...rest} layout={layout === 'grid' ? 'grid' : 'wrap'} />;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Tile + remove btn (default renderers).
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function AttachmentTile({ attachment, onClick, onRemove }: AttachmentRendererArgs) {
|
|
145
|
+
const isImage = attachment.type === 'image';
|
|
146
|
+
const isUploading = attachment.status === 'uploading';
|
|
147
|
+
|
|
148
|
+
const inner = isImage ? (
|
|
149
|
+
<img
|
|
150
|
+
src={attachment.thumbnailUrl ?? attachment.url}
|
|
151
|
+
alt={attachment.name ?? 'attachment'}
|
|
152
|
+
className="h-16 w-16 rounded-md object-cover"
|
|
153
|
+
loading="lazy"
|
|
154
|
+
/>
|
|
155
|
+
) : (
|
|
156
|
+
<div className="flex max-w-44 items-center gap-2 rounded-md border border-border bg-background/60 px-2 py-1.5 text-xs">
|
|
157
|
+
<FileIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
|
158
|
+
<span className="truncate">{attachment.name ?? 'file'}</span>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className="relative">
|
|
164
|
+
{onClick ? (
|
|
165
|
+
<button type="button" onClick={onClick} className="block">
|
|
166
|
+
{inner}
|
|
167
|
+
</button>
|
|
168
|
+
) : (
|
|
169
|
+
inner
|
|
170
|
+
)}
|
|
171
|
+
{isUploading ? (
|
|
172
|
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-background/70 text-[10px] font-medium">
|
|
173
|
+
{attachment.progress != null ? `${Math.round(attachment.progress * 100)}%` : '…'}
|
|
174
|
+
</div>
|
|
175
|
+
) : null}
|
|
176
|
+
{onRemove ? <RemoveBtn onRemove={onRemove} /> : null}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function RemoveBtn({ onRemove }: { onRemove: () => void }) {
|
|
182
|
+
return (
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
aria-label="Remove attachment"
|
|
186
|
+
onClick={onRemove}
|
|
187
|
+
className="absolute -right-1.5 -top-1.5 grid h-4 w-4 place-items-center rounded-full border border-border bg-background text-muted-foreground hover:bg-destructive hover:text-destructive-foreground"
|
|
188
|
+
>
|
|
189
|
+
<X aria-hidden className="size-2.5" />
|
|
190
|
+
</button>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
import type { ChatAttachment, ChatConfig, ChatMessage, ChatTransport } from '../types';
|
|
8
|
+
import type { ChatAudioConfig } from '../core/audio/types';
|
|
9
|
+
import { ChatProvider, useChatContext, type ChatContextValue } from '../context';
|
|
10
|
+
import { useChatComposer, type UseChatComposerReturn } from '../hooks/useChatComposer';
|
|
11
|
+
import { useChatScroll } from '../hooks/useChatScroll';
|
|
12
|
+
import { useChatHistory } from '../hooks/useChatHistory';
|
|
13
|
+
import { Composer } from './Composer';
|
|
14
|
+
import { EmptyState } from './EmptyState';
|
|
15
|
+
import { ErrorBanner } from './ErrorBanner';
|
|
16
|
+
import { JumpToLatest } from './JumpToLatest';
|
|
17
|
+
import { MessageBubble } from './MessageBubble';
|
|
18
|
+
import { MessageList } from './MessageList';
|
|
19
|
+
import type { AttachmentRendererMap } from './Attachments';
|
|
20
|
+
import type { ToolCallsProps } from './ToolCalls';
|
|
21
|
+
|
|
22
|
+
export interface ChatRootProps {
|
|
23
|
+
// ---- core wiring -------------------------------------------------------
|
|
24
|
+
transport: ChatTransport;
|
|
25
|
+
config?: ChatConfig;
|
|
26
|
+
initialSessionId?: string;
|
|
27
|
+
autoCreateSession?: boolean;
|
|
28
|
+
streaming?: boolean;
|
|
29
|
+
/** Audio-trigger configuration. Off by default (no `sounds` map). */
|
|
30
|
+
audio?: ChatAudioConfig;
|
|
31
|
+
className?: string;
|
|
32
|
+
|
|
33
|
+
// ---- named ReactNode slots --------------------------------------------
|
|
34
|
+
/** Sticky banner above the message list (e.g. quota warning). */
|
|
35
|
+
banner?: ReactNode;
|
|
36
|
+
/** Header row below the banner — title / actions / session switcher. */
|
|
37
|
+
header?: ReactNode;
|
|
38
|
+
/** Footer slot below the composer (disclaimers, model picker). */
|
|
39
|
+
footer?: ReactNode;
|
|
40
|
+
/** Replaces the default `<EmptyState>` rendered when the conversation is empty. */
|
|
41
|
+
empty?: ReactNode;
|
|
42
|
+
/** Slot left of the textarea inside `<Composer>`. */
|
|
43
|
+
composerToolbarStart?: ReactNode;
|
|
44
|
+
/** Slot right of the textarea inside `<Composer>`. */
|
|
45
|
+
composerToolbarEnd?: ReactNode;
|
|
46
|
+
/** Replaces the default attachment tray inside `<Composer>`. */
|
|
47
|
+
composerAttachmentTray?: ReactNode;
|
|
48
|
+
/** Replaces the default `<JumpToLatest>` floating pill. */
|
|
49
|
+
jumpToLatest?: ReactNode;
|
|
50
|
+
|
|
51
|
+
// ---- render-prop slots (need access to data) --------------------------
|
|
52
|
+
/** Replace `<MessageBubble>` per message. */
|
|
53
|
+
renderMessage?: (m: ChatMessage, i: number) => ReactNode;
|
|
54
|
+
/** Render the header lazily — receives the chat context. */
|
|
55
|
+
renderHeader?: (ctx: ChatContextValue) => ReactNode;
|
|
56
|
+
/** Render the empty-state lazily — receives a `setValue` to seed the composer. */
|
|
57
|
+
renderEmpty?: (api: { setValue: (v: string) => void; focus: () => void }) => ReactNode;
|
|
58
|
+
/** Forwarded into `<MessageBubble toolCallsProps>` so hosts can swap payload renderers. */
|
|
59
|
+
toolCallsProps?: Omit<ToolCallsProps, 'calls'>;
|
|
60
|
+
/** Per-type attachment renderers — `{ image, audio, video, file, default }`. */
|
|
61
|
+
attachmentRenderers?: AttachmentRendererMap;
|
|
62
|
+
/** Called when an attachment tile is clicked (e.g. open lightbox). */
|
|
63
|
+
onAttachmentOpen?: (attachment: ChatAttachment) => void;
|
|
64
|
+
|
|
65
|
+
// ---- composer customization -------------------------------------------
|
|
66
|
+
/** Show the paperclip "attach" button in the composer. */
|
|
67
|
+
showAttachmentButton?: boolean;
|
|
68
|
+
/** Called when the user clicks the attach button (host opens its file picker). */
|
|
69
|
+
onPickFiles?: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function ChatRoot(props: ChatRootProps) {
|
|
73
|
+
const { transport, config, initialSessionId, autoCreateSession, streaming, audio, className, ...slots } = props;
|
|
74
|
+
return (
|
|
75
|
+
<ChatProvider
|
|
76
|
+
transport={transport}
|
|
77
|
+
config={config}
|
|
78
|
+
initialSessionId={initialSessionId}
|
|
79
|
+
autoCreateSession={autoCreateSession}
|
|
80
|
+
streaming={streaming}
|
|
81
|
+
audio={audio}
|
|
82
|
+
>
|
|
83
|
+
<ChatRootShell className={className} slots={slots} />
|
|
84
|
+
</ChatProvider>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface ChatRootShellProps {
|
|
89
|
+
className?: string;
|
|
90
|
+
slots: Omit<ChatRootProps, 'transport' | 'config' | 'initialSessionId' | 'autoCreateSession' | 'streaming' | 'audio' | 'className'>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ChatRootShell({ className, slots }: ChatRootShellProps) {
|
|
94
|
+
const chat = useChatContext();
|
|
95
|
+
const composer = useChatComposer({
|
|
96
|
+
onSubmit: (content, attachments) => chat.sendMessage(content, attachments),
|
|
97
|
+
disabled: chat.isStreaming,
|
|
98
|
+
});
|
|
99
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
100
|
+
const bottomRef = useRef<HTMLDivElement | null>(null);
|
|
101
|
+
const topRef = useRef<HTMLDivElement | null>(null);
|
|
102
|
+
|
|
103
|
+
const scroll = useChatScroll({
|
|
104
|
+
containerRef,
|
|
105
|
+
bottomRef,
|
|
106
|
+
isStreaming: chat.isStreaming,
|
|
107
|
+
messagesCount: chat.messages.length,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
useChatHistory({
|
|
111
|
+
containerRef,
|
|
112
|
+
topSentinelRef: topRef,
|
|
113
|
+
hasMore: chat.hasMore,
|
|
114
|
+
isLoadingMore: chat.isLoadingMore,
|
|
115
|
+
loadMore: chat.loadMore,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const greeting = chat.config.greeting ?? 'How can I help?';
|
|
119
|
+
const description = chat.config.description;
|
|
120
|
+
const suggestions = chat.config.suggestions;
|
|
121
|
+
|
|
122
|
+
const headerNode = slots.renderHeader ? slots.renderHeader(chat) : slots.header;
|
|
123
|
+
|
|
124
|
+
const emptyNode = slots.empty
|
|
125
|
+
?? (slots.renderEmpty
|
|
126
|
+
? slots.renderEmpty({ setValue: composer.setValue, focus: composer.focus })
|
|
127
|
+
: (
|
|
128
|
+
<EmptyState
|
|
129
|
+
greeting={greeting}
|
|
130
|
+
description={description}
|
|
131
|
+
suggestions={suggestions}
|
|
132
|
+
onPickSuggestion={(prompt) => {
|
|
133
|
+
composer.setValue(prompt);
|
|
134
|
+
composer.focus();
|
|
135
|
+
}}
|
|
136
|
+
/>
|
|
137
|
+
));
|
|
138
|
+
|
|
139
|
+
const renderItem = slots.renderMessage
|
|
140
|
+
?? ((m: ChatMessage) => (
|
|
141
|
+
<MessageBubble
|
|
142
|
+
key={m.id}
|
|
143
|
+
message={m}
|
|
144
|
+
toolCallsProps={slots.toolCallsProps}
|
|
145
|
+
attachmentRenderers={slots.attachmentRenderers}
|
|
146
|
+
onAttachmentOpen={slots.onAttachmentOpen}
|
|
147
|
+
onCopy={() => copy(m.content)}
|
|
148
|
+
onRegenerate={() => void chat.regenerate(m.id)}
|
|
149
|
+
onDelete={() => chat.deleteMessage(m.id)}
|
|
150
|
+
/>
|
|
151
|
+
));
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div className={cn('relative flex h-full min-h-0 flex-col overflow-hidden', className)}>
|
|
155
|
+
{slots.banner ?? null}
|
|
156
|
+
{headerNode ?? null}
|
|
157
|
+
<div className="relative flex min-h-0 flex-1 flex-col">
|
|
158
|
+
<ErrorBanner
|
|
159
|
+
error={chat.error}
|
|
160
|
+
onDismiss={chat.error ? () => chat.clearMessages() : undefined}
|
|
161
|
+
onRetry={chat.error ? () => void chat.regenerate() : undefined}
|
|
162
|
+
/>
|
|
163
|
+
<MessageList
|
|
164
|
+
ref={containerRef}
|
|
165
|
+
topSentinelRef={topRef}
|
|
166
|
+
bottomRef={bottomRef}
|
|
167
|
+
renderItem={renderItem}
|
|
168
|
+
renderEmpty={() => <>{emptyNode}</>}
|
|
169
|
+
/>
|
|
170
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-2 flex justify-center">
|
|
171
|
+
{slots.jumpToLatest ?? (
|
|
172
|
+
<JumpToLatest
|
|
173
|
+
visible={!scroll.isAtBottom}
|
|
174
|
+
unreadCount={scroll.unreadCount}
|
|
175
|
+
onClick={() => scroll.scrollToBottom(true)}
|
|
176
|
+
/>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
<Composer
|
|
181
|
+
composer={composer}
|
|
182
|
+
placeholder={chat.config.placeholder}
|
|
183
|
+
showAttachmentButton={slots.showAttachmentButton}
|
|
184
|
+
onPickFiles={slots.onPickFiles}
|
|
185
|
+
toolbarStart={slots.composerToolbarStart}
|
|
186
|
+
toolbarEnd={slots.composerToolbarEnd}
|
|
187
|
+
attachmentTray={slots.composerAttachmentTray}
|
|
188
|
+
/>
|
|
189
|
+
{slots.footer ?? null}
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function copy(text: string) {
|
|
195
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
196
|
+
void navigator.clipboard.writeText(text);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// re-export for convenience: composer hook return is a common slot dependency
|
|
201
|
+
export type { UseChatComposerReturn };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, forwardRef } from 'react';
|
|
4
|
+
import { Paperclip, Send, Square } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import { Button, Textarea } from '@djangocfg/ui-core/components';
|
|
7
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
|
+
|
|
9
|
+
import { useChatContextOptional } from '../context';
|
|
10
|
+
import type { UseChatComposerReturn } from '../hooks/useChatComposer';
|
|
11
|
+
import { Attachments } from './Attachments';
|
|
12
|
+
|
|
13
|
+
export interface ComposerProps {
|
|
14
|
+
composer: UseChatComposerReturn;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
showAttachmentButton?: boolean;
|
|
18
|
+
onPickFiles?: () => void;
|
|
19
|
+
toolbarStart?: ReactNode;
|
|
20
|
+
toolbarEnd?: ReactNode;
|
|
21
|
+
attachmentTray?: ReactNode;
|
|
22
|
+
className?: string;
|
|
23
|
+
textareaClassName?: string;
|
|
24
|
+
/** Show "Stop" button instead of "Send" while streaming. */
|
|
25
|
+
isStreaming?: boolean;
|
|
26
|
+
onCancel?: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Composer(
|
|
30
|
+
{
|
|
31
|
+
composer,
|
|
32
|
+
placeholder = 'Type a message...',
|
|
33
|
+
disabled,
|
|
34
|
+
showAttachmentButton = false,
|
|
35
|
+
onPickFiles,
|
|
36
|
+
toolbarStart,
|
|
37
|
+
toolbarEnd,
|
|
38
|
+
attachmentTray,
|
|
39
|
+
className,
|
|
40
|
+
textareaClassName,
|
|
41
|
+
isStreaming: isStreamingProp,
|
|
42
|
+
onCancel: onCancelProp,
|
|
43
|
+
},
|
|
44
|
+
ref,
|
|
45
|
+
) {
|
|
46
|
+
const ctx = useChatContextOptional();
|
|
47
|
+
const isStreaming = isStreamingProp ?? ctx?.isStreaming ?? false;
|
|
48
|
+
const onCancel = onCancelProp ?? ctx?.cancelStream;
|
|
49
|
+
const isDisabled = disabled ?? isStreaming;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
ref={ref}
|
|
54
|
+
className={cn(
|
|
55
|
+
'border-t border-border bg-background/95 px-2.5 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]',
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{composer.attachments.length > 0 ? (
|
|
60
|
+
<div className="mb-1.5">
|
|
61
|
+
{attachmentTray ?? (
|
|
62
|
+
<Attachments
|
|
63
|
+
attachments={composer.attachments}
|
|
64
|
+
onRemove={(a) => composer.removeAttachment(a.id)}
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
) : null}
|
|
69
|
+
|
|
70
|
+
{/* `[&>*]:h-9` enforces a consistent 36px slot height so toolbar
|
|
71
|
+
* buttons line up with the textarea baseline (`min-h-[36px]`).
|
|
72
|
+
* Toolbar slots that want to opt out can pass an explicit class
|
|
73
|
+
* like `!h-auto`. */}
|
|
74
|
+
<div className="flex items-end gap-1.5 [&>:not(textarea)]:shrink-0 [&>:not(textarea)]:h-9">
|
|
75
|
+
{showAttachmentButton ? (
|
|
76
|
+
<Button
|
|
77
|
+
type="button"
|
|
78
|
+
variant="ghost"
|
|
79
|
+
size="icon"
|
|
80
|
+
onClick={onPickFiles}
|
|
81
|
+
aria-label="Attach files"
|
|
82
|
+
disabled={isDisabled}
|
|
83
|
+
className="h-9 w-9"
|
|
84
|
+
>
|
|
85
|
+
<Paperclip aria-hidden className="size-4" />
|
|
86
|
+
</Button>
|
|
87
|
+
) : null}
|
|
88
|
+
|
|
89
|
+
{toolbarStart}
|
|
90
|
+
|
|
91
|
+
<Textarea
|
|
92
|
+
{...composer.textareaProps}
|
|
93
|
+
rows={1}
|
|
94
|
+
placeholder={placeholder}
|
|
95
|
+
aria-label={placeholder}
|
|
96
|
+
aria-multiline="true"
|
|
97
|
+
disabled={isDisabled}
|
|
98
|
+
className={cn(
|
|
99
|
+
'min-h-9 max-h-60 flex-1 resize-none rounded-2xl px-3.5 py-2 text-base sm:text-sm',
|
|
100
|
+
textareaClassName,
|
|
101
|
+
)}
|
|
102
|
+
/>
|
|
103
|
+
|
|
104
|
+
{toolbarEnd}
|
|
105
|
+
|
|
106
|
+
{isStreaming ? (
|
|
107
|
+
<Button
|
|
108
|
+
type="button"
|
|
109
|
+
variant="secondary"
|
|
110
|
+
size="icon"
|
|
111
|
+
onClick={onCancel}
|
|
112
|
+
aria-label="Stop"
|
|
113
|
+
aria-keyshortcuts="Escape"
|
|
114
|
+
className="h-9 w-9"
|
|
115
|
+
>
|
|
116
|
+
<Square aria-hidden className="size-3.5" />
|
|
117
|
+
</Button>
|
|
118
|
+
) : (
|
|
119
|
+
<Button
|
|
120
|
+
type="button"
|
|
121
|
+
size="icon"
|
|
122
|
+
onClick={() => void composer.submit()}
|
|
123
|
+
disabled={!composer.canSubmit}
|
|
124
|
+
aria-label="Send"
|
|
125
|
+
aria-keyshortcuts="Enter"
|
|
126
|
+
className="h-9 w-9"
|
|
127
|
+
>
|
|
128
|
+
<Send aria-hidden className="size-4" />
|
|
129
|
+
</Button>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Sparkles } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
export interface EmptyStateProps {
|
|
8
|
+
greeting?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
suggestions?: Array<{ label: string; prompt: string }>;
|
|
11
|
+
onPickSuggestion?: (prompt: string) => void;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function EmptyState({
|
|
16
|
+
greeting,
|
|
17
|
+
description,
|
|
18
|
+
suggestions,
|
|
19
|
+
onPickSuggestion,
|
|
20
|
+
className,
|
|
21
|
+
}: EmptyStateProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div className={cn('flex flex-col items-center gap-3 px-4 py-12 text-center', className)}>
|
|
24
|
+
<div className="grid size-10 place-items-center rounded-full bg-muted">
|
|
25
|
+
<Sparkles aria-hidden className="size-5 text-muted-foreground" />
|
|
26
|
+
</div>
|
|
27
|
+
{greeting ? <h2 className="text-base font-semibold">{greeting}</h2> : null}
|
|
28
|
+
{description ? (
|
|
29
|
+
<p className="max-w-md text-sm text-muted-foreground">{description}</p>
|
|
30
|
+
) : null}
|
|
31
|
+
{suggestions?.length ? (
|
|
32
|
+
<div className="mt-2 grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
|
|
33
|
+
{suggestions.map((s) => (
|
|
34
|
+
<button
|
|
35
|
+
key={s.prompt}
|
|
36
|
+
type="button"
|
|
37
|
+
onClick={() => onPickSuggestion?.(s.prompt)}
|
|
38
|
+
className="rounded-lg border border-border bg-background/60 px-3 py-2 text-left text-xs hover:bg-accent"
|
|
39
|
+
>
|
|
40
|
+
{s.label}
|
|
41
|
+
</button>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
) : null}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AlertCircle, RefreshCw, X } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
export interface ErrorBannerProps {
|
|
8
|
+
error: string | null;
|
|
9
|
+
onDismiss?: () => void;
|
|
10
|
+
onRetry?: () => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ErrorBanner({ error, onDismiss, onRetry, className }: ErrorBannerProps) {
|
|
15
|
+
if (!error) return null;
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
role="alert"
|
|
19
|
+
className={cn(
|
|
20
|
+
'mx-2.5 my-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive',
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
24
|
+
<AlertCircle aria-hidden className="mt-0.5 size-3.5 shrink-0" />
|
|
25
|
+
<p className="min-w-0 flex-1 break-words">{error}</p>
|
|
26
|
+
{onRetry ? (
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
onClick={onRetry}
|
|
30
|
+
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-destructive/15"
|
|
31
|
+
>
|
|
32
|
+
<RefreshCw aria-hidden className="size-3" /> Retry
|
|
33
|
+
</button>
|
|
34
|
+
) : null}
|
|
35
|
+
{onDismiss ? (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
aria-label="Dismiss"
|
|
39
|
+
onClick={onDismiss}
|
|
40
|
+
className="rounded p-0.5 hover:bg-destructive/15"
|
|
41
|
+
>
|
|
42
|
+
<X aria-hidden className="size-3" />
|
|
43
|
+
</button>
|
|
44
|
+
) : null}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ArrowDown } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
export interface JumpToLatestProps {
|
|
8
|
+
visible?: boolean;
|
|
9
|
+
unreadCount?: number;
|
|
10
|
+
onClick?: () => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function JumpToLatest({ visible, unreadCount = 0, onClick, className }: JumpToLatestProps) {
|
|
15
|
+
if (!visible) return null;
|
|
16
|
+
return (
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
onClick={onClick}
|
|
20
|
+
aria-live="polite"
|
|
21
|
+
className={cn(
|
|
22
|
+
'pointer-events-auto inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-3 py-1 text-xs shadow-md hover:bg-accent',
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
>
|
|
26
|
+
<ArrowDown aria-hidden className="size-3.5" />
|
|
27
|
+
{unreadCount > 0 ? `${unreadCount} new` : 'Jump to latest'}
|
|
28
|
+
</button>
|
|
29
|
+
);
|
|
30
|
+
}
|