update license
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2023 TailAdmin
|
Copyright (c) 2026 Pregnant guide
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
This project includes portions of TailAdmin.
|
||||||
|
|
||||||
|
TailAdmin
|
||||||
|
Copyright (c) 2023 TailAdmin
|
||||||
|
Licensed under the MIT License.
|
||||||
|
Website: https://tailadmin.com
|
||||||
|
|
||||||
|
## TailAdmin MIT License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 TailAdmin
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+2
-5
@@ -1,8 +1,6 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { SidebarProvider } from '@/context/SidebarContext';
|
|
||||||
import { ThemeProvider } from '@/context/ThemeContext';
|
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import StoreProvider from '@/store/StoreProvider';
|
import StoreProvider from '@/store/StoreProvider';
|
||||||
|
|
||||||
@@ -27,9 +25,8 @@ export default function RootLayout({
|
|||||||
<html lang="en" className={inter.variable}>
|
<html lang="en" className={inter.variable}>
|
||||||
<body className={`${inter.className} dark:bg-gray-900`}>
|
<body className={`${inter.className} dark:bg-gray-900`}>
|
||||||
<StoreProvider>
|
<StoreProvider>
|
||||||
<ThemeProvider>
|
{children}
|
||||||
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
|
<Toaster closeButton richColors position="top-right" />
|
||||||
</ThemeProvider>
|
|
||||||
</StoreProvider>
|
</StoreProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+17
-3
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSidebar } from "@/context/SidebarContext";
|
import { SidebarProvider, useSidebar } from "@/context/SidebarContext";
|
||||||
import AppHeader from "@/layout/AppHeader";
|
import { ThemeProvider } from "@/context/ThemeContext";
|
||||||
import AppSidebar from "@/layout/AppSidebar";
|
import AppSidebar from "@/layout/AppSidebar";
|
||||||
import Backdrop from "@/layout/Backdrop";
|
import Backdrop from "@/layout/Backdrop";
|
||||||
import { apiGetCurrentUser } from "@/service/auth";
|
import { apiGetCurrentUser } from "@/service/auth";
|
||||||
@@ -13,6 +13,20 @@ export default function AdminLayout({
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<SidebarProvider>
|
||||||
|
<AdminLayoutContent>{children}</AdminLayoutContent>
|
||||||
|
</SidebarProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminLayoutContent({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
|
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
@@ -27,7 +41,7 @@ export default function AdminLayout({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchUser();
|
fetchUser();
|
||||||
}, [])
|
}, [dispatch])
|
||||||
|
|
||||||
|
|
||||||
const mainContentMargin = isMobileOpen
|
const mainContentMargin = isMobileOpen
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import type {
|
|||||||
UIOptionName,
|
UIOptionName,
|
||||||
} from "@/uhm/types/projects";
|
} from "@/uhm/types/projects";
|
||||||
import { Panel } from "./Panel";
|
import { Panel } from "./Panel";
|
||||||
import { Modal } from "@/components/ui/modal";
|
import { Modal } from "@/uhm/components/ui/Modal";
|
||||||
import Button from "@/components/ui/button/Button";
|
import Button from "@/uhm/components/ui/Button";
|
||||||
import Label from "@/components/form/Label";
|
import Label from "@/uhm/components/ui/Label";
|
||||||
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||||
import { useEditorStore } from "@/uhm/store/editorStore";
|
import { useEditorStore } from "@/uhm/store/editorStore";
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
|||||||
|
|
||||||
import { type CSSProperties, type ReactNode, useState, useEffect } from "react";
|
import { type CSSProperties, type ReactNode, useState, useEffect } from "react";
|
||||||
import { apiGetCurrentUser } from "@/service/auth";
|
import { apiGetCurrentUser } from "@/service/auth";
|
||||||
import ChatbotWidget from "@/components/ui/chat/ChatbotWidget";
|
import ChatbotWidget from "@/uhm/components/ui/ChatbotWidget";
|
||||||
import Map, { type MapFeaturePayload } from "@/uhm/components/Map";
|
import Map, { type MapFeaturePayload } from "@/uhm/components/Map";
|
||||||
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
|
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
|
||||||
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
|
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
size?: "sm" | "md";
|
||||||
|
variant?: "primary" | "outline";
|
||||||
|
startIcon?: ReactNode;
|
||||||
|
endIcon?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
children,
|
||||||
|
size = "md",
|
||||||
|
variant = "primary",
|
||||||
|
startIcon,
|
||||||
|
endIcon,
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
type = "button",
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "px-4 py-3 text-sm",
|
||||||
|
md: "px-5 py-3.5 text-sm",
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary:
|
||||||
|
"bg-brand-500 text-white shadow-theme-xs hover:bg-brand-600 disabled:bg-brand-300",
|
||||||
|
outline:
|
||||||
|
"bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700 dark:hover:bg-white/[0.03] dark:hover:text-gray-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`inline-flex items-center justify-center font-medium gap-2 rounded-lg transition ${className} ${
|
||||||
|
sizeClasses[size]
|
||||||
|
} ${variantClasses[variant]} ${
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
disabled={disabled}
|
||||||
|
type={type}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{startIcon && <span className="flex items-center">{startIcon}</span>}
|
||||||
|
{children}
|
||||||
|
{endIcon && <span className="flex items-center">{endIcon}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import type { ChatbotPayload } from "@/interface/chatbot";
|
||||||
|
import { apiChatbot, apiChatbotHistory } from "@/service/chatbotService";
|
||||||
|
|
||||||
|
type Message = {
|
||||||
|
id: string;
|
||||||
|
sender: "user" | "bot";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatbotWidgetProps = {
|
||||||
|
projectId?: string;
|
||||||
|
hideFloatingButton?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanAnswer = (text: string) => {
|
||||||
|
if (!text) return "";
|
||||||
|
return text.replace(/<\/?answer>/gi, "").trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChatbotWidget({
|
||||||
|
projectId = "",
|
||||||
|
hideFloatingButton = false,
|
||||||
|
}: ChatbotWidgetProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [preCursor, setPreCursor] = useState<string | null>(null);
|
||||||
|
const [isFetchingHistory, setIsFetchingHistory] = useState(false);
|
||||||
|
const [hasLoadedHistory, setHasLoadedHistory] = useState(false);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const prevMessagesLength = useRef(0);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleToggle = () => setIsOpen((prev) => !prev);
|
||||||
|
window.addEventListener("toggle-chatbot", handleToggle);
|
||||||
|
return () => window.removeEventListener("toggle-chatbot", handleToggle);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadHistory = async (cursor?: string) => {
|
||||||
|
setIsFetchingHistory(true);
|
||||||
|
try {
|
||||||
|
const res = await apiChatbotHistory({ cursor, limit: 10 });
|
||||||
|
if (res?.status && res.data) {
|
||||||
|
const historyItems = res.data.items || [];
|
||||||
|
const newMessages: Message[] = [];
|
||||||
|
|
||||||
|
historyItems.forEach((item: { id: string; question: string; answer: string }) => {
|
||||||
|
newMessages.push({
|
||||||
|
id: `${item.id}_q`,
|
||||||
|
sender: "user",
|
||||||
|
text: item.question,
|
||||||
|
});
|
||||||
|
newMessages.push({
|
||||||
|
id: `${item.id}_a`,
|
||||||
|
sender: "bot",
|
||||||
|
text: cleanAnswer(item.answer),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
setMessages((prev) => [...newMessages, ...prev]);
|
||||||
|
} else {
|
||||||
|
setMessages(newMessages);
|
||||||
|
setHasLoadedHistory(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreCursor(res.data.pre_cursor || null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load chat history:", error);
|
||||||
|
} finally {
|
||||||
|
setIsFetchingHistory(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !hasLoadedHistory) {
|
||||||
|
void loadHistory();
|
||||||
|
}
|
||||||
|
}, [isOpen, hasLoadedHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) scrollToBottom();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > prevMessagesLength.current) {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
prevMessagesLength.current = messages.length;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !isLoading) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen, isLoading]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!input.trim() || isLoading) return;
|
||||||
|
|
||||||
|
const userText = input.trim();
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
sender: "user",
|
||||||
|
text: userText,
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
setInput("");
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: ChatbotPayload = {
|
||||||
|
project_id: projectId,
|
||||||
|
question: userText,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await apiChatbot(payload);
|
||||||
|
const botMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
sender: "bot",
|
||||||
|
text: res?.status
|
||||||
|
? cleanAnswer(res?.data)
|
||||||
|
: "Xin lỗi, tôi không thể trả lời lúc này.",
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, botMessage]);
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as AxiosError<{ message: string }>;
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
sender: "bot",
|
||||||
|
text:
|
||||||
|
axiosError.response?.data?.message ||
|
||||||
|
"Có lỗi xảy ra khi kết nối. Vui lòng thử lại sau.",
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, errorMessage]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
void handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allMessages: Message[] = [
|
||||||
|
{
|
||||||
|
id: "init",
|
||||||
|
sender: "bot",
|
||||||
|
text: "Xin chào! Tôi là trợ lý lịch sử thân thiện. Tôi có thể giúp gì cho bạn?",
|
||||||
|
},
|
||||||
|
...messages,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-8 right-8 z-50">
|
||||||
|
{!isOpen && !hideFloatingButton && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
name="AI chat"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="w-14 h-14 bg-brand-500 hover:bg-brand-600 text-white rounded-full flex items-center justify-center shadow-[0_4px_14px_rgba(0,0,0,0.25)] transition-transform hover:scale-105"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="w-[360px] h-[520px] bg-white dark:bg-gray-900 rounded-2xl shadow-2xl flex flex-col border border-gray-200 dark:border-gray-800 overflow-hidden animate-in slide-in-from-bottom-4 duration-300">
|
||||||
|
<div className="px-4 py-3 bg-brand-500 text-white flex items-center justify-between shadow-sm z-10">
|
||||||
|
<div className="font-semibold flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23-.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Trợ lý lịch sử</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="p-1.5 hover:bg-brand-600 rounded-lg transition-colors text-white"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={2}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 p-4 overflow-y-auto flex flex-col gap-3 bg-gray-50 dark:bg-[#0d1117] text-sm">
|
||||||
|
{preCursor && (
|
||||||
|
<div className="flex justify-center mb-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadHistory(preCursor)}
|
||||||
|
disabled={isFetchingHistory}
|
||||||
|
className="text-xs text-brand-500 hover:text-brand-600 font-medium py-1 px-3 bg-brand-50 dark:bg-brand-950/20 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isFetchingHistory ? "Đang tải..." : "Xem tin nhắn cũ hơn"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allMessages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`max-w-[85%] rounded-2xl px-4 py-2 shadow-sm ${
|
||||||
|
msg.sender === "user"
|
||||||
|
? "bg-brand-500 text-white self-end rounded-br-sm animate-in fade-in duration-200 slide-in-from-bottom-1"
|
||||||
|
: "bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-300 border border-gray-100 dark:border-gray-700 self-start rounded-bl-sm animate-in fade-in duration-200 slide-in-from-bottom-1"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 self-start rounded-2xl rounded-bl-sm px-4 py-3 shadow-sm flex items-center gap-1.5 max-w-[80%]">
|
||||||
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||||
|
style={{ animationDelay: "0.2s" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||||
|
style={{ animationDelay: "0.4s" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFetchingHistory && !preCursor && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<div className="w-5 h-5 border-2 border-brand-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Nhập câu hỏi..."
|
||||||
|
value={input}
|
||||||
|
onChange={(event) => setInput(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 bg-gray-100 dark:bg-gray-800 border-transparent focus:border-brand-500 focus:bg-white dark:focus:bg-gray-900 focus:ring-1 focus:ring-brand-500/20 rounded-full px-4 py-2.5 text-sm text-gray-900 dark:text-white outline-none transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSend()}
|
||||||
|
disabled={!input.trim() || isLoading}
|
||||||
|
className={`p-2.5 rounded-full transition-colors flex shrink-0 items-center justify-center ${
|
||||||
|
!input.trim() || isLoading
|
||||||
|
? "text-gray-400 bg-gray-100 dark:bg-gray-800 cursor-not-allowed"
|
||||||
|
: "bg-brand-500 text-white hover:bg-brand-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.405z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { LabelHTMLAttributes, ReactNode } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type LabelProps = LabelHTMLAttributes<HTMLLabelElement> & {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Label({ children, className, ...props }: LabelProps) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={twMerge(
|
||||||
|
"mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
isFullscreen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
showCloseButton = true,
|
||||||
|
isFullscreen = false,
|
||||||
|
}: ModalProps) {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const contentClasses = isFullscreen
|
||||||
|
? "w-full h-full"
|
||||||
|
: "relative w-full rounded-xl bg-white dark:bg-gray-900";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-99999 flex items-center justify-center overflow-y-auto modal">
|
||||||
|
{!isFullscreen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close modal backdrop"
|
||||||
|
className="fixed inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className={`${contentClasses} ${className}`}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{showCloseButton && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import dynamic from "next/dynamic";
|
|||||||
import "react-quill-new/dist/quill.snow.css";
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
|
||||||
import { Modal } from "@/components/ui/modal";
|
import { Modal } from "@/uhm/components/ui/Modal";
|
||||||
import Button from "@/components/ui/button/Button";
|
import Button from "@/uhm/components/ui/Button";
|
||||||
import Label from "@/components/form/Label";
|
import Label from "@/uhm/components/ui/Label";
|
||||||
|
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import { newId } from "@/uhm/lib/utils/id";
|
import { newId } from "@/uhm/lib/utils/id";
|
||||||
|
|||||||
Reference in New Issue
Block a user