Skip to content

Commit 222b820

Browse files
committed
feat: sitemap 에디터 추가
1 parent 25ffaca commit 222b820

File tree

2 files changed

+251
-1
lines changed

2 files changed

+251
-1
lines changed
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import * as Common from "@frontend/common";
2+
import { Add, Delete, Edit, Save } from "@mui/icons-material";
3+
import {
4+
Box,
5+
Button,
6+
CircularProgress,
7+
Dialog,
8+
DialogActions,
9+
DialogContent,
10+
DialogTitle,
11+
IconButton,
12+
IconButtonProps,
13+
Stack,
14+
styled,
15+
Tooltip,
16+
} from "@mui/material";
17+
import { ErrorBoundary, Suspense } from "@suspensive/react";
18+
import { enqueueSnackbar, OptionsObject } from "notistack";
19+
import * as React from "react";
20+
import { GroupOptions, ReactSortable, SortableEvent, SortableOptions } from "react-sortablejs";
21+
22+
import BackendAdminAPISchemas from "../../../../../../packages/common/src/schemas/backendAdminAPI";
23+
import { BackendAdminSignInGuard } from "../../elements/admin_signin_guard";
24+
import { AdminEditor } from "../../layouts/admin_editor";
25+
26+
type FlatSiteMap = BackendAdminAPISchemas.FlattenedSiteMapSchema;
27+
type FlatSiteMapObj = Record<string, FlatSiteMap>;
28+
type NestedSiteMap = BackendAdminAPISchemas.NestedSiteMapSchema;
29+
type FlatNestedSiteMap = Record<string, NestedSiteMap>;
30+
31+
const DepthColorMap: React.CSSProperties["backgroundColor"][] = [
32+
"rgba(255, 229, 204, 1)",
33+
"rgba(255, 255, 204, 1)",
34+
"rgba(204, 255, 204, 1)",
35+
"rgba(204, 229, 255, 1)",
36+
"rgba(204, 204, 255, 1)",
37+
"rgba(229, 204, 255, 1)",
38+
"rgba(255, 204, 229, 1)",
39+
"rgba(255, 229, 229, 1)",
40+
"rgba(229, 255, 204, 1)",
41+
"rgba(204, 255, 229, 1)",
42+
"rgba(229, 204, 204, 1)",
43+
"rgba(255, 204, 204, 1)",
44+
];
45+
46+
type StyledNodePropType = {
47+
hidden?: boolean;
48+
selected?: boolean;
49+
depth: number;
50+
};
51+
52+
const StyledNode = styled(Stack)<StyledNodePropType>(({ hidden, selected, depth, theme }) => ({
53+
padding: theme.spacing(0.5),
54+
margin: theme.spacing(0.25, 0),
55+
border: selected ? "2px dashed #000" : "1px solid #ccc",
56+
borderRadius: theme.shape.borderRadius,
57+
fontSize: theme.typography.subtitle2.fontSize,
58+
backgroundColor: hidden ? "rgba(192, 192, 192, 0.75)" : selected ? "#bbb" : DepthColorMap[depth % DepthColorMap.length],
59+
zIndex: depth + 1,
60+
}));
61+
62+
const RouteCode = styled("code")(({ theme }) => ({
63+
fontSize: theme.typography.body2.fontSize,
64+
color: theme.palette.text.secondary,
65+
padding: theme.spacing(0.5),
66+
borderRadius: theme.shape.borderRadius,
67+
}));
68+
69+
type NodePropType = {
70+
node: NestedSiteMap;
71+
index: number[];
72+
parentRoute: string;
73+
depth: number;
74+
};
75+
76+
type TooltipBtnPropType = IconButtonProps & {
77+
tooltip: string;
78+
icon: React.ReactElement<{ fontSize: string }>;
79+
};
80+
81+
const TooltipBtn: React.FC<TooltipBtnPropType> = ({ tooltip, icon, ...props }) => (
82+
<Tooltip title={tooltip} arrow children={<IconButton size="small" {...props} children={React.cloneElement(icon, { fontSize: "small" })} />} />
83+
);
84+
85+
type InnerSiteMapStateType = {
86+
parentSiteMapId?: string;
87+
editorSiteMapId?: string;
88+
deleteSiteMapId?: string;
89+
flatSiteMap: FlatSiteMap[];
90+
isMutating?: boolean;
91+
};
92+
93+
const ModifyDetectionFields: (keyof FlatSiteMap)[] = ["order", "parent_sitemap"];
94+
95+
const InnerSiteMapList: React.FC = ErrorBoundary.with(
96+
{ fallback: Common.Components.ErrorFallback },
97+
Suspense.with({ fallback: <CircularProgress /> }, () => {
98+
const backendAdminAPIClient = Common.Hooks.BackendAdminAPI.useBackendAdminClient();
99+
const { data } = Common.Hooks.BackendAdminAPI.useListQuery<FlatSiteMap>(backendAdminAPIClient, "cms", "sitemap");
100+
const originalFlatSiteMapObj = Object.values(data).reduce((acc, item) => ({ ...acc, [item.id]: item }), {} as FlatSiteMapObj);
101+
const deleteMutation = Common.Hooks.BackendAdminAPI.useRemovePreparedMutation(backendAdminAPIClient, "cms", "sitemap");
102+
const { mutateAsync: updateMutationAsync } = Common.Hooks.BackendAdminAPI.useUpdatePreparedMutation(backendAdminAPIClient, "cms", "sitemap");
103+
104+
const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
105+
enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
106+
107+
const [state, setState] = React.useState<InnerSiteMapStateType>({ flatSiteMap: data });
108+
const nestedSiteMap = Common.Utils.buildNestedSiteMap<FlatSiteMap>(state.flatSiteMap)[""];
109+
const childrenFlatSiteMap = Common.Utils.buildFlatSiteMap<NestedSiteMap>(nestedSiteMap);
110+
const childrenFlatSiteMapObj = Object.values(childrenFlatSiteMap).reduce((acc, item) => ({ ...acc, [item.id]: item }), {} as FlatNestedSiteMap);
111+
112+
React.useEffect(() => setState((ps) => ({ ...ps, flatSiteMap: data })), [data]);
113+
114+
const setEditorSiteMapId = (editorSiteMapId: string | undefined) => setState((ps) => ({ ...ps, editorSiteMapId }));
115+
const setDeleteSiteMapId = (deleteSiteMapId: string | undefined) => setState((ps) => ({ ...ps, deleteSiteMapId }));
116+
const setParentSiteMapId = (parentSiteMapId: string | undefined) => setState((ps) => ({ ...ps, parentSiteMapId, editorSiteMapId: "add" }));
117+
const closeEditor = () => setEditorSiteMapId(undefined);
118+
const deleteSiteMap = (id: string) => deleteMutation.mutate(id, { onSuccess: () => setDeleteSiteMapId(undefined) });
119+
const setIsMutating = (isMutating: boolean) => setState((ps) => ({ ...ps, isMutating }));
120+
121+
const disabled = deleteMutation.isPending;
122+
const editorId = state.editorSiteMapId === "add" ? undefined : state.editorSiteMapId;
123+
const editorContext = state.parentSiteMapId ? { parent_sitemap: state.parentSiteMapId } : undefined;
124+
125+
const resetFlatSiteMap = () => setState((ps) => ({ ...ps, flatSiteMap: data }));
126+
const applyChanges = () => {
127+
addSnackbar("변경 사항을 적용하는 중입니다...\n조금 시간이 걸릴 수 있어요, 잠시만 기다려주세요.", "info");
128+
setIsMutating(true);
129+
const modified = Object.values(childrenFlatSiteMapObj).filter((item) =>
130+
ModifyDetectionFields.some((field) => item[field] !== originalFlatSiteMapObj?.[item.id]?.[field])
131+
);
132+
133+
if (modified.length > 0) {
134+
const updateMutations = modified.map((sitemap) => updateMutationAsync(sitemap));
135+
Promise.all(updateMutations)
136+
.then(() => {
137+
setIsMutating(false);
138+
addSnackbar("변경 사항이 성공적으로 적용되었습니다.", "success");
139+
})
140+
.catch((error) => {
141+
setIsMutating(false);
142+
addSnackbar(`변경 사항 적용에 실패했습니다:\n${error.message}`, "error");
143+
});
144+
}
145+
};
146+
147+
const onAdd = (evt: SortableEvent) => {
148+
const dragged = childrenFlatSiteMapObj[evt.from.id];
149+
const target = childrenFlatSiteMapObj[evt.to.id];
150+
const parent = childrenFlatSiteMapObj[target.parent_sitemap || ""];
151+
152+
let currTargetIdx = parent.children.findIndex((child) => child.id === target.id);
153+
const isForwardOfTargetId = evt.newDraggableIndex === 0;
154+
if (!isForwardOfTargetId) currTargetIdx += 1;
155+
156+
const oldCldn = parent.children.filter((child) => child.id !== dragged.id);
157+
const newFlatSiteMapObj = {
158+
...childrenFlatSiteMapObj,
159+
[dragged.id]: { ...dragged, parent_sitemap: parent.id },
160+
[parent.id]: { ...parent, children: [...oldCldn.slice(0, currTargetIdx), dragged, ...oldCldn.slice(currTargetIdx)] },
161+
};
162+
newFlatSiteMapObj[parent.id].children.forEach((child, order) => {
163+
newFlatSiteMapObj[child.id].order = order;
164+
child.order = order;
165+
});
166+
167+
setState((ps) => ({ ...ps, flatSiteMap: Object.values(newFlatSiteMapObj) }));
168+
};
169+
170+
const CommonSortableOptions: SortableOptions = {
171+
animation: 150,
172+
fallbackOnBody: true,
173+
swapThreshold: 0.65,
174+
ghostClass: "ghost",
175+
group: "shared",
176+
sort: true,
177+
disabled,
178+
onAdd,
179+
};
180+
181+
const Node: React.FC<NodePropType> = ({ node, index, parentRoute, depth }) => {
182+
const isSelected = state.editorSiteMapId === node.id;
183+
const route = parentRoute || node.route_code ? `${parentRoute}/${node.route_code}` : "";
184+
const group: GroupOptions = { pull: depth !== 0, put: depth !== 0, name: node.id };
185+
186+
return (
187+
<ReactSortable id={node.id} list={childrenFlatSiteMap} setList={() => {}} onAdd={onAdd} {...CommonSortableOptions} group={group}>
188+
<StyledNode key={node.id} hidden={node.hide} selected={isSelected} depth={depth}>
189+
<Stack direction="row" spacing={1} justifyContent="flex-end" alignItems="center" sx={{ width: "100%" }}>
190+
<Stack direction="row" spacing={1} justifyContent="space-between" alignItems="center" sx={{ flexGrow: 1 }}>
191+
<RouteCode>{route || "/"}</RouteCode>
192+
<Box>{node.name_ko + (node.hide ? " (숨겨짐)" : "")}</Box>
193+
</Stack>
194+
<TooltipBtn disabled={disabled} icon={<Add />} onClick={() => setParentSiteMapId(node.id)} tooltip="하위에 새 사이트맵 추가" />
195+
<TooltipBtn disabled={disabled} icon={<Edit />} onClick={() => setEditorSiteMapId(node.id)} tooltip="사이트맵 편집" />
196+
<TooltipBtn disabled={disabled} icon={<Delete />} onClick={() => setDeleteSiteMapId(node.id)} tooltip="사이트맵 삭제" />
197+
</Stack>
198+
{Object.values(node.children).map((childNode, i) => (
199+
<Node key={childNode.id} node={childNode} index={[...index, i]} parentRoute={route} depth={depth + 1} />
200+
))}
201+
</StyledNode>
202+
</ReactSortable>
203+
);
204+
};
205+
206+
return (
207+
<>
208+
<Dialog open={state.deleteSiteMapId !== undefined} onClose={() => setDeleteSiteMapId(undefined)} maxWidth="xs" fullWidth>
209+
<DialogTitle>삭제</DialogTitle>
210+
<DialogContent>정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.</DialogContent>
211+
<DialogActions>
212+
<Button onClick={() => setDeleteSiteMapId(undefined)} disabled={disabled} children="취소" />
213+
<Button onClick={() => deleteSiteMap(state.deleteSiteMapId!)} color="error" disabled={disabled} children="삭제" />
214+
</DialogActions>
215+
</Dialog>
216+
<Stack direction="row" spacing={2} sx={{ width: "100%", height: "100%" }}>
217+
<Stack sx={{ flexGrow: 1, width: "40%", height: "100%" }} spacing={2}>
218+
<Stack direction="row" spacing={1} alignItems="center" justifyContent="flex-end">
219+
<Button variant="outlined" color="error" startIcon={<Delete />} onClick={resetFlatSiteMap} children="초기화" />
220+
<Button variant="outlined" color="primary" startIcon={<Save />} onClick={applyChanges} children="반영" />
221+
</Stack>
222+
<Node node={nestedSiteMap} index={[0]} parentRoute="" depth={0} />
223+
</Stack>
224+
<Box sx={{ flexGrow: 1, width: "60%", height: "100%" }}>
225+
{state.editorSiteMapId && (
226+
<AdminEditor
227+
app="cms"
228+
resource="sitemap"
229+
id={editorId}
230+
onClose={closeEditor}
231+
context={editorContext}
232+
hidingFields={["parent_sitemap", "order"]}
233+
/>
234+
)}
235+
</Box>
236+
</Stack>
237+
</>
238+
);
239+
})
240+
);
241+
242+
export const SiteMapList: React.FC = () => (
243+
<BackendAdminSignInGuard>
244+
<InnerSiteMapList />
245+
</BackendAdminSignInGuard>
246+
);

apps/pyconkr-admin/src/routes.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AccountManagementPage } from "./components/pages/account/manage";
88
import { SignInPage } from "./components/pages/account/sign_in";
99
import { PublicFileUploadPage } from "./components/pages/file/upload";
1010
import { AdminCMSPageEditor } from "./components/pages/page/editor";
11+
import { SiteMapList } from "./components/pages/sitemap/list";
1112
import { AdminUserExtEditor } from "./components/pages/user/editor";
1213

1314
export const RouteDefinitions: RouteDef[] = [
@@ -72,7 +73,7 @@ export const RouteDefinitions: RouteDef[] = [
7273

7374
const buildDefaultRoutes = (app: string, resource: string) => {
7475
return {
75-
[`/${app}/${resource}/`]: <AdminList app={app} resource={resource} />,
76+
[`/${app}/${resource}`]: <AdminList app={app} resource={resource} />,
7677
[`/${app}/${resource}/create`]: <AdminEditorCreateRoutePage app={app} resource={resource} />,
7778
[`/${app}/${resource}/:id`]: <AdminEditorModifyRoutePage app={app} resource={resource} />,
7879
};
@@ -97,4 +98,7 @@ export const RegisteredRoutes = {
9798
"/account": <AccountRedirectPage />,
9899
"/account/sign-in": <SignInPage />,
99100
"/account/manage": <AccountManagementPage />,
101+
"/cms/sitemap": <SiteMapList />,
102+
"/cms/sitemap/create": <SiteMapList />,
103+
"/cms/sitemap/:id": <SiteMapList />,
100104
};

0 commit comments

Comments
 (0)