diff --git a/LICENSE b/LICENSE
index cb92d41..1d490f0 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2023 TailAdmin
+Copyright (c) 2026 Pregnant guide
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..4b1bf4d
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -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.
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index a0bf69d..a81bbda 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,8 +1,6 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
-import { SidebarProvider } from '@/context/SidebarContext';
-import { ThemeProvider } from '@/context/ThemeContext';
import { Toaster } from 'sonner';
import StoreProvider from '@/store/StoreProvider';
@@ -27,9 +25,8 @@ export default function RootLayout({
-
- {children}
-
+ {children}
+
diff --git a/src/app/user/layout.tsx b/src/app/user/layout.tsx
index 4d532d1..2bff3b0 100644
--- a/src/app/user/layout.tsx
+++ b/src/app/user/layout.tsx
@@ -1,7 +1,7 @@
"use client";
-import { useSidebar } from "@/context/SidebarContext";
-import AppHeader from "@/layout/AppHeader";
+import { SidebarProvider, useSidebar } from "@/context/SidebarContext";
+import { ThemeProvider } from "@/context/ThemeContext";
import AppSidebar from "@/layout/AppSidebar";
import Backdrop from "@/layout/Backdrop";
import { apiGetCurrentUser } from "@/service/auth";
@@ -13,6 +13,20 @@ export default function AdminLayout({
children,
}: {
children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function AdminLayoutContent({
+ children,
+}: {
+ children: React.ReactNode;
}) {
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
const dispatch = useDispatch()
@@ -27,7 +41,7 @@ export default function AdminLayout({
}
};
fetchUser();
- }, [])
+ }, [dispatch])
const mainContentMargin = isMobileOpen
diff --git a/src/uhm/components/editor/ReplayEffectsSidebar.tsx b/src/uhm/components/editor/ReplayEffectsSidebar.tsx
index a95fad4..c486fd8 100644
--- a/src/uhm/components/editor/ReplayEffectsSidebar.tsx
+++ b/src/uhm/components/editor/ReplayEffectsSidebar.tsx
@@ -14,9 +14,9 @@ import type {
UIOptionName,
} from "@/uhm/types/projects";
import { Panel } from "./Panel";
-import { Modal } from "@/components/ui/modal";
-import Button from "@/components/ui/button/Button";
-import Label from "@/components/form/Label";
+import { Modal } from "@/uhm/components/ui/Modal";
+import Button from "@/uhm/components/ui/Button";
+import Label from "@/uhm/components/ui/Label";
import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
import { useEditorStore } from "@/uhm/store/editorStore";
diff --git a/src/uhm/components/preview/PreviewMapShell.tsx b/src/uhm/components/preview/PreviewMapShell.tsx
index 6065012..a052275 100644
--- a/src/uhm/components/preview/PreviewMapShell.tsx
+++ b/src/uhm/components/preview/PreviewMapShell.tsx
@@ -3,7 +3,7 @@ import Image from "next/image";
import { type CSSProperties, type ReactNode, useState, useEffect } from "react";
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 ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
diff --git a/src/uhm/components/ui/Button.tsx b/src/uhm/components/ui/Button.tsx
new file mode 100644
index 0000000..d9157cc
--- /dev/null
+++ b/src/uhm/components/ui/Button.tsx
@@ -0,0 +1,49 @@
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+
+type ButtonProps = ButtonHTMLAttributes & {
+ 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 (
+
+ {startIcon && {startIcon} }
+ {children}
+ {endIcon && {endIcon} }
+
+ );
+}
diff --git a/src/uhm/components/ui/ChatbotWidget.tsx b/src/uhm/components/ui/ChatbotWidget.tsx
new file mode 100644
index 0000000..c567483
--- /dev/null
+++ b/src/uhm/components/ui/ChatbotWidget.tsx
@@ -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([]);
+ const [preCursor, setPreCursor] = useState(null);
+ const [isFetchingHistory, setIsFetchingHistory] = useState(false);
+ const [hasLoadedHistory, setHasLoadedHistory] = useState(false);
+ const [input, setInput] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(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) => {
+ 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 (
+
+ {!isOpen && !hideFloatingButton && (
+
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"
+ >
+
+
+
+
+ )}
+
+ {isOpen && (
+
+
+
+
setIsOpen(false)}
+ className="p-1.5 hover:bg-brand-600 rounded-lg transition-colors text-white"
+ >
+
+
+
+
+
+
+
+ {preCursor && (
+
+ 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"}
+
+
+ )}
+
+ {allMessages.map((msg) => (
+
+ {msg.text}
+
+ ))}
+
+ {isLoading && (
+
+ )}
+
+ {isFetchingHistory && !preCursor && (
+
+ )}
+
+
+
+
+
+
+
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"
+ />
+
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"
+ }`}
+ >
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/uhm/components/ui/Label.tsx b/src/uhm/components/ui/Label.tsx
new file mode 100644
index 0000000..678d226
--- /dev/null
+++ b/src/uhm/components/ui/Label.tsx
@@ -0,0 +1,20 @@
+import type { LabelHTMLAttributes, ReactNode } from "react";
+import { twMerge } from "tailwind-merge";
+
+type LabelProps = LabelHTMLAttributes & {
+ children: ReactNode;
+};
+
+export default function Label({ children, className, ...props }: LabelProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/uhm/components/ui/Modal.tsx b/src/uhm/components/ui/Modal.tsx
new file mode 100644
index 0000000..3d3d52e
--- /dev/null
+++ b/src/uhm/components/ui/Modal.tsx
@@ -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(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 (
+
+ {!isFullscreen && (
+
+ )}
+
event.stopPropagation()}
+ >
+ {showCloseButton && (
+
+
+
+
+
+ )}
+
{children}
+
+
+ );
+}
diff --git a/src/uhm/components/wiki/WikiSidebarPanel.tsx b/src/uhm/components/wiki/WikiSidebarPanel.tsx
index 2e15e3e..6be7e27 100644
--- a/src/uhm/components/wiki/WikiSidebarPanel.tsx
+++ b/src/uhm/components/wiki/WikiSidebarPanel.tsx
@@ -5,9 +5,9 @@ import dynamic from "next/dynamic";
import "react-quill-new/dist/quill.snow.css";
import { useShallow } from "zustand/react/shallow";
-import { Modal } from "@/components/ui/modal";
-import Button from "@/components/ui/button/Button";
-import Label from "@/components/form/Label";
+import { Modal } from "@/uhm/components/ui/Modal";
+import Button from "@/uhm/components/ui/Button";
+import Label from "@/uhm/components/ui/Label";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import { newId } from "@/uhm/lib/utils/id";