| import { useEffect, Suspense } from "react"; |
| import { NavLink, useLocation, useBlocker, Outlet, useNavigate } from "react-router-dom"; |
| import Sidebar from "./Sidebar"; |
| import { useVideoGen } from "../stores/videoGenStore"; |
| import { useDirty } from "../stores/dirtyStore"; |
| import { useAuth } from "../stores/authStore"; |
|
|
| |
| function PageFallback() { |
| return ( |
| <div style={{ padding: 40, color: "var(--muted)", fontSize: 14 }}> |
| Đang tải công cụ… |
| </div> |
| ); |
| } |
|
|
| const tabs = [ |
| { to: "/", label: "Trang chủ" }, |
| { to: "/huong-dan", label: "Hướng dẫn" }, |
| { to: "/ho-tro", label: "Hỗ trợ" }, |
| ]; |
|
|
| export default function Layout() { |
| const location = useLocation(); |
| const navigate = useNavigate(); |
| const { authorized, user, logout } = useAuth(); |
|
|
| useEffect(() => { |
| if (!authorized) navigate("/dang-nhap", { replace: true }); |
| }, [authorized]); |
|
|
| const handleLogout = () => { |
| logout(); |
| navigate("/dang-nhap"); |
| }; |
| const isVideoGen = location.pathname === "/tao-video"; |
| const { connected, url } = useVideoGen(); |
| const { dirty, message } = useDirty(); |
|
|
| const blocker = useBlocker( |
| ({ currentLocation, nextLocation }) => |
| dirty && currentLocation.pathname !== nextLocation.pathname |
| ); |
|
|
| useEffect(() => { |
| if (blocker.state === "blocked") { |
| const proceed = window.confirm( |
| message || "Bạn có chắc muốn rời khỏi công cụ này? Tiến trình hiện tại sẽ bị mất." |
| ); |
| if (proceed) { |
| blocker.proceed(); |
| } else { |
| blocker.reset(); |
| } |
| } |
| }, [blocker.state, message]); |
|
|
| return ( |
| <div className="shell"> |
| <Sidebar /> |
| <div className="main"> |
| <div className="topbar"> |
| {tabs.map((t) => ( |
| <NavLink key={t.to} to={t.to} end className={({ isActive }) => "tab" + (isActive ? " active" : "")}> |
| {t.label} |
| </NavLink> |
| ))} |
| <div className="spacer" /> |
| <span style={{ fontSize: 12, color: "var(--muted)" }}>{user}</span> |
| <div className="avatar" style={{ cursor: "pointer" }} onClick={handleLogout} title="Đăng xuất">TNT</div> |
| </div> |
| |
| {/* Chỉ MỘT <Outlet> được render tại một thời điểm (cái còn lại là `false`) |
| -> trang route chỉ mount 1 lần, không còn chạy đôi effect/poll/upload. |
| iframe Gradio LUÔN nằm trong cây (ngoài điều kiện) để giữ phiên kết nối |
| khi chuyển tool rồi quay lại /tao-video. */} |
| <div className="content" style={{ display: isVideoGen ? "none" : "block" }}> |
| <Suspense fallback={<PageFallback />}>{!isVideoGen && <Outlet />}</Suspense> |
| </div> |
| |
| <div style={{ |
| display: isVideoGen ? "flex" : "none", |
| flexDirection: "column", |
| flex: 1, |
| minHeight: 0, |
| }}> |
| <Suspense fallback={<PageFallback />}>{isVideoGen && <Outlet />}</Suspense> |
| <iframe |
| src={connected && url ? url : "about:blank"} |
| style={{ |
| flex: 1, |
| width: "100%", |
| border: "none", |
| display: connected ? "block" : "none", |
| }} |
| allow="clipboard-read; clipboard-write; fullscreen" |
| sandbox="allow-scripts allow-same-origin allow-forms allow-popups" |
| title="Gradio Console" |
| /> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
| |