Skip to content

Commit 654fb5f

Browse files
committed
feat: @apps/pyconkr-admin 추가
1 parent ca202e9 commit 654fb5f

27 files changed

+1484
-0
lines changed

apps/pyconkr-admin/index.html

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charSet="UTF-8" />
5+
<base href="/" />
6+
<link rel="icon" href="/favicon.ico" sizes="32x32">
7+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
8+
<link rel="apple-touch-icon" href="/favicon-180.png">
9+
10+
<meta name="theme-color" content="#fff" />
11+
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" />
12+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#fff" />
13+
14+
<meta name="msapplication-navbutton-color" content="#fff" />
15+
<meta name="msapplication-TileColor" content="#fff" />
16+
<meta name="msapplication-TileImage" content="/favicon-192.png" />
17+
<meta name="application-name" content="PyCon KR Admin" />
18+
<meta name="apple-mobile-web-app-title" content="PyCon KR Admin" />
19+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
20+
<meta name="apple-mobile-web-app-capable" content="yes" />
21+
<meta name="mobile-web-app-capable" content="yes" />
22+
<!-- https://developers.google.com/web/fundamentals/web-app-manifest/ -->
23+
<link rel="manifest" href="/site.webmanifest" />
24+
25+
<meta name="viewport" content="width=device-width,
26+
height=device-height,
27+
target-densitydpi=device-dpi,
28+
initial-scale=1.0,
29+
minimum-scale=1.0,
30+
maximum-scale=1.0,
31+
user-scalable=0,
32+
user-scalable=no,
33+
shrink-to-fit=no" />
34+
<meta name="author" content="PyCon Korea Organizing Team" />
35+
<meta name="description" content="Teaser site for PyCon Korea 2025" />
36+
<meta name="keywords" content="PyCon, Python, Conference, Korea, 2025" />
37+
<meta name="google" content="notranslate" />
38+
<meta name="googlebot" content="index, follow" />
39+
<meta name="robots" content="index, follow" />
40+
41+
<script src="https://cdn.iamport.kr/v1/iamport.js"></script>
42+
43+
<style>
44+
html, body, main {
45+
background-color: #f0f0f0;
46+
}
47+
48+
a {
49+
text-decoration: none;
50+
}
51+
</style>
52+
53+
<title>PyCon Korea Admin</title>
54+
</head>
55+
<body>
56+
<div id="root"></div>
57+
<script type="module" src="./src/main.tsx"></script>
58+
</body>
59+
</html>

apps/pyconkr-admin/package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "@apps/pyconkr-admin",
3+
"dependencies": {
4+
"@frontend/common": "workspace:*",
5+
"@frontend/shop": "workspace:*"
6+
},
7+
"devDependencies": {
8+
"vite": "^6.3.5",
9+
"vite-plugin-mdx": "^3.6.1",
10+
"vite-plugin-mkcert": "^1.17.8",
11+
"vite-plugin-svgr": "^4.3.0"
12+
}
13+
}
3.78 KB
Loading
4.27 KB
Loading
15.2 KB
Loading
1.35 KB
Binary file not shown.
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "PyCon Korea Admin",
3+
"icons": [
4+
{
5+
"src": "favicon-192.png",
6+
"type": "image/png",
7+
"sizes": "192x192"
8+
},
9+
{
10+
"src": "favicon-512.png",
11+
"type": "image/png",
12+
"sizes": "512x512",
13+
"purpose": "maskable"
14+
},
15+
{
16+
"src": "favicon-512.png",
17+
"type": "image/png",
18+
"sizes": "512x512"
19+
}
20+
],
21+
"id": "/",
22+
"start_url": "/",
23+
"scope": "/",
24+
"display": "standalone"
25+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as React from "react";
2+
import { Navigate } from 'react-router-dom';
3+
4+
import { CircularProgress } from "@mui/material";
5+
import { ErrorBoundary, Suspense } from "@suspensive/react";
6+
7+
import * as Common from "@frontend/common";
8+
9+
import { addSnackbar } from '../../utils/snackbar';
10+
11+
export const BackendAdminSignInGuard: React.FC<{ children: React.ReactNode }> = ErrorBoundary.with(
12+
{ fallback: <>로그인 정보를 불러오는 중 문제가 발생했습니다.</> },
13+
Suspense.with(
14+
{ fallback: <CircularProgress /> },
15+
({ children }) => {
16+
const backendAdminAPIClient = Common.Hooks.BackendAdminAPI.useBackendAdminClient();
17+
const { data } = Common.Hooks.BackendAdminAPI.useSignedInUserQuery(backendAdminAPIClient);
18+
19+
if (!data) {
20+
addSnackbar("로그인 후 이용해주세요.", "error");
21+
return <Navigate to="/account/sign-in" replace />;
22+
}
23+
return children;
24+
}
25+
)
26+
)
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import * as React from "react";
2+
import { useNavigate, useParams } from 'react-router-dom';
3+
4+
import { Add, Delete, Edit } from '@mui/icons-material';
5+
import { Box, Button, ButtonProps, CircularProgress, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
6+
import Form, { IChangeEvent } from "@rjsf/core";
7+
import MuiForm from "@rjsf/mui";
8+
import { Field, RJSFSchema, UiSchema } from '@rjsf/utils';
9+
import { customizeValidator } from '@rjsf/validator-ajv8';
10+
import { ErrorBoundary, Suspense } from "@suspensive/react";
11+
import AjvDraft04 from 'ajv-draft-04';
12+
13+
import * as Common from "@frontend/common";
14+
15+
import { addErrorSnackbar, addSnackbar } from '../../utils/snackbar';
16+
import { BackendAdminSignInGuard } from '../elements/admin_signin_guard';
17+
18+
type EditorFormDataEventType = IChangeEvent<Record<string, string>, RJSFSchema, { [k in string]: unknown }>
19+
type onSubmitType = (data: EditorFormDataEventType, event: React.FormEvent<unknown>) => void;
20+
21+
type AppResourceType = { app: string; resource: string; }
22+
type AppResourceIdType = AppResourceType & { id?: string }
23+
type AdminEditorPropsType = React.PropsWithChildren<{
24+
beforeSubmit?: onSubmitType;
25+
afterSubmit?: onSubmitType;
26+
notModifiable?: boolean;
27+
notDeletable?: boolean;
28+
extraActions?: ButtonProps[];
29+
}>
30+
31+
const processFile = (event: React.ChangeEvent<HTMLInputElement>) => {
32+
if (!event.target.files || event.target.files.length === 0)
33+
return Promise.resolve("");
34+
35+
const f = event.target.files[0];
36+
return new Promise((resolve) => {
37+
const reader = new FileReader();
38+
reader.onload = (event) => resolve(event.target ? event.target.result : "");
39+
reader.readAsDataURL(f);
40+
});
41+
}
42+
43+
const FileField: Field = (p) => (
44+
<input
45+
type="file"
46+
required={p.required}
47+
disabled={p.disabled}
48+
defaultValue={p.defaultValue}
49+
onChange={(event) => processFile(event).then(p.onChange)}
50+
/>
51+
);
52+
53+
const ReadOnlyValueField: React.FC<{ name: string; value: unknown; uiSchema: UiSchema }> = ({ name, value, uiSchema }) => {
54+
if (uiSchema[name] && uiSchema[name]["ui:field"] === "file") {
55+
return <Stack spacing={2} alignItems="flex-start">
56+
<img src={value as string} alt={name} style={{ maxWidth: "100%", maxHeight: "600px", objectFit: "contain" }} />
57+
<a href={value as string}>링크</a>
58+
</Stack>
59+
}
60+
61+
return value as string;
62+
}
63+
64+
const InnerAdminEditor: React.FC<AppResourceIdType & AdminEditorPropsType> = ErrorBoundary.with(
65+
{ fallback: Common.Components.ErrorFallback },
66+
Suspense.with(
67+
{ fallback: <CircularProgress /> },
68+
({ app, resource, id, beforeSubmit, afterSubmit, extraActions, notModifiable, notDeletable, children }) => {
69+
const navigate = useNavigate();
70+
const formRef = React.useRef<Form<Record<string, string>, RJSFSchema, { [k in string]: unknown }> | null>(null);
71+
const [formDataState, setFormDataState] = React.useState<Record<string, string> | undefined>(undefined);
72+
const backendAdminClient = Common.Hooks.BackendAdminAPI.useBackendAdminClient();
73+
const { data: schemaInfo } = Common.Hooks.BackendAdminAPI.useSchemaQuery(backendAdminClient, app, resource);
74+
75+
const createMutation = Common.Hooks.BackendAdminAPI.useCreateMutation<Record<string, string>>(backendAdminClient, app, resource);
76+
const modifyMutation = Common.Hooks.BackendAdminAPI.useUpdateMutation<Record<string, string>>(backendAdminClient, app, resource, id || "");
77+
const deleteMutation = Common.Hooks.BackendAdminAPI.useRemoveMutation(backendAdminClient, app, resource, id || "undefined");
78+
const submitMutation = id ? modifyMutation : createMutation;
79+
80+
React.useEffect(() => {
81+
(
82+
async () => {
83+
if (!id) {
84+
setFormDataState({});
85+
return
86+
}
87+
88+
setFormDataState(await Common.BackendAdminAPIs.retrieve<Record<string, string>>(backendAdminClient, app, resource, id)());
89+
}
90+
)()
91+
}, [id]);
92+
93+
const onSubmitButtonClick: React.MouseEventHandler<HTMLButtonElement> = () => formRef.current && formRef.current.submit();
94+
95+
const onSubmitFunc: onSubmitType = (data, event) => {
96+
beforeSubmit && beforeSubmit(data, event);
97+
submitMutation.mutate(data.formData || {}, {
98+
onSuccess: () => {
99+
addSnackbar(id ? '저장했습니다.' : '페이지를 생성했습니다.', 'success');
100+
afterSubmit && afterSubmit(data, event);
101+
102+
if (!id && data.formData?.id) navigate(`/${app}/${resource}/${data.formData?.id}`);
103+
},
104+
onError: addErrorSnackbar,
105+
})
106+
};
107+
108+
const onDeleteFunc = () => {
109+
if (window.confirm("정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.")) {
110+
deleteMutation.mutate(undefined, {
111+
onSuccess: () => {
112+
addSnackbar('삭제했습니다.', 'success');
113+
navigate(`/${app}/${resource}`);
114+
},
115+
onError: addErrorSnackbar,
116+
});
117+
}
118+
};
119+
120+
const goToCreateNew = () => navigate(`/${app}/${resource}/create`)
121+
122+
const writableSchema = Common.Utils.filterWritablePropertiesInJsonSchema(schemaInfo.schema);
123+
const readOnlySchema = Common.Utils.filterReadOnlyPropertiesInJsonSchema(schemaInfo.schema);
124+
const uiSchema: UiSchema = schemaInfo.ui_schema;
125+
const disabled = createMutation.isPending || modifyMutation.isPending || deleteMutation.isPending;
126+
const title = `${app.toUpperCase()} > ${resource.toUpperCase()} > ${id ? "편집: " + id : "새 객체 추가"}`
127+
128+
129+
const handleCtrlSAction: (this: GlobalEventHandlers, ev: KeyboardEvent) => any = (event) => {
130+
if (event.key === "s" && (event.ctrlKey || event.metaKey)) {
131+
console.log("Ctrl+S pressed, executing save action");
132+
event.preventDefault();
133+
event.stopPropagation();
134+
formRef.current?.submit();
135+
}
136+
};
137+
138+
React.useEffect(
139+
() => {
140+
document.addEventListener("keydown", handleCtrlSAction);
141+
return () => {
142+
console.log("Removing event listener for Ctrl+S action");
143+
document.removeEventListener("keydown", handleCtrlSAction);
144+
}
145+
}, [formRef.current]
146+
);
147+
148+
if (formDataState === undefined)
149+
return <CircularProgress />;
150+
151+
return <Box sx={{ flexGrow: 1, width: "100%", minHeight: "100%" }}>
152+
<Typography variant="h5">{title}</Typography>
153+
{
154+
id && <>
155+
<Table>
156+
<TableHead>
157+
<TableRow>
158+
<TableCell>필드</TableCell>
159+
<TableCell></TableCell>
160+
</TableRow>
161+
</TableHead>
162+
<TableBody>
163+
{
164+
Object.keys(readOnlySchema.properties || {}).map((key) => (
165+
<TableRow key={key}>
166+
<TableCell>{key}</TableCell>
167+
<TableCell>
168+
<ReadOnlyValueField name={key} value={formDataState?.[key]} uiSchema={uiSchema} />
169+
</TableCell>
170+
</TableRow>
171+
))
172+
}
173+
</TableBody>
174+
</Table>
175+
<br />
176+
</>
177+
}
178+
<MuiForm
179+
ref={formRef}
180+
schema={writableSchema}
181+
uiSchema={{ ...uiSchema, "ui:submitButtonOptions": { norender: true } }}
182+
validator={customizeValidator({ AjvClass: AjvDraft04 })}
183+
formData={formDataState}
184+
liveValidate
185+
focusOnFirstError
186+
formContext={{ readonlyAsDisabled: true }}
187+
onChange={({ formData }) => setFormDataState(formData)}
188+
onSubmit={onSubmitFunc}
189+
disabled={disabled}
190+
showErrorList={false}
191+
fields={{ file: FileField }}
192+
/>
193+
{children}
194+
<Stack direction="row" spacing={2} sx={{ justifyContent: "flex-end" }}>
195+
{
196+
id
197+
? <>
198+
{(extraActions || []).map((p, i) => <Button key={i} {...p} />)}
199+
<Button variant="outlined" color="info" onClick={goToCreateNew} disabled={disabled} startIcon={<Add />}>새 객체 추가</Button>
200+
{!notDeletable && <Button variant="outlined" color="error" onClick={onDeleteFunc} disabled={disabled} startIcon={<Delete />}>삭제</Button>}
201+
{!notModifiable && <Button variant="contained" color="primary" onClick={onSubmitButtonClick} disabled={disabled} startIcon={<Edit />}>수정</Button>}
202+
</>
203+
: <Button type="submit" variant="contained" color="primary" onClick={onSubmitButtonClick} disabled={disabled} startIcon={<Add />}>새 객체 추가</Button>
204+
}
205+
</Stack>
206+
</Box>
207+
}
208+
)
209+
)
210+
211+
export const AdminEditor: React.FC<AppResourceIdType & AdminEditorPropsType> = (props) => <BackendAdminSignInGuard>
212+
<InnerAdminEditor {...props} />
213+
</BackendAdminSignInGuard>
214+
215+
export const AdminEditorCreateRoutePage: React.FC<AppResourceType & AdminEditorPropsType> = (props) => <AdminEditor {...props} />
216+
217+
export const AdminEditorModifyRoutePage: React.FC<AppResourceType & AdminEditorPropsType> = Suspense.with(
218+
{ fallback: <CircularProgress /> },
219+
(props) => {
220+
const { id } = useParams<{ id?: string }>();
221+
return <AdminEditor {...props} id={id} />
222+
}
223+
)

0 commit comments

Comments
 (0)