update license

This commit is contained in:
taDuc
2026-06-07 23:24:52 +07:00
parent 282df2bc91
commit 74b5b615ac
11 changed files with 545 additions and 16 deletions
+1 -1
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+49
View File
@@ -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>
);
}
+325
View File
@@ -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ị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>
);
}
+20
View File
@@ -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>
);
}
+92
View File
@@ -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>
);
}
+3 -3
View File
@@ -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";