diff --git a/apps/pyconkr-admin/src/main.tsx b/apps/pyconkr-admin/src/main.tsx
index a2055cf..6545c53 100644
--- a/apps/pyconkr-admin/src/main.tsx
+++ b/apps/pyconkr-admin/src/main.tsx
@@ -32,6 +32,7 @@ const queryClient = new QueryClient({
const CommonOptions: Common.Contexts.ContextOptions = {
debug: true,
+ language: "ko",
baseUrl: ".",
frontendDomain: import.meta.env.VITE_PYCONKR_FRONTEND_DOMAIN,
backendApiDomain: import.meta.env.VITE_PYCONKR_BACKEND_API_DOMAIN,
diff --git a/apps/pyconkr/src/App.tsx b/apps/pyconkr/src/App.tsx
index f02b9f1..1471c9c 100644
--- a/apps/pyconkr/src/App.tsx
+++ b/apps/pyconkr/src/App.tsx
@@ -1,36 +1,48 @@
import * as Common from "@frontend/common";
-import React from "react";
-import { BrowserRouter, Route, Routes, useLocation } from "react-router-dom";
+import * as React from "react";
+import { Route, Routes, useLocation } from "react-router-dom";
+import * as R from "remeda";
-import MainLayout from "./components/layout";
-import { Test } from "./components/pages/test";
-import { IS_DEBUG_ENV } from "./consts/index.ts";
-import { SponsorProvider } from "./contexts/SponsorContext";
+import MainLayout from "./components/layout/index.tsx";
+import { PageIdParamRenderer, RouteRenderer } from "./components/pages/dynamic_route.tsx";
+import { Test } from "./components/pages/test.tsx";
+import { IS_DEBUG_ENV } from "./consts";
+import { useAppContext } from "./contexts/app_context";
+import BackendAPISchemas from "../../../packages/common/src/schemas/backendAPI";
-// 스폰서를 표시할 페이지 경로 설정
-const SPONSOR_VISIBLE_PATHS = ["/"];
+export const App: React.FC = () => {
+ const backendAPIClient = Common.Hooks.BackendAPI.useBackendClient();
+ const { data: flatSiteMap } = Common.Hooks.BackendAPI.useFlattenSiteMapQuery(backendAPIClient);
+ const siteMapNode = Common.Utils.buildNestedSiteMap(flatSiteMap)?.[""];
-const AppContent = () => {
const location = useLocation();
- const shouldShowSponsor = SPONSOR_VISIBLE_PATHS.includes(location.pathname);
+ const { setAppContext, language } = useAppContext();
- return (
-
-
- }>
- {IS_DEBUG_ENV && } />}
- } />
- } />
-
-
-
- );
-};
+ React.useEffect(() => {
+ (async () => {
+ const currentRouteCodes = ["", ...location.pathname.split("/").filter((code) => !R.isEmpty(code))];
+ const currentSiteMapDepth: (BackendAPISchemas.NestedSiteMapSchema | undefined)[] = [siteMapNode];
+
+ for (const routeCode of currentRouteCodes.splice(1)) {
+ currentSiteMapDepth.push(currentSiteMapDepth.at(-1)?.children[routeCode]);
+ if (R.isNullish(currentSiteMapDepth.at(-1))) {
+ console.warn(`Route not found in site map: ${routeCode}`);
+ break;
+ }
+ }
+
+ setAppContext((ps) => ({ ...ps, siteMapNode, currentSiteMapDepth }));
+ })();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [location, language, flatSiteMap]);
-export const App: React.FC = () => {
return (
-
-
-
+
+ }>
+ {IS_DEBUG_ENV && } />}
+ } />
+ } />
+
+
);
};
diff --git a/apps/pyconkr/src/components/layout/BreadCrumb/index.tsx b/apps/pyconkr/src/components/layout/BreadCrumb/index.tsx
index 1a6fdaf..464b7af 100644
--- a/apps/pyconkr/src/components/layout/BreadCrumb/index.tsx
+++ b/apps/pyconkr/src/components/layout/BreadCrumb/index.tsx
@@ -1,56 +1,71 @@
-import styled from "@emotion/styled";
-import { useEffect, useState } from "react";
+import { Stack, styled } from "@mui/material";
+import * as React from "react";
+import { Link } from "react-router-dom";
+import * as R from "remeda";
-export default function BreadCrumb() {
- const [breadcrumbInfo, setBreadcrumbInfo] = useState({
- paths: [{ text: "홈", href: "/" }],
- title: "파이콘 한국 행동강령(CoC)",
- });
+import BackendAPISchemas from "../../../../../../packages/common/src/schemas/backendAPI";
- useEffect(() => {
- const mockPathInfo = {
- paths: [
- { text: "홈", href: "/" },
- { text: "파이콘 한국", href: "/about" },
- ],
- title: "파이콘 한국 행동강령(CoC)",
- };
- setBreadcrumbInfo(mockPathInfo);
- }, []);
+type BreadCrumbPropType = {
+ title: string;
+ parentSiteMaps: (BackendAPISchemas.NestedSiteMapSchema | undefined)[];
+};
+export const BreadCrumb: React.FC = ({ title, parentSiteMaps }) => {
+ let route = "/";
return (
-
- {breadcrumbInfo.paths.map((item, index) => (
-
- {index > 0 && >}
- {item.text}
-
- ))}
+
+ {parentSiteMaps
+ .slice(1, -1)
+ .filter((routeInfo) => R.isNonNullish(routeInfo))
+ .map(({ route_code, name }, index) => {
+ route += `${route_code}/`;
+ return (
+
+ {index > 0 && >}
+
+
+ );
+ })}
- {breadcrumbInfo.title}
+ {title}
);
-}
+};
-const BreadCrumbContainer = styled.div`
- width: 100%;
- padding: 14px 117px;
- background-color: rgba(255, 255, 255, 0.7);
- background-image: linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.45));
- box-shadow: 0 1px 10px rgba(0, 0, 0, 0.1);
- display: flex;
- flex-direction: column;
- gap: 5px;
-`;
+const BreadCrumbContainer = styled(Stack)(({ theme }) => ({
+ position: "fixed",
+
+ top: "3.625rem",
+ width: "100%",
+ height: "4.5rem",
+ background: "linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.45))",
+ boxShadow: "0 1px 10px rgba(0, 0, 0, 0.1)",
+ backdropFilter: "blur(10px)",
+
+ gap: "0.25rem",
+ justifyContent: "center",
+ alignItems: "flex-start",
+
+ zIndex: theme.zIndex.appBar - 1,
+
+ paddingRight: "8rem",
+ paddingLeft: "8rem",
+
+ [theme.breakpoints.down("lg")]: {
+ paddingRight: "2rem",
+ paddingLeft: "2rem",
+ },
+ [theme.breakpoints.down("sm")]: {
+ paddingRight: "1rem",
+ paddingLeft: "1rem",
+ },
+}));
-const BreadcrumbPathContainer = styled.div`
+const BreadcrumbPathContainer = styled(Stack)`
font-size: 9.75px;
font-weight: 300;
color: #000000;
- display: flex;
- align-items: center;
- gap: 0;
a {
color: #000000;
@@ -67,7 +82,7 @@ const BreadcrumbPathContainer = styled.div`
}
`;
-const PageTitle = styled.h1`
+const PageTitle = styled("h1")`
font-size: 27px;
font-weight: 600;
color: #000000;
diff --git a/apps/pyconkr/src/components/layout/Header/index.tsx b/apps/pyconkr/src/components/layout/Header/index.tsx
index d9aab15..b8301fd 100644
--- a/apps/pyconkr/src/components/layout/Header/index.tsx
+++ b/apps/pyconkr/src/components/layout/Header/index.tsx
@@ -1,57 +1,287 @@
-import styled from "@emotion/styled";
import * as Common from "@frontend/common";
-import { useNavigate } from "react-router-dom";
+import { ArrowForwardIos } from "@mui/icons-material";
+import { Box, Button, CircularProgress, Divider, Stack, styled, SxProps, Theme, Typography } from "@mui/material";
+import { MUIStyledCommonProps } from "@mui/system";
+import * as React from "react";
+import { Link } from "react-router-dom";
+import * as R from "remeda";
-import BreadCrumb from "../BreadCrumb";
+import BackendAPISchemas from "../../../../../../packages/common/src/schemas/backendAPI";
+import { useAppContext } from "../../../contexts/app_context";
import LanguageSelector from "../LanguageSelector";
import LoginButton from "../LoginButton";
-import Nav from "../Nav";
+
+type MenuType = BackendAPISchemas.NestedSiteMapSchema;
+type MenuOrUndefinedType = MenuType | undefined;
+
+type NavigationStateType = {
+ depth1?: MenuType;
+ depth2?: MenuType;
+ depth3?: MenuType;
+};
+
+const HeaderHeight: React.CSSProperties["height"] = "3.625rem";
+const BreadCrumbHeight: React.CSSProperties["height"] = "4.5rem";
const Header: React.FC = () => {
- const navigate = useNavigate();
+ const { title, language, siteMapNode, currentSiteMapDepth, shouldShowTitleBanner } = useAppContext();
+ const [navState, setNavState] = React.useState({});
+
+ const resetDepths = () => setNavState({});
+ const setDepth1 = (depth1: MenuOrUndefinedType) => setNavState({ depth1 });
+ const setDepth2 = (depth2: MenuOrUndefinedType) => setNavState((ps) => ({ ...ps, depth2, depth3: undefined }));
+ const setDepth3 = (depth3: MenuOrUndefinedType) => setNavState((ps) => ({ ...ps, depth3 }));
+
+ const getDepth2Route = (nextRoute?: string) => (navState.depth1?.route_code || "") + `/${nextRoute || ""}`;
+ const getDepth3Route = (nextRoute?: string) => getDepth2Route(navState.depth2?.route_code) + `/${nextRoute || ""}`;
+
+ React.useEffect(resetDepths, [language]);
+
+ let breadCrumbRoute = "";
+ let breadCrumbArray = currentSiteMapDepth.slice(1, -1);
+ if (R.isEmpty(breadCrumbArray)) breadCrumbArray = currentSiteMapDepth.slice(0, -1);
+
+ const headerContainerStyle: SxProps = shouldShowTitleBanner
+ ? {}
+ : {
+ backgroundColor: "transparent",
+ [":hover"]: { backgroundColor: (theme) => theme.palette.primary.light },
+ };
return (
- <>
-
- navigate("/")}>
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {siteMapNode ? (
+ <>
+
+ {Object.values(siteMapNode.children).map((r) => (
+
+
+
+ ))}
+
+
+ {navState.depth1 && (
+
+
+
+ {navState.depth1.name}
+
+
+
+
+ {Object.values(navState.depth1.children).map((r) => (
+ setDepth2(r)}
+ // 하위 depth가 있는 경우, 하위 depth를 선택할 수 있도록 유지하기 위해 depth2도 유지합니다.
+ onMouseLeave={() => R.isEmpty(navState.depth2?.children ?? {}) && setDepth2(undefined)}
+ to={getDepth2Route(r.route_code)}
+ />
+ ))}
+
+
+ {navState.depth2 && !R.isEmpty(navState.depth2.children) && (
+ <>
+ {!R.isEmpty(navState.depth2.children) && }
+
+
+ {Object.values(navState.depth2.children).map((r) => (
+ setDepth3(r)}
+ onMouseLeave={() => setDepth3(undefined)}
+ to={getDepth3Route(r?.route_code)}
+ />
+ ))}
+
+ >
+ )}
+
+
+
+ )}
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+
-
- >
+ {shouldShowTitleBanner && (
+ <>
+
+
+ {breadCrumbArray
+ .filter((routeInfo) => R.isNonNullish(routeInfo))
+ .map(({ route_code, name }, index) => {
+ breadCrumbRoute += `${route_code}/`;
+ return (
+
+ {index > 0 && }
+
+
+ );
+ })}
+
+
+ {title}
+
+
+ {/* Spacer for fixed header */}
+
+ >
+ )}
+
);
};
-const HeaderContainer = styled.header`
- background-color: ${({ theme }) => theme.palette.primary.light};
- color: ${({ theme }) => theme.palette.primary.dark};
- font-size: 0.8125rem;
- font-weight: 500;
- width: 100%;
- height: 3.625rem;
- padding: 0.5625rem 7.125rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
- position: relative;
-`;
-
-const HeaderLogo = styled.div`
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
-`;
-
-const HeaderLeft = styled.div`
- display: flex;
- align-items: center;
- gap: 1.125rem;
-`;
+const ResponsivePaddingDefinition = ({ theme }: MUIStyledCommonProps) => ({
+ paddingRight: theme!.spacing(16),
+ paddingLeft: theme!.spacing(16),
+
+ [theme!.breakpoints.down("lg")]: {
+ paddingRight: theme!.spacing(4),
+ paddingLeft: theme!.spacing(4),
+ },
+ [theme!.breakpoints.down("sm")]: {
+ paddingRight: theme!.spacing(2),
+ paddingLeft: theme!.spacing(2),
+ },
+});
+
+const HeaderContainer = styled("header")(({ theme }) => ({
+ position: "fixed",
+
+ display: "flex",
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+
+ width: "100%",
+ minWidth: "100%",
+ maxWidth: "100%",
+ height: HeaderHeight,
+
+ backgroundColor: theme.palette.primary.light,
+ color: theme.palette.primary.dark,
+
+ fontWeight: 500,
+
+ zIndex: theme.zIndex.appBar,
+ transition: "background-color 0.3s ease-in-out",
+
+ ...ResponsivePaddingDefinition({ theme }),
+}));
+
+const NavOuterContainer = styled(Stack)(({ theme }) => ({
+ width: "100vw",
+
+ position: "fixed",
+ left: 0,
+ top: HeaderHeight,
+
+ zIndex: theme.zIndex.appBar + 1,
+
+ backgroundColor: "rgba(255, 255, 255, 0.7)",
+ boxShadow: "0 5px 5px 0px rgba(0, 0, 0, 0.1)",
+ backdropFilter: "blur(10px)",
+
+ fontSize: "0.875rem",
+ color: theme.palette.primary.dark,
+}));
+
+const NavInnerContainer = styled(Stack)(({ theme }) => ({
+ width: "100%",
+ minHeight: "10rem",
+ overflowY: "auto",
+ gap: "1rem",
+
+ backgroundColor: "rgba(182, 216, 215, 0.05)",
+
+ paddingTop: "1.5rem",
+ paddingBottom: "2rem",
+
+ ...ResponsivePaddingDefinition({ theme }),
+}));
+
+const NavSideElementContainer = styled(Stack)({
+ flexGrow: 1,
+ flexBasis: 0,
+});
+
+const Depth1to2Divider = styled(Divider)(({ theme }) => ({
+ width: "3.375rem",
+ borderBottom: `4px solid ${theme.palette.highlight.main}`,
+}));
+
+const Depth2Item = styled(Link)(({ theme }) => ({
+ fontWeight: 300,
+ textDecoration: "none",
+ width: "fit-content",
+ borderBottom: "2px solid transparent",
+
+ "&.active": {
+ fontWeight: 700,
+ borderBottom: `2px solid ${theme.palette.primary.dark}`,
+ },
+}));
+
+const Depth2to3Divider = styled(Divider)(({ theme }) => ({ borderColor: theme.palette.primary.light }));
+
+const Depth3Item = styled(Depth2Item)({ fontSize: "0.75rem" });
+
+const BreadCrumbContainer = styled(Stack)(({ theme }) => ({
+ position: "fixed",
+
+ top: HeaderHeight,
+ width: "100%",
+ height: BreadCrumbHeight,
+ background: "linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.45))",
+ boxShadow: "0 1px 10px rgba(0, 0, 0, 0.1)",
+ backdropFilter: "blur(10px)",
+
+ gap: "0.25rem",
+ justifyContent: "center",
+ alignItems: "flex-start",
+
+ zIndex: theme.zIndex.appBar - 1,
+
+ ...ResponsivePaddingDefinition({ theme }),
+
+ "& a": {
+ color: "#000000",
+ fontWeight: 300,
+ fontSize: "0.75rem",
+ textDecoration: "none",
+
+ "&:hover": {
+ textDecoration: "underline",
+ },
+ },
+ "& svg": {
+ color: "rgba(0, 0, 0, 0.5)",
+ fontSize: "0.75rem",
+ },
+}));
export default Header;
diff --git a/apps/pyconkr/src/components/layout/LanguageSelector/index.tsx b/apps/pyconkr/src/components/layout/LanguageSelector/index.tsx
index 4693661..5535802 100644
--- a/apps/pyconkr/src/components/layout/LanguageSelector/index.tsx
+++ b/apps/pyconkr/src/components/layout/LanguageSelector/index.tsx
@@ -1,31 +1,33 @@
-import styled from "@emotion/styled";
import { Language } from "@mui/icons-material";
-import * as React from "react";
+import { Button, Stack, styled } from "@mui/material";
+
+import { LOCAL_STORAGE_LANGUAGE_KEY } from "../../../consts/local_stroage";
+import { useAppContext } from "../../../contexts/app_context";
export default function LanguageSelector() {
- const [selectedLang, setSelectedLang] = React.useState<"KO" | "EN">("KO");
+ const { language, setAppContext } = useAppContext();
+ const toggleLanguage = () => {
+ const newLanguage = language === "ko" ? "en" : "ko";
+ localStorage.setItem(LOCAL_STORAGE_LANGUAGE_KEY, newLanguage);
+ setAppContext((ps) => ({ ...ps, language: newLanguage }));
+ };
return (
-
- theme.palette.primary.nonFocus, w: 25, h: 25 }} />
- setSelectedLang("KO")}>
+
+ theme.palette.primary.nonFocus, w: "1.5rem", h: "1.5rem" }} />
+
KO
- setSelectedLang("EN")}>
+
EN
-
+
);
}
-const LanguageContainer = styled.div`
- display: flex;
- align-items: center;
- gap: 0.6rem;
-`;
-
-const LanguageItem = styled.div<{ isSelected: boolean }>`
- cursor: pointer;
- color: ${({ isSelected, theme }) => (isSelected ? theme.palette.primary.dark : theme.palette.primary.nonFocus)};
- transition: color 0.2s ease;
-`;
+const LanguageItem = styled(Button)<{ selected: boolean }>(({ selected, theme }) => ({
+ color: selected ? theme.palette.primary.dark : theme.palette.primary.nonFocus,
+ minWidth: 0,
+ padding: "0.375rem 0.25rem",
+ transition: "color 0.2s ease",
+}));
diff --git a/apps/pyconkr/src/components/layout/LoginButton/index.tsx b/apps/pyconkr/src/components/layout/LoginButton/index.tsx
index 7b8b17d..12e275d 100644
--- a/apps/pyconkr/src/components/layout/LoginButton/index.tsx
+++ b/apps/pyconkr/src/components/layout/LoginButton/index.tsx
@@ -1,16 +1,13 @@
-import styled from "@emotion/styled";
+import { Button } from "@mui/material";
+
+import { useAppContext } from "../../../contexts/app_context";
export default function LoginButton() {
- return 로그인;
-}
+ const { language } = useAppContext();
-const LoginButtonStyled = styled.button`
- background: none;
- border: none;
- color: ${({ theme }) => theme.palette.primary.dark};
- font-size: 0.875rem;
- font-weight: 500;
- cursor: pointer;
- padding: 0;
- transition: color 0.2s ease;
-`;
+ return (
+
+ );
+}
diff --git a/apps/pyconkr/src/components/layout/Nav/index.tsx b/apps/pyconkr/src/components/layout/Nav/index.tsx
deleted file mode 100644
index de139ce..0000000
--- a/apps/pyconkr/src/components/layout/Nav/index.tsx
+++ /dev/null
@@ -1,392 +0,0 @@
-import styled from "@emotion/styled";
-import { useState, useEffect, useRef, useMemo } from "react";
-
-import { useMenu } from "../../../hooks/useMenu";
-
-const menus = [
- {
- text: "파이콘 한국",
- subMenu: [
- { text: "파이콘 한국 소개", href: "/about" },
-
- { text: "파이콘 한국 2025", href: "/2025" },
- { text: "파이콘 한국 행동강령(CoC)", href: "/coc" },
- { text: "파이썬 사용자 모임", href: "/user-group" },
- {
- text: "역대 파이콘 행사",
- href: "/past-events",
- subMenu: [
- { text: "2025", href: "/2025" },
- { text: "2024", href: "/2024" },
- { text: "2023", href: "/2023" },
- { text: "2022", href: "/2022" },
- { text: "2021", href: "/2021" },
- { text: "2020", href: "/2020" },
- ],
- },
- { text: "파이콘 한국 건강 관련 안내", href: "/health" },
- ],
- },
- {
- text: "프로그램",
- subMenu: [
- { text: "튜토리얼", href: "/tutorial" },
- { text: "스프린트", href: "/sprint" },
- { text: "포스터 세션", href: "/poster" },
- ],
- },
- {
- text: "세션",
- subMenu: [
- { text: "세션 목록", href: "/sessions" },
- { text: "세션 시간표", href: "/schedule" },
- ],
- },
- {
- text: "구매",
- subMenu: [
- { text: "티켓 구매", href: "/tickets" },
- { text: "굿즈 구매", href: "/goods" },
- { text: "결제 내역", href: "/payments" },
- ],
- },
- {
- text: "후원하기",
- subMenu: [
- { text: "후원사 안내", href: "/sponsors" },
- { text: "개인 후원자", href: "/individual-sponsors" },
- ],
- },
-];
-
-export default function Nav() {
- const { hoveredMenu, focusedMenu, menuRefs, setHoveredMenu, setFocusedMenu, handleKeyDown, handleBlur } = useMenu();
-
- const [isSubMenuHovered, setIsSubMenuHovered] = useState(false);
- const [hoveredSubItem, setHoveredSubItem] = useState(null);
- const lastActiveMenuRef = useRef(null);
-
- useEffect(() => {
- if (hoveredMenu || focusedMenu) {
- lastActiveMenuRef.current = hoveredMenu || focusedMenu;
- }
- }, [hoveredMenu, focusedMenu]);
-
- const showSubmenu = !!hoveredMenu || !!focusedMenu || isSubMenuHovered;
- const activeMenu = hoveredMenu || focusedMenu || (isSubMenuHovered ? lastActiveMenuRef.current : null);
- const currentMenu = menus.find((menu) => menu.text === activeMenu);
-
- const hasActiveThirdLevel = useMemo(() => {
- if (!hoveredSubItem || !currentMenu) return false;
- const activeSubItem = currentMenu.subMenu.find((item) => item.text === hoveredSubItem);
- return activeSubItem?.subMenu && activeSubItem.subMenu.length > 0;
- }, [currentMenu, hoveredSubItem]);
-
- return (
- <>
-
-
- {menus.map((menu) => (
- {
- menuRefs.current[menu.text] = el;
- }}
- onMouseEnter={() => setHoveredMenu(menu.text)}
- onMouseLeave={() => setHoveredMenu(null)}
- onFocus={() => setFocusedMenu(menu.text)}
- onBlur={() => handleBlur(menu)}
- onKeyDown={(e) => handleKeyDown(e, menu)}
- tabIndex={0}
- >
- {menu.text}
-
- ))}
-
-
-
- {showSubmenu && currentMenu && (
- setIsSubMenuHovered(true)}
- onMouseLeave={() => {
- setIsSubMenuHovered(false);
- setHoveredSubItem(null);
- }}
- >
-
- {currentMenu.text}
-
-
-
- {currentMenu.subMenu.map((subItem) => {
- return (
- setHoveredSubItem(subItem.text)}
- className={hoveredSubItem === subItem.text ? "active" : ""}
- >
-
- {subItem.text}
-
-
- );
- })}
-
-
-
- {hasActiveThirdLevel && (
- <>
-
-
- {
- if (hoveredSubItem) {
- setHoveredSubItem(hoveredSubItem);
- }
- }}
- >
- {currentMenu.subMenu.map((subItem) => {
- const hasThirdLevel = subItem.subMenu && subItem.subMenu.length > 0;
- const isActive = hoveredSubItem === subItem.text;
-
- if (!hasThirdLevel || !isActive) return null;
-
- return (
-
- {subItem.subMenu!.map((thirdItem) => (
-
-
- {thirdItem.text}
-
-
- ))}
-
- );
- })}
-
- >
- )}
-
-
-
- )}
- >
- );
-}
-
-const NavMainContainer = styled.div`
- display: flex;
- align-items: center;
- position: relative;
- z-index: 1100;
-`;
-
-const NavSubContainer = styled.div`
- width: 100vw;
- height: auto;
- min-height: 150px;
- background-color: rgba(255, 255, 255, 0.7);
- background-image: linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.45));
- box-shadow: 0 1px 10px rgba(0, 0, 0, 0.1);
- position: fixed;
- left: 0;
- top: 60px;
- z-index: 1500;
- display: flex;
- padding-top: 34px;
- padding-bottom: 34px;
- overflow-y: auto;
-`;
-
-const SubMenuWrapper = styled.div`
- display: flex;
- flex-direction: column;
- max-width: 1200px;
- width: 100%;
- height: auto;
- padding-left: 114px;
- box-sizing: border-box;
-`;
-
-const CategoryTitle = styled.h3`
- font-size: 32px;
- font-weight: 700;
- color: ${({ theme }) => theme.palette.primary.dark};
- margin: 0;
- position: relative;
-
- &:after {
- content: "";
- position: absolute;
- left: 0;
- bottom: -15px;
- width: 53px;
- height: 5px;
- background-color: #ee8d74;
- }
-`;
-
-const HeaderNav = styled.ul`
- display: flex;
- align-items: center;
- gap: 2rem;
- font-size: 0.875rem;
- font-weight: 500;
- position: relative;
- list-style: none;
-
- li {
- position: relative;
- cursor: pointer;
- outline: none;
- padding: 20px 10px 30px 10px;
- margin: -20px -10px -30px -10px;
-
- &:focus {
- outline: 2px solid ${({ theme }) => theme.palette.primary.main};
- outline-offset: 1px;
- }
-
- &::after {
- content: "";
- position: absolute;
- top: 100%;
- left: 0;
- width: 100%;
- background: transparent;
- }
- }
-`;
-
-const SubMenuContent = styled.div`
- display: flex;
- margin-top: 25px;
- width: 100%;
- align-items: flex-start;
- height: auto;
-`;
-
-const SecondLevelContainer = styled.div`
- height: auto;
- overflow: visible;
- display: flex;
- flex-direction: column;
- flex-wrap: nowrap;
- min-width: 220px;
- width: 220px;
- padding-right: 30px;
-`;
-
-const SecondLevelList = styled.ul`
- display: flex;
- flex-direction: column;
- flex-wrap: nowrap;
- gap: 13px;
- list-style: none;
- padding: 0;
- margin: 0;
- width: 100%;
-`;
-
-const SecondLevelItem = styled.li`
- padding: 0;
- position: relative;
- width: 100%;
- text-align: left;
-
- &.active a {
- font-weight: 700;
-
- &:after {
- content: "";
- position: absolute;
- left: 0;
- bottom: -3px;
- width: 100%;
- height: 2px;
- background-color: ${({ theme }) => theme.palette.primary.dark};
- }
- }
-
- a {
- color: ${({ theme }) => theme.palette.primary.dark};
- text-decoration: none;
- font-size: 15px;
- font-weight: 300;
- display: inline-block;
- outline: none;
- position: relative;
- white-space: nowrap;
-
- &:hover,
- &:focus {
- font-weight: 700;
-
- &:after {
- content: "";
- position: absolute;
- left: 0;
- bottom: -3px;
- width: 100%;
- height: 2px;
- background-color: ${({ theme }) => theme.palette.primary.dark};
- }
- }
-
- &:focus {
- outline: none;
- }
- }
-`;
-
-const ThirdLevelDivider = styled.div`
- width: 1px;
- height: auto;
- background-color: ${({ theme }) => theme.palette.primary.light};
- margin: 0;
- flex-shrink: 0;
- align-self: stretch;
-`;
-
-const ThirdLevelSection = styled.div`
- height: 100%;
- display: flex;
- flex-direction: column;
- justify-content: center;
- min-width: 220px;
- padding-left: 30px;
-`;
-
-const ThirdLevelList = styled.ul`
- list-style: none;
- padding: 0;
- margin: 0;
- display: flex;
- flex-direction: column;
- flex-wrap: wrap;
- gap: 8px;
- height: auto;
- max-height: 160px;
- column-gap: 30px;
- width: 100%;
-`;
-
-const ThirdLevelItem = styled.li`
- width: auto;
- text-align: right;
- min-width: 40px;
-
- a {
- color: ${({ theme }) => theme.palette.primary.dark};
- text-decoration: none;
- font-size: 10px;
- display: inline-block;
- white-space: nowrap;
- font-variation-settings: "wght" 400;
-
- &:hover,
- &:focus {
- font-weight: 700;
- }
- }
-`;
diff --git a/apps/pyconkr/src/components/layout/Sponsor/index.tsx b/apps/pyconkr/src/components/layout/Sponsor/index.tsx
index 5c9437d..c98904f 100644
--- a/apps/pyconkr/src/components/layout/Sponsor/index.tsx
+++ b/apps/pyconkr/src/components/layout/Sponsor/index.tsx
@@ -2,7 +2,6 @@ import styled from "@emotion/styled";
import { useEffect, useState } from "react";
import SponsorExample from "../../../assets/sponsorExample.svg?react";
-import { useSponsor } from "../../../contexts/SponsorContext";
interface Sponsor {
id: number;
@@ -12,7 +11,6 @@ interface Sponsor {
export default function Sponsor() {
const [sponsors, setSponsors] = useState([]);
- const { isVisible } = useSponsor();
// 16개의 임시 스폰서 데이터 생성
useEffect(() => {
@@ -31,8 +29,6 @@ export default function Sponsor() {
fetchSponsors();
}, []);
- if (!isVisible) return null;
-
return (
diff --git a/apps/pyconkr/src/components/layout/index.tsx b/apps/pyconkr/src/components/layout/index.tsx
index ede2244..8cfb398 100644
--- a/apps/pyconkr/src/components/layout/index.tsx
+++ b/apps/pyconkr/src/components/layout/index.tsx
@@ -1,27 +1,25 @@
import styled from "@emotion/styled";
+import { Stack } from "@mui/material";
import { Outlet } from "react-router-dom";
import Footer from "./Footer";
import Header from "./Header";
import Sponsor from "./Sponsor";
+import { useAppContext } from "../../contexts/app_context";
export default function MainLayout() {
+ const { shouldShowSponsorBanner } = useAppContext();
return (
-
+
-
+ {shouldShowSponsorBanner && }
-
+
);
}
-const LayoutContainer = styled.div`
- display: flex;
- flex-direction: column;
- min-height: 100vh;
-`;
const MainContent = styled.main`
flex: 1;
diff --git a/apps/pyconkr/src/components/pages/dynamic_route.tsx b/apps/pyconkr/src/components/pages/dynamic_route.tsx
new file mode 100644
index 0000000..2c036b2
--- /dev/null
+++ b/apps/pyconkr/src/components/pages/dynamic_route.tsx
@@ -0,0 +1,138 @@
+import * as Common from "@frontend/common";
+import { CircularProgress, Stack, Theme } from "@mui/material";
+import { ErrorBoundary, Suspense } from "@suspensive/react";
+import { AxiosError, AxiosResponse } from "axios";
+import * as React from "react";
+import { useLocation, useParams } from "react-router-dom";
+import * as R from "remeda";
+
+import { useAppContext } from "../../contexts/app_context";
+
+const initialPageStyle: (additionalStyle: React.CSSProperties) => (theme: Theme) => React.CSSProperties =
+ (additionalStyle) => (theme) => ({
+ width: "100%",
+ display: "flex",
+ justifyContent: "flex-start",
+ alignItems: "center",
+ flexDirection: "column",
+
+ marginTop: theme.spacing(4),
+
+ ...(!R.isEmpty(additionalStyle)
+ ? additionalStyle
+ : {
+ [theme.breakpoints.down("lg")]: {
+ marginTop: theme.spacing(2),
+ },
+ [theme.breakpoints.down("sm")]: {
+ marginTop: theme.spacing(1),
+ },
+ }),
+ });
+
+const initialSectionStyle: (additionalStyle: React.CSSProperties) => (theme: Theme) => React.CSSProperties =
+ (additionalStyle) => (theme) => ({
+ width: "100%",
+ maxWidth: "1200px",
+ display: "flex",
+ justifyContent: "flex-start",
+ alignItems: "center",
+ paddingRight: theme.spacing(16),
+ paddingLeft: theme.spacing(16),
+
+ "& .markdown-body": { width: "100%" },
+ ...(!R.isEmpty(additionalStyle)
+ ? additionalStyle
+ : {
+ [theme.breakpoints.down("lg")]: {
+ paddingRight: theme.spacing(4),
+ paddingLeft: theme.spacing(4),
+ },
+ [theme.breakpoints.down("sm")]: {
+ paddingRight: theme.spacing(2),
+ paddingLeft: theme.spacing(2),
+ },
+ }),
+ });
+
+const LoginRequired: React.FC = () => <>401 Login Required>;
+const PermissionDenied: React.FC = () => <>403 Permission Denied>;
+const PageNotFound: React.FC = () => <>404 Not Found>;
+const CenteredLoadingPage: React.FC = () => (
+
+
+
+);
+
+const throwPageNotFound: (message: string) => never = (message) => {
+ const errorStr = `RouteRenderer: ${message}`;
+ const axiosError = new AxiosError(errorStr, errorStr, undefined, undefined, {
+ status: 404,
+ } as AxiosResponse);
+ throw new Common.BackendAPIs.BackendAPIClientError(axiosError);
+};
+
+const RouteErrorFallback: React.FC<{ error: Error; reset: () => void }> = ({ error, reset }) => {
+ if (error instanceof Common.BackendAPIs.BackendAPIClientError) {
+ switch (error.status) {
+ case 401:
+ return ;
+ case 403:
+ return ;
+ case 404:
+ return ;
+ default:
+ return ;
+ }
+ }
+ return ;
+};
+
+export const PageRenderer: React.FC<{ id: string }> = ErrorBoundary.with(
+ { fallback: RouteErrorFallback },
+ Suspense.with({ fallback: }, ({ id }) => {
+ const { setAppContext } = useAppContext();
+ const backendClient = Common.Hooks.BackendAPI.useBackendClient();
+ const { data } = Common.Hooks.BackendAPI.usePageQuery(backendClient, id);
+
+ React.useEffect(() => {
+ setAppContext((prev) => ({
+ ...prev,
+ title: data.title,
+ shouldShowTitleBanner: data.show_top_title_banner,
+ shouldShowSponsorBanner: data.show_bottom_sponsor_banner,
+ }));
+ }, [data, setAppContext]);
+
+ return (
+
+ {data.sections.map((s) => (
+
+
+
+ ))}
+
+ );
+ })
+);
+
+export const RouteRenderer: React.FC = ErrorBoundary.with(
+ { fallback: RouteErrorFallback },
+ Suspense.with({ fallback: }, () => {
+ const location = useLocation();
+ const { siteMapNode, currentSiteMapDepth } = useAppContext();
+
+ if (!siteMapNode) return ;
+
+ const routeInfo = !R.isEmpty(currentSiteMapDepth) && currentSiteMapDepth[currentSiteMapDepth.length - 1];
+ if (!routeInfo) throwPageNotFound(`Route ${location} not found`);
+
+ return ;
+ })
+);
+
+export const PageIdParamRenderer: React.FC = Suspense.with({ fallback: }, () => {
+ const { id } = useParams();
+ if (!id) throwPageNotFound("Page ID is required");
+ return ;
+});
diff --git a/apps/pyconkr/src/consts/local_stroage.ts b/apps/pyconkr/src/consts/local_stroage.ts
new file mode 100644
index 0000000..d2e88da
--- /dev/null
+++ b/apps/pyconkr/src/consts/local_stroage.ts
@@ -0,0 +1 @@
+export const LOCAL_STORAGE_LANGUAGE_KEY = "language";
diff --git a/apps/pyconkr/src/contexts/SponsorContext.tsx b/apps/pyconkr/src/contexts/SponsorContext.tsx
deleted file mode 100644
index f7ada60..0000000
--- a/apps/pyconkr/src/contexts/SponsorContext.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { createContext, useContext, useState, ReactNode } from "react";
-
-interface SponsorContextType {
- isVisible: boolean;
- setIsVisible: (value: boolean) => void;
-}
-
-const SponsorContext = createContext(undefined);
-
-interface SponsorProviderProps {
- children: ReactNode;
- initialVisibility?: boolean;
-}
-
-export function SponsorProvider({ children, initialVisibility = false }: SponsorProviderProps) {
- const [isVisible, setIsVisible] = useState(initialVisibility);
-
- return {children};
-}
-
-export function useSponsor() {
- const context = useContext(SponsorContext);
- if (context === undefined) {
- throw new Error("useSponsor must be used within a SponsorProvider");
- }
- return context;
-}
diff --git a/apps/pyconkr/src/contexts/app_context.tsx b/apps/pyconkr/src/contexts/app_context.tsx
new file mode 100644
index 0000000..bbf4500
--- /dev/null
+++ b/apps/pyconkr/src/contexts/app_context.tsx
@@ -0,0 +1,28 @@
+import * as React from "react";
+
+import BackendAPISchemas from "../../../../packages/common/src/schemas/backendAPI";
+
+type LanguageType = "ko" | "en";
+
+export type AppContextType = {
+ language: LanguageType;
+ shouldShowTitleBanner: boolean;
+ shouldShowSponsorBanner: boolean;
+
+ siteMapNode?: BackendAPISchemas.NestedSiteMapSchema;
+ sponsors: unknown;
+ title: string;
+ currentSiteMapDepth: (BackendAPISchemas.NestedSiteMapSchema | undefined)[];
+
+ setAppContext: React.Dispatch>>;
+};
+
+export const AppContext = React.createContext(undefined);
+
+export const useAppContext = (): AppContextType => {
+ const context = React.useContext(AppContext);
+ if (!context) {
+ throw new Error("useAppContext must be used within an AppContextProvider");
+ }
+ return context;
+};
diff --git a/apps/pyconkr/src/debug/page/backend_test.tsx b/apps/pyconkr/src/debug/page/backend_test.tsx
index a9281b5..482fcfb 100644
--- a/apps/pyconkr/src/debug/page/backend_test.tsx
+++ b/apps/pyconkr/src/debug/page/backend_test.tsx
@@ -3,6 +3,8 @@ import { CircularProgress, MenuItem, Select, SelectProps, Stack } from "@mui/mat
import { Suspense } from "@suspensive/react";
import * as React from "react";
+import { PageRenderer } from "../../components/pages/dynamic_route";
+
const SiteMapRenderer: React.FC = Suspense.with({ fallback: }, () => {
const backendClient = Common.Hooks.BackendAPI.useBackendClient();
const { data } = Common.Hooks.BackendAPI.useFlattenSiteMapQuery(backendClient);
@@ -34,11 +36,7 @@ export const BackendTestPage: React.FC = () => {
setPageId(e.target.value as string)} />
- {Common.Utils.isFilledString(pageId) ? (
-
- ) : (
- <>페이지를 선택해주세요.>
- )}
+ {Common.Utils.isFilledString(pageId) ? : <>페이지를 선택해주세요.>}
);
};
diff --git a/apps/pyconkr/src/debug/page/map_test.tsx b/apps/pyconkr/src/debug/page/map_test.tsx
index 603eb03..0c952a1 100644
--- a/apps/pyconkr/src/debug/page/map_test.tsx
+++ b/apps/pyconkr/src/debug/page/map_test.tsx
@@ -8,7 +8,6 @@ type MapTestPageStateType = {
};
const INITIAL_DATA: Common.Components.MDX.MapPropType = {
- language: "ko",
geo: { lat: 37.5580918, lng: 126.9982178 },
placeName: {
ko: "동국대학교 신공학관",
diff --git a/apps/pyconkr/src/hooks/useMenu.ts b/apps/pyconkr/src/hooks/useMenu.ts
deleted file mode 100644
index b3b6348..0000000
--- a/apps/pyconkr/src/hooks/useMenu.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { useState, useRef, useEffect } from "react";
-
-interface MenuItem {
- text: string;
- href?: string;
- subMenu?: {
- text: string;
- href: string;
- }[];
-}
-
-export const useMenu = () => {
- const [hoveredMenu, setHoveredMenu] = useState(null);
- const [focusedMenu, setFocusedMenu] = useState(null);
- const menuRefs = useRef<{ [key: string]: HTMLLIElement | null }>({});
-
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (focusedMenu && !menuRefs.current[focusedMenu]?.contains(event.target as Node)) {
- setFocusedMenu(null);
- }
- };
-
- document.addEventListener("mousedown", handleClickOutside);
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, [focusedMenu]);
-
- const handleKeyDown = (e: React.KeyboardEvent, menu: MenuItem) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- setFocusedMenu(menu.text);
- }
- };
-
- const handleBlur = (menu: MenuItem) => {
- setTimeout(() => {
- if (!menuRefs.current[menu.text]?.contains(document.activeElement)) {
- setFocusedMenu(null);
- }
- }, 0);
- };
-
- return {
- hoveredMenu,
- focusedMenu,
- menuRefs,
- setHoveredMenu,
- setFocusedMenu,
- handleKeyDown,
- handleBlur,
- };
-};
diff --git a/apps/pyconkr/src/main.tsx b/apps/pyconkr/src/main.tsx
index 560f5ba..0dba916 100644
--- a/apps/pyconkr/src/main.tsx
+++ b/apps/pyconkr/src/main.tsx
@@ -8,10 +8,13 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { SnackbarProvider } from "notistack";
import * as React from "react";
import * as ReactDom from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
import { App } from "./App.tsx";
import { IS_DEBUG_ENV } from "./consts";
+import { LOCAL_STORAGE_LANGUAGE_KEY } from "./consts/local_stroage.ts";
import { PyConKRMDXComponents } from "./consts/mdx_components.ts";
+import { AppContext, AppContextType } from "./contexts/app_context.tsx";
import { globalStyles, muiTheme } from "./styles/globalStyles.ts";
const queryClient = new QueryClient({
@@ -32,6 +35,7 @@ const queryClient = new QueryClient({
});
const CommonOptions: Common.Contexts.ContextOptions = {
+ language: "ko",
debug: IS_DEBUG_ENV,
baseUrl: ".",
backendApiDomain: import.meta.env.VITE_PYCONKR_BACKEND_API_DOMAIN,
@@ -46,37 +50,50 @@ const ShopOptions: Shop.Contexts.ContextOptions = {
shopImpAccountId: import.meta.env.VITE_PYCONKR_SHOP_IMP_ACCOUNT_ID,
};
-ReactDom.createRoot(document.getElementById("root")!).render(
-
-
-
-
-
-
-
-
-
-
- 문제가 발생했습니다, 새로고침을 해주세요.
-
- }
- >
-
-
-
- }
- >
-
-
-
-
-
-
-
-
-
+const SuspenseFallback = (
+
+
+
);
+
+const MainApp: React.FC = () => {
+ const [appState, setAppContext] = React.useState>({
+ language: (localStorage.getItem(LOCAL_STORAGE_LANGUAGE_KEY) as "ko" | "en" | null) ?? "ko",
+ shouldShowTitleBanner: true,
+ shouldShowSponsorBanner: false,
+
+ currentSiteMapDepth: [],
+
+ sponsors: null,
+ title: "PyCon Korea 2025",
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+ReactDom.createRoot(document.getElementById("root")!).render();
diff --git a/apps/pyconkr/src/styles/globalStyles.ts b/apps/pyconkr/src/styles/globalStyles.ts
index b0de463..f90c19e 100644
--- a/apps/pyconkr/src/styles/globalStyles.ts
+++ b/apps/pyconkr/src/styles/globalStyles.ts
@@ -97,6 +97,8 @@ export const globalStyles = css`
-moz-user-drag: none;
-o-user-drag: none;
user-drag: none;
+
+ font-variant-numeric: tabular-nums;
}
a {
diff --git a/package.json b/package.json
index 12edad0..fd9796b 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"@mdx-js/rollup": "^3.1.0",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
+ "@mui/system": "^7.1.1",
"@rjsf/core": "6.0.0-beta.10",
"@rjsf/mui": "6.0.0-beta.10",
"@rjsf/utils": "6.0.0-beta.10",
diff --git a/packages/common/src/apis/client.ts b/packages/common/src/apis/client.ts
index f8a79df..c54aea3 100644
--- a/packages/common/src/apis/client.ts
+++ b/packages/common/src/apis/client.ts
@@ -59,6 +59,8 @@ export class BackendAPIClientError extends Error {
}
}
+type supportedLanguages = "ko" | "en";
+
type AxiosRequestWithoutPayload = , D = unknown>(
url: string,
config?: AxiosRequestConfig
@@ -70,6 +72,7 @@ type AxiosRequestWithPayload = , D = unknown>(
) => Promise;
export class BackendAPIClient {
+ readonly language: supportedLanguages;
readonly baseURL: string;
protected readonly csrfCookieName: string;
private readonly backendAPI: AxiosInstance;
@@ -78,9 +81,14 @@ export class BackendAPIClient {
baseURL: string,
timeout: number,
csrfCookieName: string = "csrftoken",
- withCredentials: boolean = false
+ withCredentials: boolean = false,
+ language: supportedLanguages = "ko"
) {
- const headers = { "Content-Type": "application/json" };
+ const headers = {
+ "Content-Type": "application/json",
+ "Accept-Language": language,
+ };
+ this.language = language;
this.baseURL = baseURL;
this.csrfCookieName = csrfCookieName;
this.backendAPI = axios.create({
diff --git a/packages/common/src/apis/index.ts b/packages/common/src/apis/index.ts
index cca823d..3f679b6 100644
--- a/packages/common/src/apis/index.ts
+++ b/packages/common/src/apis/index.ts
@@ -1,7 +1,8 @@
-import { BackendAPIClient } from "./client";
+import { BackendAPIClient, BackendAPIClientError as _BackendAPIClientError } from "./client";
import BackendAPISchemas from "../schemas/backendAPI";
namespace BackendAPIs {
+ export const BackendAPIClientError = _BackendAPIClientError;
export const listSiteMaps = (client: BackendAPIClient) => () =>
client.get("v1/cms/sitemap/");
export const retrievePage = (client: BackendAPIClient) => (id: string) =>
diff --git a/packages/common/src/components/centered_page.tsx b/packages/common/src/components/centered_page.tsx
index 40b934a..3486298 100644
--- a/packages/common/src/components/centered_page.tsx
+++ b/packages/common/src/components/centered_page.tsx
@@ -2,18 +2,7 @@ import { Stack } from "@mui/material";
import * as React from "react";
export const CenteredPage: React.FC = ({ children }) => (
-
+
{children}
);
diff --git a/packages/common/src/components/dynamic_route.tsx b/packages/common/src/components/dynamic_route.tsx
deleted file mode 100644
index 1ab3397..0000000
--- a/packages/common/src/components/dynamic_route.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-import { CircularProgress, Stack, Theme } from "@mui/material";
-import { ErrorBoundary, Suspense } from "@suspensive/react";
-import { AxiosError, AxiosResponse } from "axios";
-import * as React from "react";
-import { useLocation, useParams } from "react-router-dom";
-import * as R from "remeda";
-
-import { BackendAPIClientError } from "../apis/client";
-import Hooks from "../hooks";
-import BackendAPISchemas from "../schemas/backendAPI";
-import Utils from "../utils";
-import { ErrorFallback } from "./error_handler";
-import { MDXRenderer } from "./mdx";
-
-const initialPageStyle: (additionalStyle: React.CSSProperties) => (theme: Theme) => React.CSSProperties =
- (additionalStyle) => (theme) => ({
- width: "100%",
- display: "flex",
- justifyContent: "flex-start",
- alignItems: "center",
- flexDirection: "column",
-
- marginTop: theme.spacing(8),
-
- ...(additionalStyle
- ? additionalStyle
- : {
- [theme.breakpoints.down("md")]: {
- marginTop: theme.spacing(4),
- },
- [theme.breakpoints.down("sm")]: {
- marginTop: theme.spacing(2),
- },
- }),
- ...additionalStyle,
- });
-
-const initialSectionStyle: (additionalStyle: React.CSSProperties) => (theme: Theme) => React.CSSProperties =
- (additionalStyle) => (theme) => ({
- width: "100%",
- maxWidth: "1000px",
- display: "flex",
- justifyContent: "flex-start",
- alignItems: "center",
- paddingRight: "2rem",
- paddingLeft: "2rem",
-
- "& .markdown-body": { width: "100%" },
- ...(additionalStyle
- ? additionalStyle
- : {
- [theme.breakpoints.down("md")]: {
- paddingRight: "1rem",
- paddingLeft: "1rem",
- },
- [theme.breakpoints.down("sm")]: {
- paddingRight: "0.5rem",
- paddingLeft: "0.5rem",
- },
- }),
- });
-
-const LoginRequired: React.FC = () => <>401 Login Required>;
-const PermissionDenied: React.FC = () => <>403 Permission Denied>;
-const PageNotFound: React.FC = () => <>404 Not Found>;
-
-const throwPageNotFound: (message: string) => never = (message) => {
- const errorStr = `RouteRenderer: ${message}`;
- const axiosError = new AxiosError(errorStr, errorStr, undefined, undefined, {
- status: 404,
- } as AxiosResponse);
- throw new BackendAPIClientError(axiosError);
-};
-
-const RouteErrorFallback: React.FC<{ error: Error; reset: () => void }> = ({ error, reset }) => {
- if (error instanceof BackendAPIClientError) {
- switch (error.status) {
- case 401:
- return ;
- case 403:
- return ;
- case 404:
- return ;
- default:
- return ;
- }
- }
- return ;
-};
-
-export const PageRenderer: React.FC<{ id?: string }> = ErrorBoundary.with(
- { fallback: RouteErrorFallback },
- Suspense.with({ fallback: }, ({ id }) => {
- const backendClient = Hooks.BackendAPI.useBackendClient();
- const { data } = Hooks.BackendAPI.usePageQuery(backendClient, id || "");
-
- return (
-
- {data.sections.map((s) => (
-
-
-
- ))}
-
- );
- })
-);
-
-export const RouteRenderer: React.FC = ErrorBoundary.with(
- { fallback: RouteErrorFallback },
- Suspense.with({ fallback: }, () => {
- const location = useLocation();
-
- const backendClient = Hooks.BackendAPI.useBackendClient();
- const { data } = Hooks.BackendAPI.useFlattenSiteMapQuery(backendClient);
- const nestedSiteMap = Utils.buildNestedSiteMap(data);
-
- const currentRouteCodes = ["", ...location.pathname.split("/").filter((code) => !R.isEmpty(code))];
- let currentSitemap: BackendAPISchemas.NestedSiteMapSchema | undefined = nestedSiteMap[currentRouteCodes[0]];
- if (currentSitemap === undefined) throwPageNotFound(`Route ${location} not found`);
-
- for (const routeCode of currentRouteCodes.slice(1))
- if ((currentSitemap = currentSitemap.children[routeCode]) === undefined)
- throwPageNotFound(`Route ${location} not found`);
-
- return ;
- })
-);
-
-export const PageIdParamRenderer: React.FC = Suspense.with({ fallback: }, () => {
- const { id } = useParams();
- return ;
-});
diff --git a/packages/common/src/components/index.ts b/packages/common/src/components/index.ts
index b409d89..257b7f4 100644
--- a/packages/common/src/components/index.ts
+++ b/packages/common/src/components/index.ts
@@ -1,10 +1,5 @@
import { CenteredPage as CenteredPageComponent } from "./centered_page";
import { CommonContextProvider as CommonContextProviderComponent } from "./common_context";
-import {
- PageIdParamRenderer as PageIdParamRendererComponent,
- PageRenderer as PageRendererComponent,
- RouteRenderer as RouteRendererComponent,
-} from "./dynamic_route";
import { ErrorFallback as ErrorFallbackComponent } from "./error_handler";
import {
LottieDebugPanel as LottieDebugPanelComponent,
@@ -24,9 +19,6 @@ import { PythonKorea as PythonKoreaComponent } from "./pythonkorea";
namespace Components {
export const CenteredPage = CenteredPageComponent;
export const CommonContextProvider = CommonContextProviderComponent;
- export const RouteRenderer = RouteRendererComponent;
- export const PageRenderer = PageRendererComponent;
- export const PageIdParamRenderer = PageIdParamRendererComponent;
export const MDXEditor = MDXEditorComponent;
export const MDXRenderer = MDXRendererComponent;
export const PythonKorea = PythonKoreaComponent;
diff --git a/packages/common/src/components/mdx_components/map.tsx b/packages/common/src/components/mdx_components/map.tsx
index 486926a..89fa785 100644
--- a/packages/common/src/components/mdx_components/map.tsx
+++ b/packages/common/src/components/mdx_components/map.tsx
@@ -2,13 +2,14 @@ import { Box, Button, Stack, Tab, Tabs } from "@mui/material";
import * as React from "react";
import { renderToStaticMarkup } from "react-dom/server";
+import { useCommonContext } from "../../hooks/useCommonContext";
+
type SupportedMapType = "kakao" | "google" | "naver";
const MAP_TYPES: SupportedMapType[] = ["kakao", "google", "naver"];
type LangType = "ko" | "en";
export type MapPropType = {
- language: LangType;
geo: {
lat: number;
lng: number;
@@ -54,7 +55,8 @@ const MapData: { [key in SupportedMapType]: MapDataType } = {
},
};
-export const Map: React.FC = ({ language, geo, placeName, placeCode, googleMapIframeSrc }) => {
+export const Map: React.FC = ({ geo, placeName, placeCode, googleMapIframeSrc }) => {
+ const { language } = useCommonContext();
const kakaoMapRef = React.useRef(null);
const [mapState, setMapState] = React.useState({ tab: 0 });
const selectedMapType = MAP_TYPES[mapState.tab] || "kakao";
diff --git a/packages/common/src/contexts/index.ts b/packages/common/src/contexts/index.ts
index ad75691..69ffc1d 100644
--- a/packages/common/src/contexts/index.ts
+++ b/packages/common/src/contexts/index.ts
@@ -3,6 +3,7 @@ import * as React from "react";
namespace GlobalContext {
export type ContextOptions = {
+ language: "ko" | "en";
frontendDomain?: string;
baseUrl: string;
debug?: boolean;
@@ -13,6 +14,7 @@ namespace GlobalContext {
};
export const context = React.createContext({
+ language: "ko",
frontendDomain: "",
baseUrl: "",
debug: false,
diff --git a/packages/common/src/hooks/useAPI.ts b/packages/common/src/hooks/useAPI.ts
index 974eba1..1c0fa59 100644
--- a/packages/common/src/hooks/useAPI.ts
+++ b/packages/common/src/hooks/useAPI.ts
@@ -18,19 +18,19 @@ namespace BackendAPIHooks {
};
export const useBackendClient = () => {
- const { backendApiDomain, backendApiTimeout } = useBackendContext();
- return new BackendAPIClient(backendApiDomain, backendApiTimeout);
+ const { language, backendApiDomain, backendApiTimeout } = useBackendContext();
+ return new BackendAPIClient(backendApiDomain, backendApiTimeout, "", false, language);
};
export const useFlattenSiteMapQuery = (client: BackendAPIClient) =>
useSuspenseQuery({
- queryKey: QUERY_KEYS.SITEMAP_LIST,
+ queryKey: [client.language, ...QUERY_KEYS.SITEMAP_LIST],
queryFn: BackendAPIs.listSiteMaps(client),
});
export const usePageQuery = (client: BackendAPIClient, id: string) =>
useSuspenseQuery({
- queryKey: [...QUERY_KEYS.PAGE, id],
+ queryKey: [client.language, ...QUERY_KEYS.PAGE, id],
queryFn: () => BackendAPIs.retrievePage(client)(id),
});
}
diff --git a/packages/common/src/schemas/backendAPI.ts b/packages/common/src/schemas/backendAPI.ts
index 9f3b1f8..22797cc 100644
--- a/packages/common/src/schemas/backendAPI.ts
+++ b/packages/common/src/schemas/backendAPI.ts
@@ -45,6 +45,10 @@ namespace BackendAPISchemas {
css: string;
title: string;
subtitle: string;
+
+ show_top_title_banner: boolean;
+ show_bottom_sponsor_banner: boolean;
+
sections: SectionSchema[];
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cfc5976..691ea8d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,6 +29,9 @@ importers:
'@mui/material':
specifier: ^7.1.0
version: 7.1.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@mui/system':
+ specifier: ^7.1.1
+ version: 7.1.1(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0)
'@rjsf/core':
specifier: 6.0.0-beta.10
version: 6.0.0-beta.10(@rjsf/utils@6.0.0-beta.10(react@19.1.0))(react@19.1.0)
@@ -695,8 +698,8 @@ packages:
'@types/react':
optional: true
- '@mui/private-theming@7.1.0':
- resolution: {integrity: sha512-4Kck4jxhqF6YxNwJdSae1WgDfXVg0lIH6JVJ7gtuFfuKcQCgomJxPvUEOySTFRPz1IZzwz5OAcToskRdffElDA==}
+ '@mui/private-theming@7.1.1':
+ resolution: {integrity: sha512-M8NbLUx+armk2ZuaxBkkMk11ultnWmrPlN0Xe3jUEaBChg/mcxa5HWIWS1EE4DF36WRACaAHVAvyekWlDQf0PQ==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -705,8 +708,8 @@ packages:
'@types/react':
optional: true
- '@mui/styled-engine@7.1.0':
- resolution: {integrity: sha512-m0mJ0c6iRC+f9hMeRe0W7zZX1wme3oUX0+XTVHjPG7DJz6OdQ6K/ggEOq7ZdwilcpdsDUwwMfOmvO71qDkYd2w==}
+ '@mui/styled-engine@7.1.1':
+ resolution: {integrity: sha512-R2wpzmSN127j26HrCPYVQ53vvMcT5DaKLoWkrfwUYq3cYytL6TQrCH8JBH3z79B6g4nMZZVoaXrxO757AlShaw==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.4.1
@@ -718,8 +721,8 @@ packages:
'@emotion/styled':
optional: true
- '@mui/system@7.1.0':
- resolution: {integrity: sha512-iedAWgRJMCxeMHvkEhsDlbvkK+qKf9me6ofsf7twk/jfT4P1ImVf7Rwb5VubEA0sikrVL+1SkoZM41M4+LNAVA==}
+ '@mui/system@7.1.1':
+ resolution: {integrity: sha512-Kj1uhiqnj4Zo7PDjAOghtXJtNABunWvhcRU0O7RQJ7WOxeynoH6wXPcilphV8QTFtkKaip8EiNJRiCD+B3eROA==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@emotion/react': ^11.5.0
@@ -742,6 +745,14 @@ packages:
'@types/react':
optional: true
+ '@mui/types@7.4.3':
+ resolution: {integrity: sha512-2UCEiK29vtiZTeLdS2d4GndBKacVyxGvReznGXGr+CzW/YhjIX+OHUdCIczZjzcRAgKBGmE9zCIgoV9FleuyRQ==}
+ peerDependencies:
+ '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@mui/utils@7.1.0':
resolution: {integrity: sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==}
engines: {node: '>=14.0.0'}
@@ -752,6 +763,16 @@ packages:
'@types/react':
optional: true
+ '@mui/utils@7.1.1':
+ resolution: {integrity: sha512-BkOt2q7MBYl7pweY2JWwfrlahhp+uGLR8S+EhiyRaofeRYUWL2YKbSGQvN4hgSN1i8poN0PaUiii1kEMrchvzg==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@napi-rs/wasm-runtime@0.2.10':
resolution: {integrity: sha512-bCsCyeZEwVErsGmyPNSzwfwFn4OdxBj0mmv6hOFucB/k81Ojdu68RbZdxYsRQUPc9l6SU5F/cG+bXgWs3oUgsQ==}
@@ -4125,7 +4146,7 @@ snapshots:
dependencies:
'@babel/runtime': 7.27.1
'@mui/core-downloads-tracker': 7.1.0
- '@mui/system': 7.1.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0)
+ '@mui/system': 7.1.1(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0)
'@mui/types': 7.4.2(@types/react@19.1.4)
'@mui/utils': 7.1.0(@types/react@19.1.4)(react@19.1.0)
'@popperjs/core': 2.11.8
@@ -4142,16 +4163,16 @@ snapshots:
'@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0)
'@types/react': 19.1.4
- '@mui/private-theming@7.1.0(@types/react@19.1.4)(react@19.1.0)':
+ '@mui/private-theming@7.1.1(@types/react@19.1.4)(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.1
- '@mui/utils': 7.1.0(@types/react@19.1.4)(react@19.1.0)
+ '@mui/utils': 7.1.1(@types/react@19.1.4)(react@19.1.0)
prop-types: 15.8.1
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.4
- '@mui/styled-engine@7.1.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(react@19.1.0)':
+ '@mui/styled-engine@7.1.1(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.1
'@emotion/cache': 11.14.0
@@ -4164,13 +4185,13 @@ snapshots:
'@emotion/react': 11.14.0(@types/react@19.1.4)(react@19.1.0)
'@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0)
- '@mui/system@7.1.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0)':
+ '@mui/system@7.1.1(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.1
- '@mui/private-theming': 7.1.0(@types/react@19.1.4)(react@19.1.0)
- '@mui/styled-engine': 7.1.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(react@19.1.0)
- '@mui/types': 7.4.2(@types/react@19.1.4)
- '@mui/utils': 7.1.0(@types/react@19.1.4)(react@19.1.0)
+ '@mui/private-theming': 7.1.1(@types/react@19.1.4)(react@19.1.0)
+ '@mui/styled-engine': 7.1.1(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.4)(react@19.1.0))(@types/react@19.1.4)(react@19.1.0))(react@19.1.0)
+ '@mui/types': 7.4.3(@types/react@19.1.4)
+ '@mui/utils': 7.1.1(@types/react@19.1.4)(react@19.1.0)
clsx: 2.1.1
csstype: 3.1.3
prop-types: 15.8.1
@@ -4186,6 +4207,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.4
+ '@mui/types@7.4.3(@types/react@19.1.4)':
+ dependencies:
+ '@babel/runtime': 7.27.1
+ optionalDependencies:
+ '@types/react': 19.1.4
+
'@mui/utils@7.1.0(@types/react@19.1.4)(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.1
@@ -4198,6 +4225,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.4
+ '@mui/utils@7.1.1(@types/react@19.1.4)(react@19.1.0)':
+ dependencies:
+ '@babel/runtime': 7.27.1
+ '@mui/types': 7.4.3(@types/react@19.1.4)
+ '@types/prop-types': 15.7.14
+ clsx: 2.1.1
+ prop-types: 15.8.1
+ react: 19.1.0
+ react-is: 19.1.0
+ optionalDependencies:
+ '@types/react': 19.1.4
+
'@napi-rs/wasm-runtime@0.2.10':
dependencies:
'@emnapi/core': 1.4.3