Skip to content

Commit 249956c

Browse files
committed
Merge remote-tracking branch 'origin/feature/add-lottie-component'
2 parents 1a3f00e + 4ad56f5 commit 249956c

File tree

9 files changed

+193
-31
lines changed

9 files changed

+193
-31
lines changed

apps/pyconkr-admin/src/components/layouts/admin_editor.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { ErrorBoundary, Suspense } from "@suspensive/react";
2323
import AjvDraft04 from "ajv-draft-04";
2424
import * as React from "react";
2525
import { useNavigate, useParams } from "react-router-dom";
26+
import * as R from "remeda";
2627

2728
import { addErrorSnackbar, addSnackbar } from "../../utils/snackbar";
2829
import { BackendAdminSignInGuard } from "../elements/admin_signin_guard";
@@ -61,22 +62,59 @@ const FileField: Field = (p) => (
6162
/>
6263
);
6364

65+
type ReadOnlyValueFieldStateType = {
66+
loading: boolean;
67+
blob: Blob | null;
68+
blobText: string | null;
69+
objectUrl: string | null;
70+
};
71+
6472
const ReadOnlyValueField: React.FC<{
6573
name: string;
6674
value: unknown;
6775
uiSchema: UiSchema;
68-
}> = ({ name, value, uiSchema }) => {
69-
if (uiSchema[name] && uiSchema[name]["ui:field"] === "file") {
76+
}> = Suspense.with({ fallback: <CircularProgress /> }, ({ name, value, uiSchema }) => {
77+
const [fieldState, setFieldState] = React.useState<ReadOnlyValueFieldStateType>({
78+
loading: true,
79+
blob: null,
80+
blobText: null,
81+
objectUrl: null,
82+
});
83+
84+
React.useEffect(() => {
85+
(async () => {
86+
if (!(R.isString(value) && value.startsWith("http") && uiSchema?.[name]["ui:field"] === "file")) {
87+
setFieldState((ps) => ({ ...ps, loading: false }));
88+
return;
89+
}
90+
91+
const blob = await (await fetch(value)).blob();
92+
const blobText = await blob.text();
93+
const objectUrl = URL.createObjectURL(blob);
94+
setFieldState((ps) => ({ ...ps, loading: false, blob, blobText, objectUrl }));
95+
})();
96+
}, [value, name, uiSchema]);
97+
98+
if (fieldState.loading) return <CircularProgress />;
99+
100+
if (uiSchema?.[name]?.["ui:field"] === "file" && fieldState.blob) {
70101
return (
71102
<Stack spacing={2} alignItems="flex-start">
72-
<img src={value as string} alt={name} style={{ maxWidth: "100%", maxHeight: "600px", objectFit: "contain" }} />
103+
{fieldState.blob.type.startsWith("image/") && fieldState.objectUrl && (
104+
<img src={fieldState.objectUrl} alt={name} style={{ maxWidth: "600px", objectFit: "contain" }} />
105+
)}
106+
{fieldState.blob.type.startsWith("application/json") && fieldState.blobText && (
107+
<Box sx={{ maxWidth: "600px", overflow: "auto" }}>
108+
<Common.Components.LottieDebugPanel animationData={JSON.parse(fieldState.blobText)} />
109+
</Box>
110+
)}
73111
<a href={value as string}>링크</a>
74112
</Stack>
75113
);
76114
}
77115

78116
return value as string;
79-
};
117+
});
80118

81119
type InnerAdminEditorStateType = {
82120
tab: number;

apps/pyconkr-admin/src/components/pages/file/upload.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ const InnerPublicFileUploadPage: React.FC = () => {
7373
if (!file || file.size === 0) {
7474
addSnackbar("파일을 찾을 수 없거나, 파일 크기가 0입니다.", "error");
7575
return;
76-
} else if (!file.type.startsWith("image/")) {
77-
addSnackbar("이미지 파일만 업로드가 가능합니다.", "error");
76+
} else if (!(file.type.startsWith("image/") || file.type === "application/json")) {
77+
addSnackbar("이미지 또는 JSON 파일만 업로드가 가능합니다.", "error");
7878
return;
7979
}
8080

@@ -116,13 +116,13 @@ const InnerPublicFileUploadPage: React.FC = () => {
116116
for (let i = 0; i < items.length; i++) {
117117
if (items[i].kind === "file") {
118118
const file = items[i].getAsFile();
119-
if (!file || !file.size || !file.type.startsWith("image/")) continue;
119+
if (!file || !file.size || !(file.type.startsWith("image/") || file.type === "application/json")) continue;
120120

121121
handleFile(file);
122122
return;
123123
}
124124
}
125-
addSnackbar("클립보드에 이미지 파일이 없습니다. 이미지 파일을 선택해주세요.", "error");
125+
addSnackbar("클립보드에 이미지 또는 JSON 파일이 없습니다. 이미지 또는 JSON 파일을 선택해주세요.", "error");
126126
}
127127
},
128128
[handleFile]
@@ -196,7 +196,7 @@ const InnerPublicFileUploadPage: React.FC = () => {
196196
또는 <kbd>Ctrl</kbd>+<kbd>V</kbd>로 사진을 붙여넣어 주세요!
197197
<br />
198198
<ul>
199-
<li>이미지 파일만 업로드가 가능합니다.</li>
199+
<li>이미지와 JSON 파일만 업로드가 가능합니다.</li>
200200
<li>업로드 후에는 파일을 수정할 수 없습니다.</li>
201201
<li>파일은 공개적으로 접근 가능한 URL로 제공됩니다.</li>
202202
</ul>

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"notistack": "^3.0.2",
4848
"react": "^19.1.0",
4949
"react-dom": "^19.1.0",
50+
"react-lottie": "^1.2.10",
5051
"react-router-dom": "^7.6.0",
5152
"remark-gfm": "^4.0.1",
5253
"remeda": "^2.21.5"
@@ -65,6 +66,7 @@
6566
"@types/node": "^22.15.18",
6667
"@types/react": "^19.1.4",
6768
"@types/react-dom": "^19.1.5",
69+
"@types/react-lottie": "^1.2.10",
6870
"@typescript-eslint/parser": "^8.32.1",
6971
"@vitejs/plugin-react": "^4.4.1",
7072
"csstype": "^3.1.3",

packages/common/src/components/dynamic_route.tsx

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CircularProgress } from "@mui/material";
1+
import { Box, CircularProgress, Stack, Theme } from "@mui/material";
22
import { ErrorBoundary, Suspense } from "@suspensive/react";
33
import { AxiosError, AxiosResponse } from "axios";
44
import * as React from "react";
@@ -12,17 +12,22 @@ import Utils from "../utils";
1212
import { ErrorFallback } from "./error_handler";
1313
import { MDXRenderer } from "./mdx";
1414

15-
const InitialPageStyle: React.CSSProperties = {
16-
width: "100%",
17-
display: "flex",
18-
justifyContent: "center",
19-
alignItems: "center",
20-
flexDirection: "column",
21-
};
15+
const initialPageStyle: (additionalStyle: React.CSSProperties) => (theme: Theme) => React.CSSProperties =
16+
(additionalStyle) => (theme) => ({
17+
width: "100%",
18+
marginTop: theme.spacing(8),
19+
display: "flex",
20+
justifyContent: "center",
21+
alignItems: "center",
22+
flexDirection: "column",
23+
...additionalStyle,
24+
});
2225

23-
const InitialSectionStyle: React.CSSProperties = {
24-
width: "100%",
25-
};
26+
const initialSectionStyle: (additionalStyle: React.CSSProperties) => (theme: Theme) => React.CSSProperties =
27+
(additionalStyle) => () => ({
28+
width: "100%",
29+
...additionalStyle,
30+
});
2631

2732
const LoginRequired: React.FC = () => <>401 Login Required</>;
2833
const PermissionDenied: React.FC = () => <>403 Permission Denied</>;
@@ -57,15 +62,21 @@ export const PageRenderer: React.FC<{ id?: string }> = ErrorBoundary.with(
5762
Suspense.with({ fallback: <CircularProgress /> }, ({ id }) => {
5863
const backendClient = Hooks.BackendAPI.useBackendClient();
5964
const { data } = Hooks.BackendAPI.usePageQuery(backendClient, id || "");
65+
const commonStackStyle = {
66+
justifyContent: "flex-start",
67+
alignItems: "center",
68+
};
6069

6170
return (
62-
<div style={{ ...InitialPageStyle, ...Utils.parseCss(data.css) }}>
71+
<Stack {...commonStackStyle} sx={initialPageStyle(Utils.parseCss(data.css))}>
6372
{data.sections.map((s) => (
64-
<div style={{ ...InitialSectionStyle, ...Utils.parseCss(s.css) }} key={s.id}>
65-
<MDXRenderer text={s.body} />
66-
</div>
73+
<Stack {...commonStackStyle} sx={initialSectionStyle(Utils.parseCss(s.css))} key={s.id}>
74+
<Box sx={{ maxWidth: "1000px" }}>
75+
<MDXRenderer text={s.body} />
76+
</Box>
77+
</Stack>
6778
))}
68-
</div>
79+
</Stack>
6980
);
7081
})
7182
);

packages/common/src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
RouteRenderer as RouteRendererComponent,
77
} from "./dynamic_route";
88
import { ErrorFallback as ErrorFallbackComponent } from "./error_handler";
9+
import { LottieDebugPanel as LottieDebugPanelComponent } from "./lottie";
910
import { MDXRenderer as MDXRendererComponent } from "./mdx";
1011
import type { MapPropType as MapComponentPropType } from "./mdx_components/map";
1112
import { Map as MapComponent } from "./mdx_components/map";
@@ -25,6 +26,7 @@ namespace Components {
2526
export const MDXEditor = MDXEditorComponent;
2627
export const MDXRenderer = MDXRendererComponent;
2728
export const PythonKorea = PythonKoreaComponent;
29+
export const LottieDebugPanel = LottieDebugPanelComponent;
2830
export const ErrorFallback = ErrorFallbackComponent;
2931

3032
export namespace MDX {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Pause, PlayArrow, Stop } from "@mui/icons-material";
2+
import { Box, FormControlLabel, IconButton, Stack, Switch } from "@mui/material";
3+
import * as React from "react";
4+
import Lottie from "react-lottie";
5+
6+
type PlayState = "playing" | "paused" | "stopped";
7+
8+
type LottiePlayerStateType = {
9+
loop: boolean;
10+
isStopped: boolean;
11+
isPaused: boolean;
12+
};
13+
14+
export const LottieDebugPanel: React.FC<{ animationData: unknown }> = ({ animationData }) => {
15+
const [playerState, setPlayerState] = React.useState<LottiePlayerStateType>({
16+
loop: true,
17+
isStopped: false,
18+
isPaused: false,
19+
});
20+
21+
const toggleLoop = () => setPlayerState((ps) => ({ ...ps, loop: !ps.loop }));
22+
const setPlayState = (playState: PlayState) => {
23+
if (playState === "playing") setPlayerState((ps) => ({ ...ps, isStopped: false, isPaused: false }));
24+
if (playState === "paused") setPlayerState((ps) => ({ ...ps, isStopped: false, isPaused: true }));
25+
if (playState === "stopped") setPlayerState((ps) => ({ ...ps, isStopped: true, isPaused: true }));
26+
};
27+
28+
const stop = () => setPlayState("stopped");
29+
const togglePause = () => setPlayState(playerState.isPaused ? "playing" : "paused");
30+
31+
return (
32+
<Stack direction="column">
33+
<Box>
34+
<Lottie
35+
isStopped={playerState.isStopped}
36+
isPaused={playerState.isPaused}
37+
options={{
38+
animationData,
39+
loop: playerState.loop,
40+
autoplay: true,
41+
rendererSettings: { preserveAspectRatio: "xMidYMid slice" },
42+
}}
43+
/>
44+
</Box>
45+
<Stack direction="row" spacing={2}>
46+
<IconButton onClick={togglePause} children={playerState.isPaused ? <PlayArrow /> : <Pause />} />
47+
<IconButton onClick={stop} children={<Stop />} />
48+
<FormControlLabel control={<Switch checked={playerState.loop} onChange={toggleLoop} />} label="반복 재생" />
49+
</Stack>
50+
</Stack>
51+
);
52+
};

packages/common/src/components/mdx.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import Hooks from "../hooks";
2222
import { ErrorFallback } from "./error_handler";
2323
import { rtrim } from "../utils/string";
2424
import { StyledDivider } from "./mdx_components/styled_divider";
25+
import { SubContentContainer } from "./mdx_components/sub_content_container";
2526

2627
const REGISTERED_KEYWORDS = [
2728
"import",
@@ -46,12 +47,12 @@ const REGISTERED_KEYWORDS = [
4647
];
4748

4849
const CustomMDXComponents: MDXComponents = {
49-
h1: (props) => <h1 {...props} />,
50-
h2: (props) => <h2 {...props} />,
51-
h3: (props) => <h3 {...props} />,
52-
h4: (props) => <h4 {...props} />,
53-
h5: (props) => <h5 {...props} />,
54-
h6: (props) => <h6 {...props} />,
50+
h1: (props) => <h1 style={{ margin: 0 }} {...props} />,
51+
h2: (props) => <h2 style={{ margin: 0 }} {...props} />,
52+
h3: (props) => <h3 style={{ margin: 0 }} {...props} />,
53+
h4: (props) => <h4 style={{ margin: 0 }} {...props} />,
54+
h5: (props) => <h5 style={{ margin: 0 }} {...props} />,
55+
h6: (props) => <h6 style={{ margin: 0 }} {...props} />,
5556
strong: (props) => <strong {...props} />,
5657
hr: (props) => <StyledDivider {...props} />,
5758
em: (props) => <em {...props} />,
@@ -69,6 +70,7 @@ const CustomMDXComponents: MDXComponents = {
6970
tr: (props) => <TableRow {...props} />,
7071
th: (props) => <TableCell {...props} />,
7172
td: (props) => <TableCell {...props} />,
73+
Content: (props) => <SubContentContainer {...props} />,
7274
};
7375

7476
const lineFormatterForMDX = (line: string) => {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Box } from "@mui/material";
2+
import * as React from "react";
3+
4+
export const SubContentContainer: React.FC<React.PropsWithChildren> = ({ children }) => (
5+
<Box sx={(theme) => ({ px: theme.spacing(2), py: theme.spacing(4) })}>{children}</Box>
6+
);

0 commit comments

Comments
 (0)