diff --git a/apps/pyconkr/src/App.tsx b/apps/pyconkr/src/App.tsx index 9869c8b..d08e701 100644 --- a/apps/pyconkr/src/App.tsx +++ b/apps/pyconkr/src/App.tsx @@ -3,13 +3,17 @@ import React from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; import MainLayout from "./components/layout"; import { Test } from "./components/pages/test"; +import { IS_DEBUG_ENV } from "./consts/index.ts"; + +import * as Common from "@frontend/common"; export const App: React.FC = () => { return ( }> - } /> + { IS_DEBUG_ENV && } /> } + } /> diff --git a/apps/pyconkr/src/components/layout/Footer/index.tsx b/apps/pyconkr/src/components/layout/Footer/index.tsx index b0a467f..7988b23 100644 --- a/apps/pyconkr/src/components/layout/Footer/index.tsx +++ b/apps/pyconkr/src/components/layout/Footer/index.tsx @@ -65,7 +65,7 @@ export default function Footer({ ], icons = defaultIcons, }: FooterProps) { - const { sendEmail } = Common.Hooks.useEmail(); + const { sendEmail } = Common.Hooks.Common.useEmail(); return ( diff --git a/apps/pyconkr/src/components/layout/index.tsx b/apps/pyconkr/src/components/layout/index.tsx index bea488d..d54228d 100644 --- a/apps/pyconkr/src/components/layout/index.tsx +++ b/apps/pyconkr/src/components/layout/index.tsx @@ -55,6 +55,11 @@ const LayoutContainer = styled.div` const MainContent = styled.main` flex: 1; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; `; export default function MainLayout() { diff --git a/apps/pyconkr/src/components/pages/test.tsx b/apps/pyconkr/src/components/pages/test.tsx index 228e364..214f290 100644 --- a/apps/pyconkr/src/components/pages/test.tsx +++ b/apps/pyconkr/src/components/pages/test.tsx @@ -2,10 +2,11 @@ import * as React from "react"; import { Box, Button } from "@mui/material"; +import { BackendTestPage } from '../../debug/page/backend_test'; import { MdiTestPage } from "../../debug/page/mdi_test"; import { ShopTestPage } from "../../debug/page/shop_test"; -type SelectedTabType = "shop" | "mdi"; +type SelectedTabType = "shop" | "mdi" | "backend"; export const Test: React.FC = () => { const [selectedTab, setSelectedTab] = React.useState("mdi"); @@ -18,8 +19,12 @@ export const Test: React.FC = () => { + {selectedTab === "shop" && } {selectedTab === "mdi" && } + {selectedTab === "backend" && } ); }; diff --git a/apps/pyconkr/src/consts/index.ts b/apps/pyconkr/src/consts/index.ts new file mode 100644 index 0000000..9ce1470 --- /dev/null +++ b/apps/pyconkr/src/consts/index.ts @@ -0,0 +1 @@ +export const IS_DEBUG_ENV = import.meta.env.MODE === "development" diff --git a/apps/pyconkr/src/debug/page/backend_test.tsx b/apps/pyconkr/src/debug/page/backend_test.tsx new file mode 100644 index 0000000..ecc3a30 --- /dev/null +++ b/apps/pyconkr/src/debug/page/backend_test.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; +import * as R from "remeda"; + +import { Button, CircularProgress, MenuItem, Select, Stack } from '@mui/material'; +import { ErrorBoundary, Suspense } from '@suspensive/react'; + +import * as Common from "@frontend/common"; + +const SiteMapRenderer: React.FC = () => { + const { data } = Common.Hooks.BackendAPI.useFlattenSiteMapQuery(); + return
{JSON.stringify(Common.Utils.buildNestedSiteMap(data), null, 2)}
+}; + +const PageIdSelector: React.FC<{ inputRef: React.Ref }> = ({ inputRef }) => { + const { data } = Common.Hooks.BackendAPI.useFlattenSiteMapQuery(); + + return +} + +const SuspenseWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + }> + {children} + + +) + +export const BackendTestPage: React.FC = () => { + const inputRef = React.useRef(null); + const [pageId, setPageId] = React.useState(null); + + return +
+ +
+ +
+ +
+ {R.isString(pageId) ? : <>페이지를 선택해주세요.} +
+} diff --git a/apps/pyconkr/src/main.tsx b/apps/pyconkr/src/main.tsx index d71c332..6d2e1b8 100644 --- a/apps/pyconkr/src/main.tsx +++ b/apps/pyconkr/src/main.tsx @@ -12,6 +12,7 @@ import * as Common from "@frontend/common"; import * as Shop from "@frontend/shop"; import { App } from "./App.tsx"; +import { IS_DEBUG_ENV } from './consts/index.ts'; import { globalStyles, muiTheme } from "./styles/globalStyles.ts"; const queryClient = new QueryClient({ @@ -35,8 +36,10 @@ const queryClient = new QueryClient({ }); const CommonOptions: Common.Contexts.ContextOptions = { - debug: import.meta.env.MODE === "development", + debug: IS_DEBUG_ENV, baseUrl: '.', + backendApiDomain: import.meta.env.VITE_PYCONKR_BACKEND_API_DOMAIN, + backendApiTimeout: 10000, }; const ShopOptions: Shop.Contexts.ContextOptions = { diff --git a/apps/pyconkr/vite-env.d.ts b/apps/pyconkr/vite-env.d.ts index c3ecd2b..678a0d7 100644 --- a/apps/pyconkr/vite-env.d.ts +++ b/apps/pyconkr/vite-env.d.ts @@ -11,6 +11,7 @@ interface ViteTypeOptions { } interface ImportMetaEnv { + readonly VITE_PYCONKR_BACKEND_API_DOMAIN: string; readonly VITE_PYCONKR_SHOP_API_DOMAIN: string; readonly VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME: string; readonly VITE_PYCONKR_SHOP_IMP_ACCOUNT_ID: string; diff --git a/dotenv/.env.development b/dotenv/.env.development index 1f5fdf5..41f9c73 100644 --- a/dotenv/.env.development +++ b/dotenv/.env.development @@ -1,3 +1,4 @@ +VITE_PYCONKR_BACKEND_API_DOMAIN=https://rest-api.dev.pycon.kr VITE_PYCONKR_SHOP_API_DOMAIN=https://shop-api.dev.pycon.kr VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME=DEBUG_csrftoken VITE_PYCONKR_SHOP_IMP_ACCOUNT_ID=imp80859147 diff --git a/dotenv/.env.production b/dotenv/.env.production index a4b0532..0aac883 100644 --- a/dotenv/.env.production +++ b/dotenv/.env.production @@ -1,3 +1,4 @@ +VITE_PYCONKR_BACKEND_API_DOMAIN=https://rest-api.pycon.kr VITE_PYCONKR_SHOP_API_DOMAIN=https://shop-api.pycon.kr VITE_PYCONKR_SHOP_CSRF_COOKIE_NAME=csrftoken VITE_PYCONKR_SHOP_IMP_ACCOUNT_ID=imp96676915 diff --git a/packages/common/src/apis/client.ts b/packages/common/src/apis/client.ts new file mode 100644 index 0000000..c78331a --- /dev/null +++ b/packages/common/src/apis/client.ts @@ -0,0 +1,176 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; +import * as R from "remeda"; + +import CommonSchemas from "../schemas"; + +const DEFAULT_ERROR_MESSAGE = "알 수 없는 문제가 발생했습니다, 잠시 후 다시 시도해주세요."; +const DEFAULT_ERROR_RESPONSE = { + type: "unknown", + errors: [{ code: "unknown", detail: DEFAULT_ERROR_MESSAGE, attr: null }], +}; + +export class BackendAPIClientError extends Error { + readonly name = "BackendAPIClientError"; + readonly status: number; + readonly detail: CommonSchemas.ErrorResponseSchema; + readonly originalError: unknown; + + constructor(error?: unknown) { + let message: string = DEFAULT_ERROR_MESSAGE; + let detail: CommonSchemas.ErrorResponseSchema = DEFAULT_ERROR_RESPONSE; + let status = -1; + + if (axios.isAxiosError(error)) { + const response = error.response; + + if (response) { + status = response.status; + detail = CommonSchemas.isObjectErrorResponseSchema(response.data) + ? response.data + : { + type: "axios_error", + errors: [ + { + code: "unknown", + detail: R.isString(response.data) + ? response.data + : DEFAULT_ERROR_MESSAGE, + attr: null, + }, + ], + }; + } + } else if (error instanceof Error) { + message = error.message; + detail = { + type: error.name || typeof error || "unknown", + errors: [{ code: "unknown", detail: error.message, attr: null }], + }; + } + + super(message); + this.originalError = error || null; + this.status = status; + this.detail = detail; + } + + isRequiredAuth(): boolean { + return this.status === 401 || this.status === 403; + } +} + +type AxiosRequestWithoutPayload = , D = any>( + url: string, + config?: AxiosRequestConfig +) => Promise; +type AxiosRequestWithPayload = , D = any>( + url: string, + data?: D, + config?: AxiosRequestConfig +) => Promise; + +export class BackendAPIClient { + readonly baseURL: string; + private readonly backendAPI: AxiosInstance; + + constructor(baseURL: string, timeout: number) { + const headers = { "Content-Type": "application/json" }; + this.baseURL = baseURL; + this.backendAPI = axios.create({ baseURL, timeout, headers }); + } + + _safe_request_without_payload( + requestFunc: AxiosRequestWithoutPayload + ): AxiosRequestWithoutPayload { + return async , D = any>( + url: string, + config?: AxiosRequestConfig + ) => { + try { + return await requestFunc(url, config); + } catch (error) { + throw new BackendAPIClientError(error); + } + }; + } + + _safe_request_with_payload( + requestFunc: AxiosRequestWithPayload + ): AxiosRequestWithPayload { + return async , D = any>( + url: string, + data: D, + config?: AxiosRequestConfig + ) => { + try { + return await requestFunc(url, data, config); + } catch (error) { + throw new BackendAPIClientError(error); + } + }; + } + + async get( + url: string, + config?: AxiosRequestConfig + ): Promise { + return ( + await this._safe_request_without_payload(this.backendAPI.get)< + T, + AxiosResponse, + D + >(url, config) + ).data; + } + async post( + url: string, + data: D, + config?: AxiosRequestConfig + ): Promise { + return ( + await this._safe_request_with_payload(this.backendAPI.post)< + T, + AxiosResponse, + D + >(url, data, config) + ).data; + } + async put( + url: string, + data: D, + config?: AxiosRequestConfig + ): Promise { + return ( + await this._safe_request_with_payload(this.backendAPI.put)< + T, + AxiosResponse, + D + >(url, data, config) + ).data; + } + async patch( + url: string, + data: D, + config?: AxiosRequestConfig + ): Promise { + return ( + await this._safe_request_with_payload(this.backendAPI.patch)< + T, + AxiosResponse, + D + >(url, data, config) + ).data; + } + async delete( + url: string, + config?: AxiosRequestConfig + ): Promise { + return ( + await this._safe_request_without_payload(this.backendAPI.delete)< + T, + AxiosResponse, + D + >(url, config) + ).data; + } +} diff --git a/packages/common/src/apis/index.ts b/packages/common/src/apis/index.ts new file mode 100644 index 0000000..c9534a3 --- /dev/null +++ b/packages/common/src/apis/index.ts @@ -0,0 +1,9 @@ +import CommonSchemas from "../schemas"; +import { BackendAPIClient } from "./client"; + +namespace BackendAPIs { + export const listSiteMaps = (client: BackendAPIClient) => () => client.get("v1/cms/sitemap/"); + export const retrievePage = (client: BackendAPIClient) => (id: string) => client.get(`v1/cms/page/${id}/`); +} + +export default BackendAPIs; diff --git a/packages/common/src/components/dynamic_route.tsx b/packages/common/src/components/dynamic_route.tsx new file mode 100644 index 0000000..f9e0295 --- /dev/null +++ b/packages/common/src/components/dynamic_route.tsx @@ -0,0 +1,79 @@ +import * as React from "react"; +import { useLocation } from 'react-router-dom'; +import * as R from "remeda"; + +import { CircularProgress } from '@mui/material'; +import { ErrorBoundary, Suspense } from '@suspensive/react'; + +import styled from '@emotion/styled'; +import Components from '../components'; +import Hooks from "../hooks"; +import Schemas from "../schemas"; +import Utils from '../utils'; + +const InitialPageStyle: React.CSSProperties = { + width: '100%', + height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'column', +} + +const InitialSectionStyle: React.CSSProperties = { + width: '100%', +} + +export const PageRenderer: React.FC<{ id: string }> = ({ id }) => { + const { data } = Hooks.BackendAPI.usePageQuery(id); + + return
+ { + data.sections.map( + (s) =>
+ +
+ ) + } +
+}; + +const AsyncDynamicRoutePage: React.FC = () => { + const location = useLocation(); + const { data } = Hooks.BackendAPI.useFlattenSiteMapQuery(); + const nestedSiteMap = Utils.buildNestedSiteMap(data); + + const currentRouteCodes = ['', ...location.pathname.split('/').filter((code) => !R.isEmpty(code))]; + + let currentSitemap: Schemas.NestedSiteMapSchema | null | undefined = nestedSiteMap[currentRouteCodes[0]]; + if (currentSitemap === undefined) { + return <>404 Not Found; + } + + for (const routeCode of currentRouteCodes.slice(1)) { + if ((currentSitemap = currentSitemap.children[routeCode]) === undefined) { + break; + } + } + + return R.isNullish(currentSitemap) + ? <>404 Not Found + : +} + +const FullPage = styled.div` + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +` + +export const DynamicRoutePage: React.FC = () => + + }> + + + +; diff --git a/packages/common/src/components/error_handler.tsx b/packages/common/src/components/error_handler.tsx index 6930cbf..5bcd37d 100644 --- a/packages/common/src/components/error_handler.tsx +++ b/packages/common/src/components/error_handler.tsx @@ -42,7 +42,7 @@ const SimplifiedErrorFallback: React.FC<{ reset: () => void }> = ({ reset }) => export const ErrorFallback: React.FC<{ error: Error, reset: () => void }> = ({ error, reset }) => { const InnerErrorFallback: React.FC<{ error: Error, reset: () => void }> = ({ error, reset }) => { - const { debug } = CommonContext.useCommonContext(); + const { debug } = CommonContext.Common.useCommonContext(); return debug ? : ; } diff --git a/packages/common/src/components/index.ts b/packages/common/src/components/index.ts index d93d8e9..6f1efa3 100644 --- a/packages/common/src/components/index.ts +++ b/packages/common/src/components/index.ts @@ -1,10 +1,16 @@ import { CommonContextProvider as CommonContextProviderComponent } from './common_context'; +import { + DynamicRoutePage as DynamicRoutePageComponent, + PageRenderer as PageRendererComponent, +} from './dynamic_route'; import { ErrorFallback as ErrorFallbackComponent } from './error_handler'; import { MDXRenderer as MDXRendererComponent } from "./mdx"; import { PythonKorea as PythonKoreaComponent } from './pythonkorea'; namespace Components { export const CommonContextProvider = CommonContextProviderComponent; + export const DynamicRoutePage = DynamicRoutePageComponent; + export const PageRenderer = PageRendererComponent; export const MDXRenderer = MDXRendererComponent; export const PythonKorea = PythonKoreaComponent; export const ErrorFallback = ErrorFallbackComponent; diff --git a/packages/common/src/components/mdx.tsx b/packages/common/src/components/mdx.tsx index 5c6d57b..45ae95d 100644 --- a/packages/common/src/components/mdx.tsx +++ b/packages/common/src/components/mdx.tsx @@ -58,7 +58,7 @@ const lineFormatterForMDX = (line: string) => { export const MDXRenderer: React.FC<{ text: string }> = ({ text }) => { // 원래 MDX는 각 줄의 마지막에 공백 2개가 있어야 줄바꿈이 되고, 또 연속 줄바꿈은 무시되지만, // 편의성을 위해 렌더러 단에서 공백 2개를 추가하고 연속 줄바꿈을
로 변환합니다. - const { baseUrl, debug } = Hooks.useCommonContext(); + const { baseUrl } = Hooks.Common.useCommonContext(); const processedText = text .split("\n") diff --git a/packages/common/src/contexts/index.ts b/packages/common/src/contexts/index.ts index 7422117..ccbd8da 100644 --- a/packages/common/src/contexts/index.ts +++ b/packages/common/src/contexts/index.ts @@ -4,11 +4,15 @@ namespace GlobalContext { export type ContextOptions = { baseUrl: string; debug?: boolean; + backendApiDomain: string; + backendApiTimeout: number; } export const context = React.createContext({ baseUrl: "", debug: false, + backendApiDomain: "", + backendApiTimeout: 10000, }); } diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts index 1882485..430631b 100644 --- a/packages/common/src/hooks/index.ts +++ b/packages/common/src/hooks/index.ts @@ -1,9 +1,15 @@ +import BackendAPIHooks from './useAPI'; import { useCommonContext as useCommonContextHook } from './useCommonContext'; import { useEmail as useEmailHook } from './useEmail'; -namespace CommonHooks { +export namespace CommonHooks { export const useCommonContext = useCommonContextHook; export const useEmail = useEmailHook; +}; + +namespace Hooks { + export const Common = CommonHooks; + export const BackendAPI = BackendAPIHooks; } -export default CommonHooks; +export default Hooks; diff --git a/packages/common/src/hooks/useAPI.ts b/packages/common/src/hooks/useAPI.ts new file mode 100644 index 0000000..082e827 --- /dev/null +++ b/packages/common/src/hooks/useAPI.ts @@ -0,0 +1,39 @@ +import * as React from "react"; + +import { useSuspenseQuery } from "@tanstack/react-query"; + +import BackendAPIs from "../apis"; +import { BackendAPIClient } from '../apis/client'; +import BackendContext from '../contexts'; + +const QUERY_KEYS = { + SITEMAP: ["query", "sitemap"], + SITEMAP_LIST: ["query", "sitemap", "list"], + PAGE: ["query", "page"], +}; + +namespace BackendAPIHooks { + export const useBackendContext = () => { + const context = React.useContext(BackendContext.context); + if (!context) throw new Error("useBackendContext must be used within a CommonProvider"); + return context; + } + + const clientDecorator = (func:(client: BackendAPIClient) => T): T => { + const { backendApiDomain, backendApiTimeout } = useBackendContext(); + return func(new BackendAPIClient(backendApiDomain, backendApiTimeout)); + } + + export const useFlattenSiteMapQuery = () => useSuspenseQuery({ + queryKey: QUERY_KEYS.SITEMAP_LIST, + queryFn: clientDecorator(BackendAPIs.listSiteMaps), + meta: { invalidates: [ QUERY_KEYS.SITEMAP ] }, + }); + + export const usePageQuery = (id: string) => useSuspenseQuery({ + queryKey: [ ...QUERY_KEYS.PAGE, id ], + queryFn: () => clientDecorator(BackendAPIs.retrievePage)(id), + }); +} + +export default BackendAPIHooks; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index b3b263b..d7814e7 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,5 +1,6 @@ export { default as Components } from "./components/index"; export { default as Contexts } from "./contexts/index"; export { default as Hooks } from "./hooks/index"; +export { default as Schemas } from "./schemas/index"; export { default as Utils } from "./utils/index"; diff --git a/packages/common/src/schemas/index.ts b/packages/common/src/schemas/index.ts new file mode 100644 index 0000000..65816a6 --- /dev/null +++ b/packages/common/src/schemas/index.ts @@ -0,0 +1,70 @@ +import * as R from "remeda"; + +namespace BackendAPISchemas { + export type EmptyObject = Record; + + export type DetailedErrorSchema = { + code: string; + detail: string; + attr: string | null; + }; + + export type ErrorResponseSchema = { + type: string; + errors: DetailedErrorSchema[]; + }; + + export type FlattenedSiteMapSchema = { + id: string; + route_code: string; + name: string; + order: number; + parent_sitemap: string | null; + page: string; + }; + + export type NestedSiteMapSchema = { + id: string; + route_code: string; + name: string; + order: number; + page: string; + children: { [key: string]: NestedSiteMapSchema }; + }; + + export type SectionSchema = { + id: string; + css: string; + + order: number; + body: string; + }; + + export type PageSchema = { + id: string; + css: string; + title: string; + subtitle: string; + sections: SectionSchema[]; + }; + + export const isObjectErrorResponseSchema = ( + obj?: unknown + ): obj is BackendAPISchemas.ErrorResponseSchema => { + return ( + R.isPlainObject(obj) && + R.isString(obj.type) && + R.isArray(obj.errors) && + obj.errors.every((error) => { + return ( + R.isPlainObject(error) && + R.isString(error.code) && + R.isString(error.detail) && + (error.attr === null || R.isString(error.attr)) + ); + }) + ); + }; +} + +export default BackendAPISchemas; diff --git a/packages/common/src/utils/api.ts b/packages/common/src/utils/api.ts new file mode 100644 index 0000000..7e9dc28 --- /dev/null +++ b/packages/common/src/utils/api.ts @@ -0,0 +1,51 @@ +import * as R from "remeda"; + +import BackendAPISchemas from "../schemas"; + +export const buildNestedSiteMap: ( + flattened: BackendAPISchemas.FlattenedSiteMapSchema[] +) => { [key: string]: BackendAPISchemas.NestedSiteMapSchema } = (flattened) => { + const map: Record = {}; + const roots: BackendAPISchemas.NestedSiteMapSchema[] = []; + + const siteMapIdRouteCodeMap = flattened.reduce((acc, item) => { + acc[item.id] = item.route_code; + return acc; + }, {} as Record); + + flattened.forEach((item) => { + map[item.id] = { + ...item, + children: {}, + }; + }); + + flattened.forEach((item) => { + if (item.parent_sitemap) { + map[item.parent_sitemap].children[siteMapIdRouteCodeMap[item.id]] = map[item.id]; + } else { + roots.push(map[item.id]); + } + }); + + return roots.reduce((acc, item) => { + acc[item.route_code] = item; + return acc; + }, {} as Record); +}; + +export const findSiteMapUsingRoute = (route: string, siteMapData: BackendAPISchemas.NestedSiteMapSchema): BackendAPISchemas.NestedSiteMapSchema | null => { + const currentRouteCodes = ['', ...route.split('/').filter((code) => !R.isEmpty(code))]; + + let currentSitemap: BackendAPISchemas.NestedSiteMapSchema | null | undefined = siteMapData.children[currentRouteCodes[0]]; + if (currentSitemap === undefined) return null; + + for (const routeCode of currentRouteCodes.slice(1)) { + if ((currentSitemap = currentSitemap.children[routeCode] || null) === null) { + break; + } + } + return currentSitemap; +}; + +export const parseCss = (t: unknown): React.CSSProperties => (R.isString(t) && !R.isEmpty(t) && JSON.parse(t)) || {}; diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index d6c06c4..45f5f5e 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -1,3 +1,8 @@ +import { + buildNestedSiteMap as _buildNestedSiteMap, + findSiteMapUsingRoute as _findSiteMapUsingRoute, + parseCss as _parseCss, +} from './api'; import { getCookie as _getCookie } from "./cookie"; import { getFormValue as _getFormValue, @@ -5,6 +10,9 @@ import { } from "./form"; namespace Utils { + export const buildNestedSiteMap = _buildNestedSiteMap; + export const findSiteMapUsingRoute = _findSiteMapUsingRoute; + export const parseCss = _parseCss; export const getCookie = _getCookie; export const isFormValid = _isFormValid; export const getFormValue = _getFormValue;