diff --git a/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx b/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx index 3e4a8a2..a03fe82 100644 --- a/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx +++ b/apps/pyconkr-admin/src/components/layouts/admin_editor.tsx @@ -105,7 +105,7 @@ const ReadOnlyValueField: React.FC<{ )} {fieldState.blob.type.startsWith("application/json") && fieldState.blobText && ( - + )} 링크 diff --git a/apps/pyconkr-admin/src/consts/mdx_components.ts b/apps/pyconkr-admin/src/consts/mdx_components.ts index 8cb9f19..b8db684 100644 --- a/apps/pyconkr-admin/src/consts/mdx_components.ts +++ b/apps/pyconkr-admin/src/consts/mdx_components.ts @@ -131,6 +131,8 @@ const MUIMDXComponents: MDXComponents = { }; const PyConKRCommonMDXComponents: MDXComponents = { + Common__Components__Lottie: Common.Components.LottiePlayer, + Common__Components__NetworkLottie: Common.Components.NetworkLottiePlayer, Common__Components__MDX__PrimaryStyledDetails: Common.Components.MDX.PrimaryStyledDetails, Common__Components__MDX__SecondaryStyledDetails: Common.Components.MDX.SecondaryStyledDetails, Common__Components__MDX__Map: Common.Components.MDX.Map, diff --git a/apps/pyconkr/src/consts/mdx_components.ts b/apps/pyconkr/src/consts/mdx_components.ts index 8cb9f19..b8db684 100644 --- a/apps/pyconkr/src/consts/mdx_components.ts +++ b/apps/pyconkr/src/consts/mdx_components.ts @@ -131,6 +131,8 @@ const MUIMDXComponents: MDXComponents = { }; const PyConKRCommonMDXComponents: MDXComponents = { + Common__Components__Lottie: Common.Components.LottiePlayer, + Common__Components__NetworkLottie: Common.Components.NetworkLottiePlayer, Common__Components__MDX__PrimaryStyledDetails: Common.Components.MDX.PrimaryStyledDetails, Common__Components__MDX__SecondaryStyledDetails: Common.Components.MDX.SecondaryStyledDetails, Common__Components__MDX__Map: Common.Components.MDX.Map, diff --git a/packages/common/src/components/dynamic_route.tsx b/packages/common/src/components/dynamic_route.tsx index 2678df0..1ab3397 100644 --- a/packages/common/src/components/dynamic_route.tsx +++ b/packages/common/src/components/dynamic_route.tsx @@ -1,4 +1,4 @@ -import { Box, CircularProgress, Stack, Theme } from "@mui/material"; +import { CircularProgress, Stack, Theme } from "@mui/material"; import { ErrorBoundary, Suspense } from "@suspensive/react"; import { AxiosError, AxiosResponse } from "axios"; import * as React from "react"; @@ -15,18 +15,49 @@ import { MDXRenderer } from "./mdx"; const initialPageStyle: (additionalStyle: React.CSSProperties) => (theme: Theme) => React.CSSProperties = (additionalStyle) => (theme) => ({ width: "100%", - marginTop: theme.spacing(8), display: "flex", - justifyContent: "center", + 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) => () => ({ + (additionalStyle) => (theme) => ({ width: "100%", - ...additionalStyle, + 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; @@ -62,18 +93,12 @@ export const PageRenderer: React.FC<{ id?: string }> = ErrorBoundary.with( Suspense.with({ fallback: }, ({ id }) => { const backendClient = Hooks.BackendAPI.useBackendClient(); const { data } = Hooks.BackendAPI.usePageQuery(backendClient, id || ""); - const commonStackStyle = { - justifyContent: "flex-start", - alignItems: "center", - }; return ( - + {data.sections.map((s) => ( - - - - + + ))} diff --git a/packages/common/src/components/index.ts b/packages/common/src/components/index.ts index 6433931..b409d89 100644 --- a/packages/common/src/components/index.ts +++ b/packages/common/src/components/index.ts @@ -6,7 +6,11 @@ import { RouteRenderer as RouteRendererComponent, } from "./dynamic_route"; import { ErrorFallback as ErrorFallbackComponent } from "./error_handler"; -import { LottieDebugPanel as LottieDebugPanelComponent } from "./lottie"; +import { + LottieDebugPanel as LottieDebugPanelComponent, + LottiePlayer as LottiePlayerComponent, + NetworkLottiePlayer as NetworkLottiePlayerComponent, +} from "./lottie"; import { MDXRenderer as MDXRendererComponent } from "./mdx"; import type { MapPropType as MapComponentPropType } from "./mdx_components/map"; import { Map as MapComponent } from "./mdx_components/map"; @@ -27,6 +31,8 @@ namespace Components { export const MDXRenderer = MDXRendererComponent; export const PythonKorea = PythonKoreaComponent; export const LottieDebugPanel = LottieDebugPanelComponent; + export const LottiePlayer = LottiePlayerComponent; + export const NetworkLottiePlayer = NetworkLottiePlayerComponent; export const ErrorFallback = ErrorFallbackComponent; export namespace MDX { diff --git a/packages/common/src/components/lottie.tsx b/packages/common/src/components/lottie.tsx index 71d8062..20f28cf 100644 --- a/packages/common/src/components/lottie.tsx +++ b/packages/common/src/components/lottie.tsx @@ -1,52 +1,120 @@ import { Pause, PlayArrow, Stop } from "@mui/icons-material"; -import { Box, FormControlLabel, IconButton, Stack, Switch } from "@mui/material"; +import { Box, CircularProgress, FormControlLabel, IconButton, Stack, Switch } from "@mui/material"; +import { ErrorBoundary } from "@suspensive/react"; import * as React from "react"; -import Lottie from "react-lottie"; +import Lottie, { Options } from "react-lottie"; + +import { ErrorFallback } from "./error_handler"; +import { isValidHttpUrl } from "../utils/string"; type PlayState = "playing" | "paused" | "stopped"; +type LottiePlayerProps = { + data: unknown; + playState?: PlayState; + disableLoop?: boolean; + renderSettings?: Options["rendererSettings"]; + style?: React.CSSProperties; +}; + type LottiePlayerStateType = { + playState: PlayState; +}; + +type LottieDebugPanelStateType = LottiePlayerStateType & { loop: boolean; - isStopped: boolean; - isPaused: boolean; }; -export const LottieDebugPanel: React.FC<{ animationData: unknown }> = ({ animationData }) => { - const [playerState, setPlayerState] = React.useState({ - loop: true, - isStopped: false, - isPaused: false, +const playStateToLottiePlayerState = (playState: PlayState): { isStopped: boolean; isPaused: boolean } => { + if (playState === "playing") return { isStopped: false, isPaused: false }; + if (playState === "paused") return { isStopped: false, isPaused: true }; + return { isStopped: true, isPaused: true }; +}; + +export const LottieDebugPanel: React.FC = ({ + data, + playState = "playing", + disableLoop = false, + renderSettings = {}, + style, +}) => { + const [playerState, setPlayerState] = React.useState({ + playState, + loop: !disableLoop, }); + const isPlaying = playerState.playState === "playing"; const toggleLoop = () => setPlayerState((ps) => ({ ...ps, loop: !ps.loop })); - const setPlayState = (playState: PlayState) => { - if (playState === "playing") setPlayerState((ps) => ({ ...ps, isStopped: false, isPaused: false })); - if (playState === "paused") setPlayerState((ps) => ({ ...ps, isStopped: false, isPaused: true })); - if (playState === "stopped") setPlayerState((ps) => ({ ...ps, isStopped: true, isPaused: true })); - }; + const setPlayState = (playState: PlayState) => setPlayerState((ps) => ({ ...ps, playState })); const stop = () => setPlayState("stopped"); - const togglePause = () => setPlayState(playerState.isPaused ? "playing" : "paused"); + const togglePause = () => setPlayState(!isPlaying ? "playing" : "paused"); return ( - : } /> + : } /> } /> } label="반복 재생" /> ); }; + +export const LottiePlayer: React.FC = ({ + data, + playState = "playing", + disableLoop = false, + renderSettings = {}, + style, +}) => ( + +); + +type NetworkLottiePlayerProps = Omit & { + url: string; + fetchOptions?: RequestInit; +}; + +type NetworkLottiePlayerStateType = { + data?: unknown | null; +}; + +export const NetworkLottiePlayer: React.FC = ErrorBoundary.with( + { fallback: ErrorFallback }, + (props) => { + const [playerState, setPlayerState] = React.useState({}); + + React.useEffect(() => { + (async () => { + if (!isValidHttpUrl(props.url)) throw new Error("Invalid URL for NetworkLottiePlayer: " + props.url); + + const data = JSON.parse(await (await fetch(props.url, props.fetchOptions)).text()); + setPlayerState((ps) => ({ ...ps, data })); + })(); + }, [props.url, props.fetchOptions]); + + return playerState.data === undefined ? : ; + } +); diff --git a/packages/common/src/components/mdx.tsx b/packages/common/src/components/mdx.tsx index fbc7360..774f68e 100644 --- a/packages/common/src/components/mdx.tsx +++ b/packages/common/src/components/mdx.tsx @@ -55,6 +55,7 @@ const CustomMDXComponents: MDXComponents = { h6: (props) =>
, strong: (props) => , hr: (props) => , + img: (props) => , em: (props) => , ul: (props) =>
    , ol: (props) =>
      , diff --git a/packages/common/src/utils/api.ts b/packages/common/src/utils/api.ts index bfac3e0..c7cceb4 100644 --- a/packages/common/src/utils/api.ts +++ b/packages/common/src/utils/api.ts @@ -61,9 +61,8 @@ export const findSiteMapUsingRoute = ( export const parseCss = (t: unknown): React.CSSProperties => { try { if (R.isString(t) && !R.isEmpty(t)) return JSON.parse(t); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (_) { - // Ignore parsing errors + } catch (e) { + console.warn("Failed to parse CSS string:", t, e); } return {} as React.CSSProperties; }; diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 48f8e33..574c828 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -10,7 +10,7 @@ import { filterReadOnlyPropertiesInJsonSchema as _filterReadOnlyPropertiesInJsonSchema, filterWritablePropertiesInJsonSchema as _filterWritablePropertiesInJsonSchema, } from "./json_schema"; -import { isFilledString as _isFilledString, rtrim as _rtrim } from "./string"; +import { isFilledString as _isFilledString, isValidHttpUrl as _isValidHttpUrl, rtrim as _rtrim } from "./string"; namespace Utils { export const buildNestedSiteMap = _buildNestedSiteMap; @@ -20,6 +20,7 @@ namespace Utils { export const isFormValid = _isFormValid; export const getFormValue = _getFormValue; export const isFilledString = _isFilledString; + export const isValidHttpUrl = _isValidHttpUrl; export const rtrim = _rtrim; export const filterWritablePropertiesInJsonSchema = _filterWritablePropertiesInJsonSchema; export const filterReadOnlyPropertiesInJsonSchema = _filterReadOnlyPropertiesInJsonSchema; diff --git a/packages/common/src/utils/string.ts b/packages/common/src/utils/string.ts index b44c6f3..4bf656e 100644 --- a/packages/common/src/utils/string.ts +++ b/packages/common/src/utils/string.ts @@ -2,5 +2,15 @@ import * as R from "remeda"; export const isFilledString = (obj: unknown): obj is string => R.isString(obj) && !R.isEmpty(obj); +export const isValidHttpUrl = (obj: unknown): obj is string => { + try { + const url = new URL(obj as string); + return url.protocol === "http:" || url.protocol === "https:"; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + return false; + } +}; + // Remove whitespace from the right side of the input string. export const rtrim = (x: string): string => x.replace(/\s+$/gm, "");