@gugacoder/agentic-chat 0.2.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/dist/components/Chat.d.ts +21 -0
- package/dist/components/Chat.js +13 -0
- package/dist/components/ErrorNote.d.ts +5 -0
- package/dist/components/ErrorNote.js +6 -0
- package/dist/components/LazyRender.d.ts +8 -0
- package/dist/components/LazyRender.js +22 -0
- package/dist/components/Markdown.d.ts +5 -0
- package/dist/components/Markdown.js +65 -0
- package/dist/components/MessageBubble.d.ts +10 -0
- package/dist/components/MessageBubble.js +39 -0
- package/dist/components/MessageInput.d.ts +19 -0
- package/dist/components/MessageInput.js +214 -0
- package/dist/components/MessageList.d.ts +12 -0
- package/dist/components/MessageList.js +68 -0
- package/dist/components/StreamingIndicator.d.ts +1 -0
- package/dist/components/StreamingIndicator.js +9 -0
- package/dist/conversations/CollapsibleGroup.d.ts +11 -0
- package/dist/conversations/CollapsibleGroup.js +9 -0
- package/dist/conversations/ConversationBar.d.ts +27 -0
- package/dist/conversations/ConversationBar.js +53 -0
- package/dist/conversations/ConversationList.d.ts +33 -0
- package/dist/conversations/ConversationList.js +48 -0
- package/dist/conversations/ConversationListItem.d.ts +20 -0
- package/dist/conversations/ConversationListItem.js +22 -0
- package/dist/conversations/DeleteDialog.d.ts +13 -0
- package/dist/conversations/DeleteDialog.js +8 -0
- package/dist/conversations/RenameDialog.d.ts +15 -0
- package/dist/conversations/RenameDialog.js +15 -0
- package/dist/conversations/index.d.ts +9 -0
- package/dist/conversations/index.js +5 -0
- package/dist/conversations/types.d.ts +21 -0
- package/dist/conversations/types.js +1 -0
- package/dist/conversations/useConversations.d.ts +19 -0
- package/dist/conversations/useConversations.js +102 -0
- package/dist/conversations/utils.d.ts +8 -0
- package/dist/conversations/utils.js +134 -0
- package/dist/display/AlertRenderer.d.ts +2 -0
- package/dist/display/AlertRenderer.js +13 -0
- package/dist/display/CarouselRenderer.d.ts +2 -0
- package/dist/display/CarouselRenderer.js +41 -0
- package/dist/display/ChartRenderer.d.ts +2 -0
- package/dist/display/ChartRenderer.js +76 -0
- package/dist/display/ChoiceButtonsRenderer.d.ts +6 -0
- package/dist/display/ChoiceButtonsRenderer.js +23 -0
- package/dist/display/CodeBlockRenderer.d.ts +2 -0
- package/dist/display/CodeBlockRenderer.js +17 -0
- package/dist/display/ComparisonTableRenderer.d.ts +2 -0
- package/dist/display/ComparisonTableRenderer.js +26 -0
- package/dist/display/DataTableRenderer.d.ts +2 -0
- package/dist/display/DataTableRenderer.js +74 -0
- package/dist/display/FileCardRenderer.d.ts +2 -0
- package/dist/display/FileCardRenderer.js +31 -0
- package/dist/display/GalleryRenderer.d.ts +2 -0
- package/dist/display/GalleryRenderer.js +11 -0
- package/dist/display/ImageViewerRenderer.d.ts +2 -0
- package/dist/display/ImageViewerRenderer.js +15 -0
- package/dist/display/LinkPreviewRenderer.d.ts +2 -0
- package/dist/display/LinkPreviewRenderer.js +20 -0
- package/dist/display/MapViewRenderer.d.ts +2 -0
- package/dist/display/MapViewRenderer.js +20 -0
- package/dist/display/MetricCardRenderer.d.ts +2 -0
- package/dist/display/MetricCardRenderer.js +12 -0
- package/dist/display/PriceHighlightRenderer.d.ts +2 -0
- package/dist/display/PriceHighlightRenderer.js +13 -0
- package/dist/display/ProductCardRenderer.d.ts +2 -0
- package/dist/display/ProductCardRenderer.js +23 -0
- package/dist/display/ProgressStepsRenderer.d.ts +2 -0
- package/dist/display/ProgressStepsRenderer.js +14 -0
- package/dist/display/SourcesListRenderer.d.ts +2 -0
- package/dist/display/SourcesListRenderer.js +5 -0
- package/dist/display/SpreadsheetRenderer.d.ts +2 -0
- package/dist/display/SpreadsheetRenderer.js +32 -0
- package/dist/display/StepTimelineRenderer.d.ts +2 -0
- package/dist/display/StepTimelineRenderer.js +21 -0
- package/dist/display/index.d.ts +21 -0
- package/dist/display/index.js +20 -0
- package/dist/display/registry.d.ts +5 -0
- package/dist/display/registry.js +50 -0
- package/dist/hooks/ChatProvider.d.ts +10 -0
- package/dist/hooks/ChatProvider.js +14 -0
- package/dist/hooks/useBackboneChat.d.ts +37 -0
- package/dist/hooks/useBackboneChat.js +121 -0
- package/dist/hooks/useIsMobile.d.ts +1 -0
- package/dist/hooks/useIsMobile.js +12 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +40 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +5 -0
- package/dist/parts/PartRenderer.d.ts +40 -0
- package/dist/parts/PartRenderer.js +97 -0
- package/dist/parts/ReasoningBlock.d.ts +6 -0
- package/dist/parts/ReasoningBlock.js +18 -0
- package/dist/parts/ToolActivity.d.ts +11 -0
- package/dist/parts/ToolActivity.js +52 -0
- package/dist/parts/ToolResult.d.ts +7 -0
- package/dist/parts/ToolResult.js +38 -0
- package/dist/styles.css +2 -0
- package/dist/ui/alert.d.ts +12 -0
- package/dist/ui/alert.js +28 -0
- package/dist/ui/badge.d.ts +9 -0
- package/dist/ui/badge.js +20 -0
- package/dist/ui/button.d.ts +11 -0
- package/dist/ui/button.js +31 -0
- package/dist/ui/card.d.ts +8 -0
- package/dist/ui/card.js +21 -0
- package/dist/ui/collapsible.d.ts +1 -0
- package/dist/ui/collapsible.js +2 -0
- package/dist/ui/dialog.d.ts +19 -0
- package/dist/ui/dialog.js +23 -0
- package/dist/ui/dropdown-menu.d.ts +11 -0
- package/dist/ui/dropdown-menu.js +15 -0
- package/dist/ui/input.d.ts +3 -0
- package/dist/ui/input.js +6 -0
- package/dist/ui/progress.d.ts +7 -0
- package/dist/ui/progress.js +9 -0
- package/dist/ui/scroll-area.d.ts +5 -0
- package/dist/ui/scroll-area.js +12 -0
- package/dist/ui/separator.d.ts +4 -0
- package/dist/ui/separator.js +8 -0
- package/dist/ui/skeleton.d.ts +3 -0
- package/dist/ui/skeleton.js +6 -0
- package/dist/ui/table.d.ts +10 -0
- package/dist/ui/table.js +27 -0
- package/package.json +53 -0
- package/src/components/Chat.tsx +80 -0
- package/src/components/ErrorNote.tsx +32 -0
- package/src/components/LazyRender.tsx +42 -0
- package/src/components/Markdown.tsx +114 -0
- package/src/components/MessageBubble.tsx +102 -0
- package/src/components/MessageInput.tsx +421 -0
- package/src/components/MessageList.tsx +139 -0
- package/src/components/StreamingIndicator.tsx +19 -0
- package/src/conversations/CollapsibleGroup.tsx +41 -0
- package/src/conversations/ConversationBar.tsx +200 -0
- package/src/conversations/ConversationList.tsx +234 -0
- package/src/conversations/ConversationListItem.tsx +123 -0
- package/src/conversations/DeleteDialog.tsx +55 -0
- package/src/conversations/RenameDialog.tsx +74 -0
- package/src/conversations/index.ts +14 -0
- package/src/conversations/types.ts +17 -0
- package/src/conversations/useConversations.ts +148 -0
- package/src/conversations/utils.ts +159 -0
- package/src/display/AlertRenderer.tsx +27 -0
- package/src/display/CarouselRenderer.tsx +141 -0
- package/src/display/ChartRenderer.tsx +195 -0
- package/src/display/ChoiceButtonsRenderer.tsx +114 -0
- package/src/display/CodeBlockRenderer.tsx +49 -0
- package/src/display/ComparisonTableRenderer.tsx +132 -0
- package/src/display/DataTableRenderer.tsx +144 -0
- package/src/display/FileCardRenderer.tsx +55 -0
- package/src/display/GalleryRenderer.tsx +65 -0
- package/src/display/ImageViewerRenderer.tsx +114 -0
- package/src/display/LinkPreviewRenderer.tsx +74 -0
- package/src/display/MapViewRenderer.tsx +75 -0
- package/src/display/MetricCardRenderer.tsx +29 -0
- package/src/display/PriceHighlightRenderer.tsx +44 -0
- package/src/display/ProductCardRenderer.tsx +112 -0
- package/src/display/ProgressStepsRenderer.tsx +59 -0
- package/src/display/SourcesListRenderer.tsx +47 -0
- package/src/display/SpreadsheetRenderer.tsx +86 -0
- package/src/display/StepTimelineRenderer.tsx +75 -0
- package/src/display/index.ts +21 -0
- package/src/display/registry.ts +81 -0
- package/src/hooks/ChatProvider.tsx +22 -0
- package/src/hooks/useBackboneChat.ts +148 -0
- package/src/hooks/useIsMobile.ts +15 -0
- package/src/index.ts +80 -0
- package/src/lib/utils.ts +6 -0
- package/src/parts/PartRenderer.tsx +198 -0
- package/src/parts/ReasoningBlock.tsx +41 -0
- package/src/parts/ToolActivity.tsx +79 -0
- package/src/parts/ToolResult.tsx +79 -0
- package/src/styles.css +2 -0
- package/src/ui/alert.tsx +77 -0
- package/src/ui/badge.tsx +36 -0
- package/src/ui/button.tsx +54 -0
- package/src/ui/card.tsx +68 -0
- package/src/ui/collapsible.tsx +7 -0
- package/src/ui/dialog.tsx +122 -0
- package/src/ui/dropdown-menu.tsx +76 -0
- package/src/ui/input.tsx +24 -0
- package/src/ui/progress.tsx +36 -0
- package/src/ui/scroll-area.tsx +48 -0
- package/src/ui/separator.tsx +31 -0
- package/src/ui/skeleton.tsx +9 -0
- package/src/ui/table.tsx +114 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useRef, useState, useEffect, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface LazyRenderProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
minHeight?: number;
|
|
6
|
+
rootMargin?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function LazyRender({ children, minHeight = 120, rootMargin = "200px" }: LazyRenderProps) {
|
|
10
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
11
|
+
const [visible, setVisible] = useState(false);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const el = ref.current;
|
|
15
|
+
if (!el) return;
|
|
16
|
+
|
|
17
|
+
const observer = new IntersectionObserver(
|
|
18
|
+
([entry]) => {
|
|
19
|
+
if (entry.isIntersecting) {
|
|
20
|
+
setVisible(true);
|
|
21
|
+
observer.disconnect();
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
{ rootMargin }
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
observer.observe(el);
|
|
28
|
+
return () => observer.disconnect();
|
|
29
|
+
}, [rootMargin]);
|
|
30
|
+
|
|
31
|
+
if (visible) return <>{children}</>;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
ref={ref}
|
|
36
|
+
className="flex items-center justify-center text-muted-foreground text-xs rounded-md bg-muted/20 animate-pulse"
|
|
37
|
+
style={{ minHeight }}
|
|
38
|
+
>
|
|
39
|
+
Carregando...
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import ReactMarkdown from "react-markdown";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import rehypeHighlight from "rehype-highlight";
|
|
5
|
+
import type { Components } from "react-markdown";
|
|
6
|
+
import { cn } from "../lib/utils.js";
|
|
7
|
+
|
|
8
|
+
const REMARK_PLUGINS = [remarkGfm];
|
|
9
|
+
const REHYPE_PLUGINS = [rehypeHighlight];
|
|
10
|
+
|
|
11
|
+
const components: Components = {
|
|
12
|
+
h1({ children }) {
|
|
13
|
+
return <h1 className="text-xl font-semibold" style={{ marginTop: "20px", marginBottom: "8px" }}>{children}</h1>;
|
|
14
|
+
},
|
|
15
|
+
h2({ children }) {
|
|
16
|
+
return <h2 className="text-lg font-semibold" style={{ marginTop: "20px", marginBottom: "8px" }}>{children}</h2>;
|
|
17
|
+
},
|
|
18
|
+
h3({ children }) {
|
|
19
|
+
return <h3 className="text-base font-semibold" style={{ marginTop: "20px", marginBottom: "8px" }}>{children}</h3>;
|
|
20
|
+
},
|
|
21
|
+
h4({ children }) {
|
|
22
|
+
return <h4 className="font-semibold" style={{ marginTop: "20px", marginBottom: "8px" }}>{children}</h4>;
|
|
23
|
+
},
|
|
24
|
+
p({ children }) {
|
|
25
|
+
return <p className="mb-4 last:mb-0">{children}</p>;
|
|
26
|
+
},
|
|
27
|
+
ul({ children }) {
|
|
28
|
+
return <ul style={{ paddingLeft: "24px", marginTop: "8px", marginBottom: "8px", listStyleType: "disc" }}>{children}</ul>;
|
|
29
|
+
},
|
|
30
|
+
ol({ children }) {
|
|
31
|
+
return <ol style={{ paddingLeft: "24px", marginTop: "8px", marginBottom: "8px", listStyleType: "decimal" }}>{children}</ol>;
|
|
32
|
+
},
|
|
33
|
+
li({ children }) {
|
|
34
|
+
return <li style={{ marginTop: "4px", marginBottom: "4px" }}>{children}</li>;
|
|
35
|
+
},
|
|
36
|
+
hr() {
|
|
37
|
+
return <hr className="border-border" style={{ marginTop: "16px", marginBottom: "16px" }} />;
|
|
38
|
+
},
|
|
39
|
+
pre({ children }) {
|
|
40
|
+
return (
|
|
41
|
+
<pre className="bg-muted border border-border rounded-md overflow-hidden" style={{ marginTop: "12px", marginBottom: "12px" }}>
|
|
42
|
+
{children}
|
|
43
|
+
</pre>
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
code({ className, children }) {
|
|
47
|
+
const isBlock = className?.startsWith("language-");
|
|
48
|
+
if (isBlock) {
|
|
49
|
+
return (
|
|
50
|
+
<code className={cn("block p-4 overflow-x-auto font-mono text-sm", className)}>
|
|
51
|
+
{children}
|
|
52
|
+
</code>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return (
|
|
56
|
+
<code className="bg-muted border border-border rounded-sm px-1.5 py-0.5 font-mono text-sm">
|
|
57
|
+
{children}
|
|
58
|
+
</code>
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
a({ href, children }) {
|
|
62
|
+
return (
|
|
63
|
+
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline underline-offset-2 hover:opacity-80">
|
|
64
|
+
{children}
|
|
65
|
+
</a>
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
blockquote({ children }) {
|
|
69
|
+
return (
|
|
70
|
+
<blockquote className="border-l-[3px] border-border py-1 px-3 text-muted-foreground" style={{ marginTop: "12px", marginBottom: "12px" }}>
|
|
71
|
+
{children}
|
|
72
|
+
</blockquote>
|
|
73
|
+
);
|
|
74
|
+
},
|
|
75
|
+
table({ children }) {
|
|
76
|
+
return (
|
|
77
|
+
<div className="overflow-x-auto">
|
|
78
|
+
<table className="w-full border-collapse text-sm">{children}</table>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
th({ children }) {
|
|
83
|
+
return (
|
|
84
|
+
<th className="border border-border px-3 py-1.5 text-left font-semibold bg-muted">
|
|
85
|
+
{children}
|
|
86
|
+
</th>
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
td({ children }) {
|
|
90
|
+
return (
|
|
91
|
+
<td className="border border-border px-3 py-1.5 text-left">
|
|
92
|
+
{children}
|
|
93
|
+
</td>
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
interface MarkdownProps {
|
|
99
|
+
children: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const Markdown = memo(function Markdown({ children }: MarkdownProps) {
|
|
103
|
+
return (
|
|
104
|
+
<div className="text-foreground text-sm" style={{ lineHeight: "1.625em" }}>
|
|
105
|
+
<ReactMarkdown
|
|
106
|
+
remarkPlugins={REMARK_PLUGINS}
|
|
107
|
+
rehypePlugins={REHYPE_PLUGINS}
|
|
108
|
+
components={components}
|
|
109
|
+
>
|
|
110
|
+
{children}
|
|
111
|
+
</ReactMarkdown>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { memo, useState, useCallback } from "react";
|
|
2
|
+
import type { Message } from "@ai-sdk/react";
|
|
3
|
+
import { cn } from "../lib/utils.js";
|
|
4
|
+
import { Markdown } from "./Markdown.js";
|
|
5
|
+
import { StreamingIndicator } from "./StreamingIndicator.js";
|
|
6
|
+
import { PartRenderer } from "../parts/PartRenderer.js";
|
|
7
|
+
import type { DisplayRendererMap } from "../display/registry.js";
|
|
8
|
+
import { Copy, Check } from "lucide-react";
|
|
9
|
+
|
|
10
|
+
export interface MessageBubbleProps {
|
|
11
|
+
message: Message;
|
|
12
|
+
isStreaming?: boolean;
|
|
13
|
+
displayRenderers?: DisplayRendererMap;
|
|
14
|
+
attachmentUrl?: (ref: string) => string;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractText(message: Message): string {
|
|
19
|
+
if (message.content) return message.content;
|
|
20
|
+
if (!Array.isArray(message.parts)) return "";
|
|
21
|
+
return message.parts
|
|
22
|
+
.filter((p): p is { type: "text"; text: string } => (p as { type: string }).type === "text")
|
|
23
|
+
.map((p) => p.text)
|
|
24
|
+
.join("\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function CopyButton({ text }: { text: string }) {
|
|
28
|
+
const [copied, setCopied] = useState(false);
|
|
29
|
+
|
|
30
|
+
const handleCopy = useCallback(async () => {
|
|
31
|
+
await navigator.clipboard.writeText(text);
|
|
32
|
+
setCopied(true);
|
|
33
|
+
setTimeout(() => setCopied(false), 2000);
|
|
34
|
+
}, [text]);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onClick={handleCopy}
|
|
40
|
+
className={cn(
|
|
41
|
+
"h-7 w-7 flex items-center justify-center rounded-lg transition-all",
|
|
42
|
+
"text-muted-foreground/50 opacity-0 group-hover/bubble:opacity-100",
|
|
43
|
+
"hover:bg-muted/50 hover:text-muted-foreground",
|
|
44
|
+
)}
|
|
45
|
+
aria-label={copied ? "Copiado" : "Copiar mensagem"}
|
|
46
|
+
>
|
|
47
|
+
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
|
48
|
+
</button>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const MessageBubble = memo(function MessageBubble({ message, isStreaming, displayRenderers, attachmentUrl, className }: MessageBubbleProps) {
|
|
53
|
+
const isUser = message.role === "user";
|
|
54
|
+
const hasParts = Array.isArray(message.parts) && message.parts.length > 0;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className={cn("group/bubble", isUser ? "flex flex-col items-end" : "flex flex-col items-start")}>
|
|
58
|
+
{/* Bubble */}
|
|
59
|
+
<div
|
|
60
|
+
className={cn(
|
|
61
|
+
"min-w-0 overflow-hidden",
|
|
62
|
+
isUser
|
|
63
|
+
? "max-w-[80%] rounded-lg rounded-br-sm bg-muted text-foreground px-4 py-2.5"
|
|
64
|
+
: "w-full text-foreground py-1",
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{hasParts
|
|
69
|
+
? <div className="flex flex-col gap-3">
|
|
70
|
+
{(message.parts as { type: string }[]).map((part, i) => (
|
|
71
|
+
<PartRenderer
|
|
72
|
+
key={i}
|
|
73
|
+
part={part as Parameters<typeof PartRenderer>[0]["part"]}
|
|
74
|
+
isStreaming={isStreaming}
|
|
75
|
+
displayRenderers={displayRenderers}
|
|
76
|
+
attachmentUrl={attachmentUrl}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
: <Markdown>{message.content}</Markdown>
|
|
81
|
+
}
|
|
82
|
+
{isStreaming && !isUser && <StreamingIndicator />}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
{/* Action bar — outside bubble, below */}
|
|
86
|
+
{!isStreaming && (
|
|
87
|
+
<div className={cn(
|
|
88
|
+
"flex items-center gap-0.5 mt-0.5",
|
|
89
|
+
isUser ? "justify-end" : "justify-start"
|
|
90
|
+
)}>
|
|
91
|
+
<CopyButton text={extractText(message)} />
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}, (prev, next) =>
|
|
97
|
+
prev.message === next.message
|
|
98
|
+
&& prev.isStreaming === next.isStreaming
|
|
99
|
+
&& prev.displayRenderers === next.displayRenderers
|
|
100
|
+
&& prev.attachmentUrl === next.attachmentUrl
|
|
101
|
+
&& prev.className === next.className
|
|
102
|
+
);
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { useRef, useEffect, useState, useCallback } from "react";
|
|
2
|
+
import { Send, Square, Plus, Mic, X, Camera, Paperclip, Image as ImageIcon, CircleStop, Loader2 } from "lucide-react";
|
|
3
|
+
import { Button } from "../ui/button.js";
|
|
4
|
+
import { cn } from "../lib/utils.js";
|
|
5
|
+
|
|
6
|
+
// ── Types ──
|
|
7
|
+
|
|
8
|
+
export interface Attachment {
|
|
9
|
+
id: string;
|
|
10
|
+
file: File;
|
|
11
|
+
preview?: string;
|
|
12
|
+
type: "image" | "file" | "audio";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MessageInputProps {
|
|
16
|
+
input: string;
|
|
17
|
+
setInput: (value: string) => void;
|
|
18
|
+
handleSubmit: (e: React.FormEvent, attachments?: Attachment[]) => void;
|
|
19
|
+
isLoading?: boolean;
|
|
20
|
+
isUploading?: boolean;
|
|
21
|
+
stop?: () => void;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
enableAttachments?: boolean;
|
|
25
|
+
enableVoice?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Constants ──
|
|
29
|
+
|
|
30
|
+
const LINE_HEIGHT_PX = 24;
|
|
31
|
+
const MAX_ROWS = 10;
|
|
32
|
+
const MULTILINE_THRESHOLD_PX = LINE_HEIGHT_PX * 1.5; // 36px — above this = multiline
|
|
33
|
+
|
|
34
|
+
// ── Recording Hook ──
|
|
35
|
+
|
|
36
|
+
function useAudioRecording(onComplete: (file: File) => void) {
|
|
37
|
+
const [isRecording, setIsRecording] = useState(false);
|
|
38
|
+
const [elapsed, setElapsed] = useState(0);
|
|
39
|
+
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
40
|
+
const chunksRef = useRef<Blob[]>([]);
|
|
41
|
+
const timerRef = useRef<ReturnType<typeof setInterval>>();
|
|
42
|
+
|
|
43
|
+
const start = useCallback(async () => {
|
|
44
|
+
try {
|
|
45
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
46
|
+
const recorder = new MediaRecorder(stream);
|
|
47
|
+
mediaRecorderRef.current = recorder;
|
|
48
|
+
chunksRef.current = [];
|
|
49
|
+
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
|
|
50
|
+
recorder.onstop = () => {
|
|
51
|
+
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
|
52
|
+
const file = new File([blob], `audio-${Date.now()}.webm`, { type: "audio/webm" });
|
|
53
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
54
|
+
onComplete(file);
|
|
55
|
+
};
|
|
56
|
+
recorder.start();
|
|
57
|
+
setIsRecording(true);
|
|
58
|
+
setElapsed(0);
|
|
59
|
+
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
|
|
60
|
+
} catch { /* permission denied */ }
|
|
61
|
+
}, [onComplete]);
|
|
62
|
+
|
|
63
|
+
const stop = useCallback(() => {
|
|
64
|
+
mediaRecorderRef.current?.stop();
|
|
65
|
+
setIsRecording(false);
|
|
66
|
+
clearInterval(timerRef.current);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const cancel = useCallback(() => {
|
|
70
|
+
if (mediaRecorderRef.current) {
|
|
71
|
+
mediaRecorderRef.current.onstop = null;
|
|
72
|
+
mediaRecorderRef.current.stop();
|
|
73
|
+
mediaRecorderRef.current.stream.getTracks().forEach((t) => t.stop());
|
|
74
|
+
}
|
|
75
|
+
setIsRecording(false);
|
|
76
|
+
setElapsed(0);
|
|
77
|
+
clearInterval(timerRef.current);
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
useEffect(() => () => clearInterval(timerRef.current), []);
|
|
81
|
+
|
|
82
|
+
return { isRecording, elapsed, start, stop, cancel };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatTime(seconds: number): string {
|
|
86
|
+
const m = Math.floor(seconds / 60);
|
|
87
|
+
const s = seconds % 60;
|
|
88
|
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Attachment Preview ──
|
|
92
|
+
|
|
93
|
+
function AttachmentPreview({ attachment, onRemove }: { attachment: Attachment; onRemove: () => void }) {
|
|
94
|
+
return (
|
|
95
|
+
<div className="relative group shrink-0">
|
|
96
|
+
{attachment.type === "image" && attachment.preview ? (
|
|
97
|
+
<div className="relative h-16 w-16 rounded-lg overflow-hidden border border-border/50">
|
|
98
|
+
<img src={attachment.preview} alt={attachment.file.name} className="h-full w-full object-cover" />
|
|
99
|
+
</div>
|
|
100
|
+
) : (
|
|
101
|
+
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-background/50 px-3 py-2">
|
|
102
|
+
<Paperclip className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
103
|
+
<span className="text-xs text-muted-foreground truncate max-w-[120px]">{attachment.file.name}</span>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={onRemove}
|
|
109
|
+
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-foreground text-background flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
110
|
+
aria-label="Remover"
|
|
111
|
+
>
|
|
112
|
+
<X className="h-3 w-3" />
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Plus Menu ──
|
|
119
|
+
|
|
120
|
+
function PlusMenu({ onFile, onCamera, onGallery, onClose }: {
|
|
121
|
+
onFile: () => void; onCamera: () => void; onGallery: () => void; onClose: () => void;
|
|
122
|
+
}) {
|
|
123
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
function handleClick(e: MouseEvent) {
|
|
127
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
|
128
|
+
}
|
|
129
|
+
document.addEventListener("mousedown", handleClick);
|
|
130
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
131
|
+
}, [onClose]);
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div ref={menuRef} className="absolute bottom-full left-0 mb-2 rounded-xl border border-border bg-popover text-popover-foreground shadow-lg py-1 min-w-[160px] z-10">
|
|
135
|
+
{[
|
|
136
|
+
{ icon: Paperclip, label: "Arquivo", onClick: onFile },
|
|
137
|
+
{ icon: Camera, label: "Camera", onClick: onCamera },
|
|
138
|
+
{ icon: ImageIcon, label: "Galeria", onClick: onGallery },
|
|
139
|
+
].map((item) => (
|
|
140
|
+
<button
|
|
141
|
+
key={item.label}
|
|
142
|
+
type="button"
|
|
143
|
+
onClick={() => { item.onClick(); onClose(); }}
|
|
144
|
+
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
|
145
|
+
>
|
|
146
|
+
<item.icon className="h-4 w-4 text-muted-foreground" />
|
|
147
|
+
{item.label}
|
|
148
|
+
</button>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Main Component ──
|
|
155
|
+
|
|
156
|
+
export function MessageInput({
|
|
157
|
+
input,
|
|
158
|
+
setInput,
|
|
159
|
+
handleSubmit,
|
|
160
|
+
isLoading,
|
|
161
|
+
isUploading = false,
|
|
162
|
+
stop,
|
|
163
|
+
placeholder = "Caixa de mensagem...",
|
|
164
|
+
className,
|
|
165
|
+
enableAttachments = true,
|
|
166
|
+
enableVoice = true,
|
|
167
|
+
}: MessageInputProps) {
|
|
168
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
169
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
170
|
+
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
171
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
172
|
+
|
|
173
|
+
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
174
|
+
const [showMenu, setShowMenu] = useState(false);
|
|
175
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
176
|
+
const [isMultiline, setIsMultiline] = useState(false);
|
|
177
|
+
const historyRef = useRef<string[]>([]);
|
|
178
|
+
const historyPosRef = useRef(-1);
|
|
179
|
+
const savedInputRef = useRef("");
|
|
180
|
+
|
|
181
|
+
const onRecordingComplete = useCallback((file: File) => {
|
|
182
|
+
const att: Attachment = { id: crypto.randomUUID(), file, type: "audio" };
|
|
183
|
+
setAttachments((prev) => [...prev, att]);
|
|
184
|
+
}, []);
|
|
185
|
+
|
|
186
|
+
const { isRecording, elapsed, start: startRecording, stop: stopRecording, cancel: cancelRecording } = useAudioRecording(onRecordingComplete);
|
|
187
|
+
|
|
188
|
+
const hasContent = input.trim().length > 0 || attachments.length > 0;
|
|
189
|
+
|
|
190
|
+
// Auto-focus
|
|
191
|
+
useEffect(() => { textareaRef.current?.focus(); }, []);
|
|
192
|
+
|
|
193
|
+
// Auto-expand — stable measurement with height:auto
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
const el = textareaRef.current;
|
|
196
|
+
if (!el) return;
|
|
197
|
+
el.style.height = "auto";
|
|
198
|
+
const scrollH = el.scrollHeight;
|
|
199
|
+
const maxH = MAX_ROWS * LINE_HEIGHT_PX;
|
|
200
|
+
const clampedH = Math.min(scrollH, maxH);
|
|
201
|
+
el.style.height = `${clampedH}px`;
|
|
202
|
+
setIsMultiline(scrollH > MULTILINE_THRESHOLD_PX);
|
|
203
|
+
}, [input]);
|
|
204
|
+
|
|
205
|
+
// ── Attachments ──
|
|
206
|
+
|
|
207
|
+
const addFiles = useCallback((files: FileList | File[]) => {
|
|
208
|
+
const newAttachments: Attachment[] = Array.from(files).map((file) => {
|
|
209
|
+
const isImage = file.type.startsWith("image/");
|
|
210
|
+
const att: Attachment = { id: crypto.randomUUID(), file, type: isImage ? "image" : "file" };
|
|
211
|
+
if (isImage) {
|
|
212
|
+
const reader = new FileReader();
|
|
213
|
+
reader.onload = (e) => {
|
|
214
|
+
setAttachments((prev) => prev.map((a) => (a.id === att.id ? { ...a, preview: e.target?.result as string } : a)));
|
|
215
|
+
};
|
|
216
|
+
reader.readAsDataURL(file);
|
|
217
|
+
}
|
|
218
|
+
return att;
|
|
219
|
+
});
|
|
220
|
+
setAttachments((prev) => [...prev, ...newAttachments]);
|
|
221
|
+
}, []);
|
|
222
|
+
|
|
223
|
+
const removeAttachment = useCallback((id: string) => {
|
|
224
|
+
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
|
225
|
+
}, []);
|
|
226
|
+
|
|
227
|
+
// ── Drag & Drop ──
|
|
228
|
+
|
|
229
|
+
const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }, []);
|
|
230
|
+
|
|
231
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
232
|
+
if (containerRef.current && !containerRef.current.contains(e.relatedTarget as Node)) setIsDragging(false);
|
|
233
|
+
}, []);
|
|
234
|
+
|
|
235
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
236
|
+
e.preventDefault(); setIsDragging(false);
|
|
237
|
+
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
|
|
238
|
+
}, [addFiles]);
|
|
239
|
+
|
|
240
|
+
// ── Clipboard Paste ──
|
|
241
|
+
|
|
242
|
+
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
243
|
+
const files: File[] = [];
|
|
244
|
+
for (let i = 0; i < e.clipboardData.items.length; i++) {
|
|
245
|
+
if (e.clipboardData.items[i].kind === "file") {
|
|
246
|
+
const file = e.clipboardData.items[i].getAsFile();
|
|
247
|
+
if (file) files.push(file);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (files.length) { e.preventDefault(); addFiles(files); }
|
|
251
|
+
}, [addFiles]);
|
|
252
|
+
|
|
253
|
+
// ── Submit ──
|
|
254
|
+
|
|
255
|
+
function onSubmit(e: React.FormEvent) {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
if (!hasContent || isLoading) return;
|
|
258
|
+
if (input.trim()) {
|
|
259
|
+
historyRef.current.unshift(input);
|
|
260
|
+
if (historyRef.current.length > 50) historyRef.current.length = 50;
|
|
261
|
+
}
|
|
262
|
+
historyPosRef.current = -1;
|
|
263
|
+
savedInputRef.current = "";
|
|
264
|
+
handleSubmit(e, attachments.length > 0 ? attachments : undefined);
|
|
265
|
+
setAttachments([]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
269
|
+
// History navigation — only when cursor is at start/end and not multiline content
|
|
270
|
+
const el = textareaRef.current;
|
|
271
|
+
if (e.key === "ArrowUp" && historyRef.current.length > 0 && el) {
|
|
272
|
+
const atTop = el.selectionStart === 0 && el.selectionEnd === 0;
|
|
273
|
+
const isEmpty = input === "";
|
|
274
|
+
if (atTop || isEmpty) {
|
|
275
|
+
e.preventDefault();
|
|
276
|
+
if (historyPosRef.current === -1) savedInputRef.current = input;
|
|
277
|
+
const nextPos = Math.min(historyPosRef.current + 1, historyRef.current.length - 1);
|
|
278
|
+
if (nextPos !== historyPosRef.current) {
|
|
279
|
+
historyPosRef.current = nextPos;
|
|
280
|
+
setInput(historyRef.current[nextPos]);
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (e.key === "ArrowDown" && historyPosRef.current >= 0 && el) {
|
|
287
|
+
const atBottom = el.selectionStart === input.length;
|
|
288
|
+
if (atBottom) {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
const nextPos = historyPosRef.current - 1;
|
|
291
|
+
historyPosRef.current = nextPos;
|
|
292
|
+
setInput(nextPos < 0 ? savedInputRef.current : historyRef.current[nextPos]);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
298
|
+
e.preventDefault();
|
|
299
|
+
if (hasContent && !isLoading) onSubmit(e as unknown as React.FormEvent);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Render ──
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<div
|
|
307
|
+
ref={containerRef}
|
|
308
|
+
onDragOver={enableAttachments ? handleDragOver : undefined}
|
|
309
|
+
onDragLeave={enableAttachments ? handleDragLeave : undefined}
|
|
310
|
+
onDrop={enableAttachments ? handleDrop : undefined}
|
|
311
|
+
className={cn(
|
|
312
|
+
"relative border border-border/50 bg-muted transition-[border-radius] duration-200",
|
|
313
|
+
isMultiline || attachments.length > 0 ? "rounded-2xl" : "rounded-full",
|
|
314
|
+
isDragging && "ring-2 ring-primary/50",
|
|
315
|
+
className,
|
|
316
|
+
)}
|
|
317
|
+
>
|
|
318
|
+
{/* ── Drag overlay ── */}
|
|
319
|
+
{isDragging && (
|
|
320
|
+
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-[inherit] bg-primary/5 border-2 border-dashed border-primary/30">
|
|
321
|
+
<span className="text-sm text-primary font-medium">Solte aqui</span>
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
{/* ── Recording overlay ── */}
|
|
326
|
+
{isRecording && (
|
|
327
|
+
<div className="flex items-center gap-3 px-3 py-2">
|
|
328
|
+
<Button type="button" variant="ghost" size="icon" onClick={cancelRecording} className="h-8 w-8 rounded-full shrink-0 text-muted-foreground" aria-label="Cancelar">
|
|
329
|
+
<X className="h-4 w-4" />
|
|
330
|
+
</Button>
|
|
331
|
+
<div className="flex items-center gap-2 flex-1">
|
|
332
|
+
<span className="h-2 w-2 rounded-full bg-red-500 animate-pulse" />
|
|
333
|
+
<span className="text-sm font-medium tabular-nums">{formatTime(elapsed)}</span>
|
|
334
|
+
<div className="flex-1 flex items-center gap-0.5 px-2">
|
|
335
|
+
{Array.from({ length: 20 }, (_, i) => (
|
|
336
|
+
<span key={i} className="w-1 bg-foreground/30 rounded-full" style={{ height: `${4 + Math.random() * 12}px` }} />
|
|
337
|
+
))}
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
<Button type="button" size="icon" onClick={stopRecording} className="h-8 w-8 rounded-full shrink-0" aria-label="Parar">
|
|
341
|
+
<CircleStop className="h-4 w-4" />
|
|
342
|
+
</Button>
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
|
|
346
|
+
{/* ── Main content (hidden when recording) ── */}
|
|
347
|
+
<div className={cn(isRecording && "hidden")}>
|
|
348
|
+
{/* Attachment previews */}
|
|
349
|
+
{(attachments.length > 0 || isUploading) && (
|
|
350
|
+
<div className="flex flex-wrap gap-2 px-4 pt-3 pb-1">
|
|
351
|
+
{attachments.map((att) => (
|
|
352
|
+
<AttachmentPreview key={att.id} attachment={att} onRemove={() => removeAttachment(att.id)} />
|
|
353
|
+
))}
|
|
354
|
+
{isUploading && (
|
|
355
|
+
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-background/50 px-3 py-2 text-xs text-muted-foreground">
|
|
356
|
+
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
|
357
|
+
<span>Enviando arquivos...</span>
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
)}
|
|
362
|
+
|
|
363
|
+
{/* ── Single unified input row ── */}
|
|
364
|
+
<div className={cn("flex gap-1 p-1.5", isMultiline ? "items-end" : "items-center")}>
|
|
365
|
+
{/* Left: Plus button */}
|
|
366
|
+
{enableAttachments && (
|
|
367
|
+
<div className="relative shrink-0">
|
|
368
|
+
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => setShowMenu(!showMenu)} aria-label="Adicionar">
|
|
369
|
+
<Plus className="h-4 w-4" />
|
|
370
|
+
</Button>
|
|
371
|
+
{showMenu && (
|
|
372
|
+
<PlusMenu
|
|
373
|
+
onFile={() => fileInputRef.current?.click()}
|
|
374
|
+
onCamera={() => imageInputRef.current?.click()}
|
|
375
|
+
onGallery={() => imageInputRef.current?.click()}
|
|
376
|
+
onClose={() => setShowMenu(false)}
|
|
377
|
+
/>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
)}
|
|
381
|
+
|
|
382
|
+
{/* Center: Textarea */}
|
|
383
|
+
<textarea
|
|
384
|
+
ref={textareaRef}
|
|
385
|
+
value={input}
|
|
386
|
+
onChange={(e) => setInput(e.target.value)}
|
|
387
|
+
onKeyDown={handleKeyDown}
|
|
388
|
+
onPaste={enableAttachments ? handlePaste : undefined}
|
|
389
|
+
placeholder={placeholder}
|
|
390
|
+
rows={1}
|
|
391
|
+
disabled={isLoading}
|
|
392
|
+
aria-label="Mensagem"
|
|
393
|
+
className="flex-1 min-w-0 bg-transparent text-foreground text-sm resize-none outline-none placeholder:text-muted-foreground leading-6 py-1 px-2"
|
|
394
|
+
/>
|
|
395
|
+
|
|
396
|
+
{/* Right: Action buttons */}
|
|
397
|
+
<div className="flex items-center gap-0.5 shrink-0">
|
|
398
|
+
{enableVoice && !hasContent && (
|
|
399
|
+
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-full text-muted-foreground" onClick={startRecording} aria-label="Gravar audio">
|
|
400
|
+
<Mic className="h-4 w-4" />
|
|
401
|
+
</Button>
|
|
402
|
+
)}
|
|
403
|
+
{isLoading && stop ? (
|
|
404
|
+
<Button type="button" variant="ghost" size="icon" onClick={stop} className="h-8 w-8 rounded-full" aria-label="Parar geração">
|
|
405
|
+
<Square className="h-4 w-4" />
|
|
406
|
+
</Button>
|
|
407
|
+
) : (
|
|
408
|
+
<Button type="button" size="icon" onClick={onSubmit} disabled={!hasContent || !!isLoading} className="h-8 w-8 rounded-full" aria-label="Enviar mensagem">
|
|
409
|
+
<Send className="h-4 w-4" />
|
|
410
|
+
</Button>
|
|
411
|
+
)}
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
|
|
416
|
+
{/* Hidden file inputs */}
|
|
417
|
+
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ""; }} />
|
|
418
|
+
<input ref={imageInputRef} type="file" accept="image/*" multiple className="hidden" onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ""; }} />
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|