Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/pyconkr-admin/src/components/layouts/admin_editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const ReadOnlyValueField: React.FC<{
)}
{fieldState.blob.type.startsWith("application/json") && fieldState.blobText && (
<Box sx={{ maxWidth: "600px", overflow: "auto" }}>
<Common.Components.LottieDebugPanel animationData={JSON.parse(fieldState.blobText)} />
<Common.Components.LottieDebugPanel data={JSON.parse(fieldState.blobText)} />
</Box>
)}
<a href={value as string}>링크</a>
Expand Down
2 changes: 2 additions & 0 deletions apps/pyconkr-admin/src/consts/mdx_components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/pyconkr/src/consts/mdx_components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 39 additions & 14 deletions packages/common/src/components/dynamic_route.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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</>;
Expand Down Expand Up @@ -62,18 +93,12 @@ export const PageRenderer: React.FC<{ id?: string }> = ErrorBoundary.with(
Suspense.with({ fallback: <CircularProgress /> }, ({ id }) => {
const backendClient = Hooks.BackendAPI.useBackendClient();
const { data } = Hooks.BackendAPI.usePageQuery(backendClient, id || "");
const commonStackStyle = {
justifyContent: "flex-start",
alignItems: "center",
};

return (
<Stack {...commonStackStyle} sx={initialPageStyle(Utils.parseCss(data.css))}>
<Stack sx={initialPageStyle(Utils.parseCss(data.css))}>
{data.sections.map((s) => (
<Stack {...commonStackStyle} sx={initialSectionStyle(Utils.parseCss(s.css))} key={s.id}>
<Box sx={{ maxWidth: "1000px" }}>
<MDXRenderer text={s.body} />
</Box>
<Stack sx={initialSectionStyle(Utils.parseCss(s.css))} key={s.id}>
<MDXRenderer text={s.body} />
</Stack>
))}
</Stack>
Expand Down
8 changes: 7 additions & 1 deletion packages/common/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down
108 changes: 88 additions & 20 deletions packages/common/src/components/lottie.tsx
Original file line number Diff line number Diff line change
@@ -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<LottiePlayerStateType>({
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<LottiePlayerProps> = ({
data,
playState = "playing",
disableLoop = false,
renderSettings = {},
style,
}) => {
const [playerState, setPlayerState] = React.useState<LottieDebugPanelStateType>({
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 (
<Stack direction="column">
<Box>
<Lottie
isStopped={playerState.isStopped}
isPaused={playerState.isPaused}
{...playStateToLottiePlayerState(playerState.playState)}
options={{
animationData,
animationData: data,
loop: playerState.loop,
autoplay: true,
rendererSettings: { preserveAspectRatio: "xMidYMid slice" },
rendererSettings: { preserveAspectRatio: "xMidYMid slice", ...renderSettings },
}}
style={style}
/>
</Box>
<Stack direction="row" spacing={2}>
<IconButton onClick={togglePause} children={playerState.isPaused ? <PlayArrow /> : <Pause />} />
<IconButton onClick={togglePause} children={!isPlaying ? <PlayArrow /> : <Pause />} />
<IconButton onClick={stop} children={<Stop />} />
<FormControlLabel control={<Switch checked={playerState.loop} onChange={toggleLoop} />} label="반복 재생" />
</Stack>
</Stack>
);
};

export const LottiePlayer: React.FC<LottiePlayerProps> = ({
data,
playState = "playing",
disableLoop = false,
renderSettings = {},
style,
}) => (
<Lottie
{...playStateToLottiePlayerState(playState)}
options={{
animationData: data,
loop: !disableLoop,
autoplay: playState === "playing",
rendererSettings: { preserveAspectRatio: "xMidYMid slice", ...renderSettings },
}}
style={style}
/>
);

type NetworkLottiePlayerProps = Omit<LottiePlayerProps, "data"> & {
url: string;
fetchOptions?: RequestInit;
};

type NetworkLottiePlayerStateType = {
data?: unknown | null;
};

export const NetworkLottiePlayer: React.FC<NetworkLottiePlayerProps> = ErrorBoundary.with(
{ fallback: ErrorFallback },
(props) => {
const [playerState, setPlayerState] = React.useState<NetworkLottiePlayerStateType>({});

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 ? <CircularProgress /> : <LottiePlayer {...props} data={playerState.data} />;
}
);
1 change: 1 addition & 0 deletions packages/common/src/components/mdx.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const CustomMDXComponents: MDXComponents = {
h6: (props) => <h6 style={{ margin: 0 }} {...props} />,
strong: (props) => <strong {...props} />,
hr: (props) => <StyledDivider {...props} />,
img: (props) => <img style={{ maxWidth: "100%" }} alt="" {...props} />,
em: (props) => <em {...props} />,
ul: (props) => <ul {...props} />,
ol: (props) => <ol {...props} />,
Expand Down
5 changes: 2 additions & 3 deletions packages/common/src/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
3 changes: 2 additions & 1 deletion packages/common/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions packages/common/src/utils/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");