Skip to content

Commit 5864c5e

Browse files
committed
feat: section의 CSS도 편집 가능하게 수정
1 parent 757b904 commit 5864c5e

3 files changed

Lines changed: 90 additions & 11 deletions

File tree

apps/pyconkr-admin/src/components/pages/page/editor.tsx

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,20 @@ import { useBackendAdminClient, useBulkUpdatePageSectionsMutation, useListPageSe
33
import { useCommonContext } from "@frontend/common/hooks/useCommonContext";
44
import { PageSectionSchema } from "@frontend/common/schemas/backendAdminAPI";
55
import { Add, Delete, OpenInNew } from "@mui/icons-material";
6-
import { Box, Button, ButtonProps, CircularProgress, Divider, Stack, Tab, Tabs, ThemeProvider } from "@mui/material";
6+
import {
7+
Box,
8+
Button,
9+
ButtonProps,
10+
CircularProgress,
11+
Divider,
12+
IconButton,
13+
Stack,
14+
Tab,
15+
Tabs,
16+
TextField,
17+
ThemeProvider,
18+
Typography,
19+
} from "@mui/material";
720
import { ErrorBoundary, Suspense } from "@suspensive/react";
821
import { commands } from "@uiw/react-md-editor";
922
import { FC, SyntheticEvent, useState } from "react";
@@ -16,6 +29,10 @@ import { addErrorSnackbar } from "@apps/pyconkr-admin/utils/snackbar";
1629

1730
type SectionType = PageSectionSchema;
1831

32+
// 섹션 편집 탭. "css"는 dynamic_route에서 parseCss로 파싱되는 React 스타일 객체(JSON 문자열)를 편집함.
33+
type SectionTab = "ko" | "en" | "css";
34+
const SECTION_TABS: SectionTab[] = ["ko", "en", "css"];
35+
1936
type CommonSectionEditorPropType = {
2037
disabled?: boolean;
2138
onInsertNewSection: () => void;
@@ -27,8 +44,13 @@ type SectionTextEditorPropType = CommonSectionEditorPropType & {
2744
onChange: (value?: string) => void;
2845
};
2946

47+
type SectionCssEditorPropType = CommonSectionEditorPropType & {
48+
defaultValue?: string | null;
49+
onChange: (value: string) => void;
50+
};
51+
3052
type SectionEditorPropType = CommonSectionEditorPropType & {
31-
language: "ko" | "en";
53+
tab: SectionTab;
3254
defaultValue: SectionType;
3355
onChange: (value: SectionType) => void;
3456
};
@@ -60,18 +82,72 @@ const SectionTextEditor: FC<SectionTextEditorPropType> = ({ disabled, defaultVal
6082
);
6183
};
6284

63-
const SectionEditorField: FC<SectionEditorPropType> = ({ language, disabled, defaultValue, onInsertNewSection, onChange, onDelete }) => {
64-
const onFieldChange = (key: "body_ko" | "body_en", value?: string) => onChange({ ...defaultValue, [key]: value });
85+
// 빈 문자열은 유효(스타일 없음)로 간주하고, 유효하지 않으면 파싱 에러 메시지를 반환.
86+
const getJsonError = (value: string): string | null => {
87+
if (!value.trim()) return null;
88+
try {
89+
JSON.parse(value);
90+
return null;
91+
} catch (e) {
92+
return (e as Error).message;
93+
}
94+
};
95+
96+
const SectionCssEditor: FC<SectionCssEditorPropType> = ({ disabled, defaultValue, onInsertNewSection, onChange, onDelete }) => {
97+
const value = defaultValue ?? "";
98+
const jsonError = getJsonError(value);
6599

66100
return (
67-
<Stack direction="row" sx={{ flexGrow: 1, width: "100%", height: "100%", maxWidth: "100%" }}>
101+
<Stack spacing={1} sx={{ flexGrow: 1, width: "100%", height: "100%", maxWidth: "100%" }}>
102+
<Stack direction="row" justifyContent="space-between" alignItems="center">
103+
<Typography variant="subtitle2" color="text.secondary">
104+
섹션 CSS (React 스타일 객체 · JSON 형식)
105+
</Typography>
106+
<IconButton size="small" onClick={onDelete} disabled={disabled} aria-label="Delete">
107+
<Delete style={{ fontSize: 16 }} />
108+
</IconButton>
109+
</Stack>
110+
<TextField
111+
multiline
112+
minRows={8}
113+
fullWidth
114+
disabled={disabled}
115+
value={value}
116+
onChange={(e) => onChange(e.target.value)}
117+
error={Boolean(jsonError)}
118+
helperText={jsonError ? `JSON 파싱 오류: ${jsonError}` : '예: {"backgroundColor": "#ffffff", "padding": "16px"}'}
119+
slotProps={{ input: { sx: { fontFamily: "monospace", fontSize: 13, alignItems: "flex-start" } } }}
120+
/>
121+
<Button size="small" onClick={onInsertNewSection} startIcon={<Add />}>
122+
여기에 섹션 추가
123+
</Button>
124+
</Stack>
125+
);
126+
};
127+
128+
const SectionEditorField: FC<SectionEditorPropType> = ({ tab, disabled, defaultValue, onInsertNewSection, onChange, onDelete }) => {
129+
const child =
130+
tab === "css" ? (
131+
<SectionCssEditor
132+
disabled={disabled}
133+
onInsertNewSection={onInsertNewSection}
134+
onDelete={onDelete}
135+
defaultValue={defaultValue?.css}
136+
onChange={(css) => onChange({ ...defaultValue, css })}
137+
/>
138+
) : (
68139
<SectionTextEditor
69140
disabled={disabled}
70141
onInsertNewSection={onInsertNewSection}
71142
onDelete={onDelete}
72-
defaultValue={defaultValue?.[`body_${language}`] || undefined}
73-
onChange={(text) => onFieldChange(`body_${language}`, text)}
143+
defaultValue={defaultValue?.[`body_${tab}`] || undefined}
144+
onChange={(text) => onChange({ ...defaultValue, [`body_${tab}`]: text })}
74145
/>
146+
);
147+
148+
return (
149+
<Stack direction="row" sx={{ flexGrow: 1, width: "100%", height: "100%", maxWidth: "100%" }}>
150+
{child}
75151
</Stack>
76152
);
77153
};
@@ -154,6 +230,7 @@ export const AdminCMSPageEditor: FC = ErrorBoundary.with(
154230
<Tabs orientation="vertical" value={editorState.tab} onChange={setTab} scrollButtons={false}>
155231
<Tab wrapped label="한국어" />
156232
<Tab wrapped label="영어" />
233+
<Tab wrapped label="CSS" />
157234
</Tabs>
158235
<Stack sx={{ width: "100%", height: "100%", maxWidth: "100%" }}>
159236
<Button size="small" onClick={insertNewSection(0)} startIcon={<Add />}>
@@ -163,7 +240,7 @@ export const AdminCMSPageEditor: FC = ErrorBoundary.with(
163240
<SectionEditorField
164241
key={section.id || index}
165242
defaultValue={section}
166-
language={editorState.tab === 0 ? "ko" : "en"}
243+
tab={SECTION_TABS[editorState.tab]}
167244
onInsertNewSection={insertNewSection(index + 1)}
168245
onChange={onSectionDataChange(index)}
169246
onDelete={deleteSection(index)}

packages/common/src/components/mdx_editor.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ type PublicFileType = {
9696
const ImageSelector: GroupOptions["children"] = Suspense.with({ fallback: <CircularProgress /> }, ({ close, getState, textApi }) => {
9797
const urlInputRef = useRef<HTMLInputElement>(null);
9898
const backendAdminAPIClient = BackendAdminAPI.useBackendAdminClient();
99-
const { data } = BackendAdminAPI.useListQuery<PublicFileType>(backendAdminAPIClient, "file", "publicfile");
99+
// publicfile 뷰셋은 DRF 페이지네이션을 사용하므로 응답이 배열이 아닌 {results, ...} 객체임.
100+
// useListAutoQuery가 페이지네이션 여부를 정규화해 항상 items 배열을 돌려줌.
101+
const { data } = BackendAdminAPI.useListAutoQuery<PublicFileType>(backendAdminAPIClient, "file", "publicfile");
100102
const [widgetState, setWidgetState] = useState<ImageSelectorWidgetStateType>({ tab: 0 });
101103
const setTab = (_: SyntheticEvent, tab: number) => setWidgetState((ps) => ({ ...ps, tab }));
102104
const setImageUrl = (selectedImageUrl?: string) => setWidgetState((ps) => ({ ...ps, selectedImageUrl }));
@@ -141,7 +143,7 @@ const ImageSelector: GroupOptions["children"] = Suspense.with({ fallback: <Circu
141143
업로드 된 사진 중 선택
142144
</Typography>
143145
<Grid>
144-
{data
146+
{data.items
145147
.filter((item) => item.mimetype?.startsWith("image/"))
146148
.map((item) => ({ ...item, file: item.file.split("?")[0] })) // Remove query parameters if any
147149
.map((item) => {

packages/common/src/schemas/backendAdminAPI.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export type PublicFileSchema = {
6363
export type PageSectionSchema = {
6464
id?: string;
6565
order: number;
66-
css: string;
66+
css: string | null;
6767
body_ko: string | null;
6868
body_en: string | null;
6969
};

0 commit comments

Comments
 (0)