From 74b5b615accc9ae2f15276daa816dccb19a7858d Mon Sep 17 00:00:00 2001 From: taDuc Date: Sun, 7 Jun 2026 23:24:52 +0700 Subject: [PATCH] update license --- LICENSE | 2 +- THIRD_PARTY_NOTICES.md | 32 ++ src/app/layout.tsx | 7 +- src/app/user/layout.tsx | 20 +- .../editor/ReplayEffectsSidebar.tsx | 6 +- .../components/preview/PreviewMapShell.tsx | 2 +- src/uhm/components/ui/Button.tsx | 49 +++ src/uhm/components/ui/ChatbotWidget.tsx | 325 ++++++++++++++++++ src/uhm/components/ui/Label.tsx | 20 ++ src/uhm/components/ui/Modal.tsx | 92 +++++ src/uhm/components/wiki/WikiSidebarPanel.tsx | 6 +- 11 files changed, 545 insertions(+), 16 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 src/uhm/components/ui/Button.tsx create mode 100644 src/uhm/components/ui/ChatbotWidget.tsx create mode 100644 src/uhm/components/ui/Label.tsx create mode 100644 src/uhm/components/ui/Modal.tsx 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 ( + + ); +} 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 && ( + + )} + + {isOpen && ( +
+
+
+ + + + Trợ lý lịch sử +
+ +
+ +
+ {preCursor && ( +
+ +
+ )} + + {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" + /> + +
+
+
+ )} +
+ ); +} 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 ( + + ); +} 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 && ( + + )} +
{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";