UNPKG

analytica-frontend-lib

Version:

Repositório público dos componentes utilizados nas plataformas da Analytica Ensino

747 lines (734 loc) 24.5 kB
import { createUseChatbot } from "./chunk-L57VCDNM.mjs"; import { CHATBOT_MESSAGE_ROLES } from "./chunk-QIXFV54A.mjs"; import { HtmlMathRenderer_default, containsMath } from "./chunk-FN4G43WK.mjs"; import { TextArea_default } from "./chunk-LI2FS7M2.mjs"; import { EmptyState_default } from "./chunk-6WVHF7N3.mjs"; import { SkeletonText } from "./chunk-NKVUUWCA.mjs"; import { IconButton_default } from "./chunk-LGQNMQOT.mjs"; import { Button_default } from "./chunk-LAYB7IKW.mjs"; import { Text_default } from "./chunk-IMCIR6TJ.mjs"; import { cn } from "./chunk-53ICLDGS.mjs"; // src/components/Chatbot/Chatbot.tsx import { useRef as useRef3 } from "react"; // src/components/Chatbot/ChatbotFab.tsx import { ChatCircleDotsIcon } from "@phosphor-icons/react"; import { jsx, jsxs } from "react/jsx-runtime"; function ChatbotFab({ onClick, isOpen = false, unreadCount = 0, className }) { const noun = unreadCount === 1 ? "mensagem n\xE3o lida" : "mensagens n\xE3o lidas"; let badgeLabel; if (unreadCount > 9) { badgeLabel = "Mais de 9 mensagens n\xE3o lidas"; } else { badgeLabel = `${unreadCount} ${noun}`; } const baseAriaLabel = isOpen ? "Fechar assistente" : "Abrir assistente de estudos"; const ariaLabel = unreadCount > 0 ? `${baseAriaLabel} (${badgeLabel})` : baseAriaLabel; return /* @__PURE__ */ jsxs( Button_default, { variant: "raw", onClick, "aria-label": ariaLabel, "aria-expanded": isOpen, className: cn( "fixed bottom-6 right-6 z-40", "flex h-14 w-14 items-center justify-center rounded-full", "bg-primary-500 text-white shadow-lg", "transition-all hover:bg-primary-600 active:scale-95", "focus:outline-none focus:ring-2 focus:ring-primary-300", className ), children: [ /* @__PURE__ */ jsx(ChatCircleDotsIcon, { size: 26, weight: "fill" }), unreadCount > 0 && /* @__PURE__ */ jsx( Text_default, { as: "span", size: "xs", weight: "bold", color: "text-white", "data-testid": "chatbot-fab-badge", "aria-live": "polite", "aria-label": badgeLabel, className: "absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-error-500 px-1", children: unreadCount > 9 ? "9+" : unreadCount } ) ] } ); } // src/components/Chatbot/ChatbotPanel.tsx import { useEffect, useRef, useState } from "react"; import { CaretUpIcon, ListIcon, MinusIcon, PlusIcon, RobotIcon, XIcon } from "@phosphor-icons/react"; import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime"; var HEADER_ICON_BUTTON_CLASSES = "!text-white hover:!bg-white/15 hover:!text-white focus-visible:!ring-white/60"; function ChatbotPanel({ isOpen, onClose, onStartNew, historySlot, messagesSlot, inputSlot, errorMessage, className }) { const [showHistory, setShowHistory] = useState(false); const [isMinimized, setIsMinimized] = useState(false); const closeButtonRef = useRef(null); const previouslyFocusedRef = useRef(null); const previousErrorRef = useRef(errorMessage); useEffect(() => { if (isOpen) { setIsMinimized(false); } }, [isOpen]); useEffect(() => { const previous = previousErrorRef.current; if (errorMessage && errorMessage !== previous) { setIsMinimized(false); } previousErrorRef.current = errorMessage; }, [errorMessage]); useEffect(() => { if (!isOpen) return; previouslyFocusedRef.current = document.activeElement; closeButtonRef.current?.focus(); const handleKeyDown = (event) => { if (event.key === "Escape") { event.stopPropagation(); onClose(); } }; globalThis.addEventListener("keydown", handleKeyDown); return () => { globalThis.removeEventListener("keydown", handleKeyDown); const previous = previouslyFocusedRef.current; if (previous && typeof previous.focus === "function") { previous.focus(); } }; }, [isOpen, onClose]); if (!isOpen) return null; return /* @__PURE__ */ jsxs2( "dialog", { open: true, "aria-label": "Assistente de estudos", style: isMinimized ? void 0 : { height: "min(720px, calc(100vh - 3rem))" }, className: cn( // `<dialog open>` comes with `margin: auto` by default; `m-0` // resets that so the `fixed` positioning below pins the panel // to the bottom-right as intended. The panel sits at the same // bottom-right corner as the FAB (the FAB is hidden while the // panel is open to avoid stacking). "fixed bottom-6 right-6 z-40 m-0", // Responsive sizing: fills small viewports without overflowing, // yet caps at the intended desktop footprint on larger screens. // The panel widens when the history sidebar is visible so the // main chat column keeps enough horizontal room for messages. "flex w-[calc(100vw-3rem)] flex-col overflow-hidden rounded-2xl p-0", showHistory ? "max-w-[620px] sm:max-w-[640px]" : "max-w-[400px] sm:max-w-[420px]", "border border-background-200 bg-background shadow-2xl", className ), children: [ /* @__PURE__ */ jsxs2("div", { className: "flex items-center justify-between gap-2 bg-primary-500 px-4 py-3 text-white", children: [ /* @__PURE__ */ jsxs2("div", { className: "flex min-w-0 items-center gap-3", children: [ /* @__PURE__ */ jsx2( "div", { "aria-hidden": "true", className: "flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-white/15 text-white", children: /* @__PURE__ */ jsx2(RobotIcon, { size: 20, weight: "fill" }) } ), /* @__PURE__ */ jsxs2("div", { className: "flex min-w-0 flex-col", children: [ /* @__PURE__ */ jsx2( Text_default, { size: "md", weight: "semibold", className: "truncate leading-tight text-white", children: "Assistente de estudos" } ), /* @__PURE__ */ jsx2(Text_default, { size: "2xs", className: "truncate leading-tight text-white/80", children: "Tire d\xFAvidas enquanto estuda" }) ] }) ] }), /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-1", children: [ !isMinimized && /* @__PURE__ */ jsxs2(Fragment, { children: [ /* @__PURE__ */ jsx2( IconButton_default, { size: "sm", "aria-label": showHistory ? "Ocultar hist\xF3rico" : "Mostrar hist\xF3rico", onClick: () => setShowHistory((v) => !v), icon: /* @__PURE__ */ jsx2(ListIcon, { size: 18 }), className: HEADER_ICON_BUTTON_CLASSES } ), /* @__PURE__ */ jsx2( IconButton_default, { size: "sm", "aria-label": "Nova conversa", onClick: onStartNew, icon: /* @__PURE__ */ jsx2(PlusIcon, { size: 18 }), className: HEADER_ICON_BUTTON_CLASSES } ) ] }), /* @__PURE__ */ jsx2( IconButton_default, { size: "sm", "aria-label": isMinimized ? "Expandir assistente" : "Minimizar assistente", "aria-expanded": !isMinimized, onClick: () => setIsMinimized((v) => !v), icon: isMinimized ? /* @__PURE__ */ jsx2(CaretUpIcon, { size: 18 }) : /* @__PURE__ */ jsx2(MinusIcon, { size: 18 }), className: HEADER_ICON_BUTTON_CLASSES } ), /* @__PURE__ */ jsx2( IconButton_default, { ref: closeButtonRef, size: "sm", "aria-label": "Fechar assistente", onClick: onClose, icon: /* @__PURE__ */ jsx2(XIcon, { size: 18 }), className: HEADER_ICON_BUTTON_CLASSES } ) ] }) ] }), !isMinimized && /* @__PURE__ */ jsxs2("div", { className: "flex flex-1 overflow-hidden border-t border-background-200", children: [ showHistory && /* @__PURE__ */ jsx2("div", { className: "w-44 shrink-0 border-r border-background-200", children: historySlot }), /* @__PURE__ */ jsxs2("div", { className: "flex flex-1 flex-col", children: [ messagesSlot, errorMessage && /* @__PURE__ */ jsx2( "div", { role: "alert", "aria-live": "assertive", className: "border-t border-error-200 bg-error-50 px-3 py-2", children: /* @__PURE__ */ jsx2(Text_default, { size: "xs", className: "text-error-700", children: errorMessage }) } ), inputSlot ] }) ] }) ] } ); } // src/components/Chatbot/ChatbotMessageList.tsx import { useEffect as useEffect2, useRef as useRef2 } from "react"; // src/components/Chatbot/ChatbotMessage.tsx import { RobotIcon as RobotIcon2, UserIcon } from "@phosphor-icons/react"; // src/components/Chatbot/ChatbotContentRenderer.tsx import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { jsx as jsx3 } from "react/jsx-runtime"; var MATH_CHAR_PATTERN = /[\\^_{}]/; function collectDollarIndices(content) { const result = []; for (let i = 0; i < content.length; i++) { if (content[i] === "$" && content[i - 1] !== "\\") { result.push(i); } } return result; } function hasInlineMathSpan(content, indices) { for (let i = 0; i < indices.length; i++) { for (let j = i + 1; j < indices.length; j++) { const inner = content.slice(indices[i] + 1, indices[j]); if (inner.includes("\n")) break; if (MATH_CHAR_PATTERN.test(inner)) return true; } } return false; } function looksLikeMath(content) { if (!containsMath(content)) return false; if (/\$\$[\s\S]+?\$\$/.test(content)) return true; if (/\\begin\{[^}]+\}[\s\S]+?\\end\{[^}]+\}/.test(content)) return true; return hasInlineMathSpan(content, collectDollarIndices(content)); } function ChatbotContentRenderer({ content, className }) { if (looksLikeMath(content)) { return /* @__PURE__ */ jsx3( HtmlMathRenderer_default, { content, className: cn("whitespace-pre-wrap break-words", className) } ); } return /* @__PURE__ */ jsx3( "div", { className: cn( "prose prose-sm max-w-none break-words", // tighten prose defaults so the bubble feels compact "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className ), children: /* @__PURE__ */ jsx3(ReactMarkdown, { remarkPlugins: [remarkGfm], children: content }) } ); } // src/components/Chatbot/ChatbotMessage.tsx import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime"; function formatTimestamp(value) { const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return ""; return date.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" }); } function ChatbotMessage({ message, className, contentClassName }) { const isUser = message.role === CHATBOT_MESSAGE_ROLES.USER; return /* @__PURE__ */ jsxs3( "div", { className: cn( "flex w-full gap-2", isUser ? "justify-end" : "justify-start", className ), children: [ !isUser && /* @__PURE__ */ jsx4( "div", { role: "img", "aria-label": "Mensagem do assistente", className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-600", children: /* @__PURE__ */ jsx4(RobotIcon2, { size: 18, weight: "fill", "aria-hidden": "true" }) } ), /* @__PURE__ */ jsxs3( "div", { className: cn( "max-w-[80%] px-4 py-2", isUser ? "rounded-2xl rounded-br-md bg-background-200 text-text-900" : "rounded-2xl rounded-bl-md bg-background-100 text-text-900" ), children: [ isUser ? /* @__PURE__ */ jsx4(Text_default, { size: "sm", className: "whitespace-pre-wrap break-words", children: message.content }) : /* @__PURE__ */ jsx4( ChatbotContentRenderer, { content: message.content, className: contentClassName } ), /* @__PURE__ */ jsx4(Text_default, { size: "2xs", className: "mt-1 block text-right text-text-700", children: formatTimestamp(message.createdAt) }) ] } ), isUser && /* @__PURE__ */ jsx4( "div", { role: "img", "aria-label": "Sua mensagem", className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-primary-500 text-white", children: /* @__PURE__ */ jsx4(UserIcon, { size: 18, weight: "fill", "aria-hidden": "true" }) } ) ] } ); } // src/components/Chatbot/ChatbotTypingIndicator.tsx import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime"; function ChatbotTypingIndicator({ className }) { return /* @__PURE__ */ jsxs4( "output", { "aria-label": "Assistente digitando", className: cn( "inline-flex items-center gap-1 rounded-2xl rounded-bl-md bg-background-100 px-4 py-3", className ), children: [ /* @__PURE__ */ jsx5( Text_default, { as: "span", className: "h-2 w-2 animate-bounce rounded-full bg-text-400 [animation-delay:-0.3s]" } ), /* @__PURE__ */ jsx5( Text_default, { as: "span", className: "h-2 w-2 animate-bounce rounded-full bg-text-400 [animation-delay:-0.15s]" } ), /* @__PURE__ */ jsx5( Text_default, { as: "span", className: "h-2 w-2 animate-bounce rounded-full bg-text-400" } ) ] } ); } // src/components/Chatbot/ChatbotMessageList.tsx import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime"; function ChatbotMessageList({ messages, isSending = false, isLoading = false, emptyHint = "Comece a conversa enviando uma pergunta.", className }) { const bottomRef = useRef2(null); const didMountRef = useRef2(false); useEffect2(() => { if (!didMountRef.current) { didMountRef.current = true; return; } if (bottomRef.current) { bottomRef.current.scrollIntoView({ behavior: "smooth", block: "end" }); } }, [messages.length, isSending]); const showEmpty = !isLoading && messages.length === 0 && !isSending; return /* @__PURE__ */ jsxs5( "div", { role: "log", "aria-live": "polite", "aria-relevant": "additions", className: cn( "flex-1 overflow-y-auto p-4 space-y-3", "bg-background-50", className ), children: [ showEmpty && /* @__PURE__ */ jsx6("div", { className: "flex h-full items-center justify-center", children: /* @__PURE__ */ jsx6(Text_default, { size: "sm", className: "text-center text-text-400", children: emptyHint }) }), messages.map((msg) => /* @__PURE__ */ jsx6(ChatbotMessage, { message: msg }, msg.id)), isSending && /* @__PURE__ */ jsx6("div", { className: "flex justify-start", children: /* @__PURE__ */ jsx6(ChatbotTypingIndicator, {}) }), /* @__PURE__ */ jsx6("div", { ref: bottomRef }) ] } ); } // src/components/Chatbot/ChatbotInput.tsx import { useCallback, useState as useState2 } from "react"; import { PaperPlaneTiltIcon } from "@phosphor-icons/react"; import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime"; var MIN_HEIGHT = 40; var MAX_HEIGHT = 120; function ChatbotInput({ onSend, disabled = false, placeholder = "Pergunte ao assistente...", className }) { const [value, setValue] = useState2(""); const submit = useCallback(() => { const trimmed = value.trim(); if (!trimmed || disabled) return; onSend(trimmed); setValue(""); }, [disabled, onSend, value]); const handleKeyDown = useCallback( (e) => { if (e.nativeEvent.isComposing) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } }, [submit] ); const isSendDisabled = disabled || value.trim().length === 0; return /* @__PURE__ */ jsxs6( "div", { className: cn( "flex items-center gap-2 border-t border-background-200 bg-background p-3", className ), children: [ /* @__PURE__ */ jsx7("div", { className: "flex-1 min-w-0", children: /* @__PURE__ */ jsx7( TextArea_default, { value, onChange: (e) => setValue(e.target.value), onKeyDown: handleKeyDown, placeholder, disabled, autoResize: true, minHeight: MIN_HEIGHT, "aria-label": "Mensagem para o assistente", className: "w-full", style: { maxHeight: MAX_HEIGHT, overflowY: "auto" } } ) }), /* @__PURE__ */ jsx7( Button_default, { variant: "raw", type: "button", onClick: submit, disabled: isSendDisabled, "aria-label": "Enviar mensagem", className: cn( "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full", "bg-primary-500 text-white transition-colors", "hover:bg-primary-600", "disabled:bg-background-200 disabled:text-text-500 disabled:cursor-not-allowed", "focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-300" ), children: /* @__PURE__ */ jsx7(PaperPlaneTiltIcon, { size: 20, weight: "fill" }) } ) ] } ); } // src/components/Chatbot/ChatbotConversationList.tsx import { TrashIcon, PlusIcon as PlusIcon2 } from "@phosphor-icons/react"; import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime"; function formatDate(value) { const d = value instanceof Date ? value : new Date(value); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit" }); } function ChatbotConversationList({ conversations, activeConversationId, isLoading = false, onSelect, onDelete, onStartNew, className }) { return /* @__PURE__ */ jsxs7( "div", { className: cn( "flex h-full flex-col border-r border-background-200 bg-background", className ), children: [ /* @__PURE__ */ jsx8("div", { className: "p-3", children: /* @__PURE__ */ jsx8( Button_default, { variant: "solid", action: "primary", size: "small", onClick: onStartNew, iconLeft: /* @__PURE__ */ jsx8(PlusIcon2, { size: 14 }), className: "w-full", children: "Nova conversa" } ) }), /* @__PURE__ */ jsx8("div", { className: "flex-1 overflow-y-auto px-2 pb-2", children: renderBody() }) ] } ); function renderBody() { if (isLoading) { return /* @__PURE__ */ jsxs7("div", { className: "space-y-2 px-2", children: [ /* @__PURE__ */ jsx8(SkeletonText, {}), /* @__PURE__ */ jsx8(SkeletonText, {}), /* @__PURE__ */ jsx8(SkeletonText, {}) ] }); } if (conversations.length === 0) { return /* @__PURE__ */ jsx8("div", { className: "mt-4", children: /* @__PURE__ */ jsx8( EmptyState_default, { title: "Sem conversas ainda", description: "Comece sua primeira conversa com o assistente." } ) }); } return /* @__PURE__ */ jsx8("ul", { className: "space-y-1", children: conversations.map((c) => { const isActive = c.id === activeConversationId; return /* @__PURE__ */ jsx8("li", { children: /* @__PURE__ */ jsxs7( "div", { className: cn( "group flex items-start gap-2 rounded-md px-2 py-2 transition-colors", isActive ? "bg-primary-50" : "hover:bg-background-100" ), children: [ /* @__PURE__ */ jsxs7( Button_default, { variant: "raw", onClick: () => onSelect(c.id), className: "flex-1 text-left", children: [ /* @__PURE__ */ jsx8( Text_default, { size: "sm", weight: "semibold", className: "truncate text-text-900", children: c.title || "Conversa sem t\xEDtulo" } ), /* @__PURE__ */ jsx8(Text_default, { size: "2xs", className: "text-text-400", children: formatDate(c.lastMessageAt) }) ] } ), /* @__PURE__ */ jsx8( IconButton_default, { size: "sm", "aria-label": `Excluir conversa ${c.title || ""}`.trim(), onClick: () => onDelete(c.id), icon: /* @__PURE__ */ jsx8(TrashIcon, { size: 14 }) } ) ] } ) }, c.id); }) }); } } // src/components/Chatbot/Chatbot.tsx import { Fragment as Fragment2, jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime"; function Chatbot({ apiClient, user, currentContext, fabClassName, panelClassName }) { const apiClientRef = useRef3(apiClient); apiClientRef.current = apiClient; const boundHookRef = useRef3(null); boundHookRef.current ??= createUseChatbot(() => apiClientRef.current); const useBoundChatbot = boundHookRef.current; const { isOpen, isSending, isLoadingHistory, isLoadingMessages, errorMessage, conversations, activeConversationId, messages, togglePanel, closePanel, selectConversation, startNewConversation, sendMessage, deleteConversation } = useBoundChatbot(); const firstName = user.name?.trim().split(/\s+/)[0]; const emptyHint = firstName ? `Ol\xE1, ${firstName}! Como posso ajudar nos seus estudos?` : "Comece a conversa enviando uma pergunta."; const handleSend = (text) => { sendMessage(text, currentContext).catch(() => void 0); }; return /* @__PURE__ */ jsxs8(Fragment2, { children: [ !isOpen && /* @__PURE__ */ jsx9( ChatbotFab, { onClick: togglePanel, isOpen, className: fabClassName } ), /* @__PURE__ */ jsx9( ChatbotPanel, { isOpen, onClose: closePanel, onStartNew: startNewConversation, className: panelClassName, errorMessage, historySlot: /* @__PURE__ */ jsx9( ChatbotConversationList, { conversations, activeConversationId, isLoading: isLoadingHistory, onSelect: selectConversation, onDelete: deleteConversation, onStartNew: startNewConversation } ), messagesSlot: /* @__PURE__ */ jsx9( ChatbotMessageList, { messages, isSending, isLoading: isLoadingMessages, emptyHint } ), inputSlot: /* @__PURE__ */ jsx9(ChatbotInput, { onSend: handleSend, disabled: isSending }) } ) ] }); } export { ChatbotFab, ChatbotPanel, ChatbotContentRenderer, ChatbotMessage, ChatbotTypingIndicator, ChatbotMessageList, ChatbotInput, ChatbotConversationList, Chatbot }; //# sourceMappingURL=chunk-SHCKI46H.mjs.map