diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 1157437..849b33a 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -32,12 +32,14 @@ jobs:
BUMP_RULE: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && '--mode development' || '' }}
AWS_S3_PYCONKR_FRONTEND_BUCKET: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && secrets.AWS_S3_PYCONKR_FRONTEND_BUCKET_DEV || secrets.AWS_S3_PYCONKR_FRONTEND_BUCKET_PROD }}
AWS_S3_PYCONKR_ADMIN_BUCKET: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && secrets.AWS_S3_PYCONKR_ADMIN_BUCKET_DEV || secrets.AWS_S3_PYCONKR_ADMIN_BUCKET_PROD }}
+ AWS_S3_PYCONKR_PARTICIPANT_PORTAL_BUCKET: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && secrets.AWS_S3_PYCONKR_PARTICIPANT_PORTAL_BUCKET_DEV || secrets.AWS_S3_PYCONKR_PARTICIPANT_PORTAL_BUCKET_PROD }}
AWS_CLOUDFRONT_PYCONKR_FRONTEND_DISTRIBUTION_ID: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && secrets.AWS_CLOUDFRONT_PYCONKR_FRONTEND_DISTRIBUTION_ID_DEV || secrets.AWS_CLOUDFRONT_PYCONKR_FRONTEND_DISTRIBUTION_ID_PROD }}
AWS_CLOUDFRONT_PYCONKR_ADMIN_DISTRIBUTION_ID: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && secrets.AWS_CLOUDFRONT_PYCONKR_ADMIN_DISTRIBUTION_ID_DEV || secrets.AWS_CLOUDFRONT_PYCONKR_ADMIN_DISTRIBUTION_ID_PROD }}
+ AWS_CLOUDFRONT_PYCONKR_PARTICIPANT_PORTAL_DISTRIBUTION_ID: ${{ (github.event_name == 'workflow_dispatch' && inputs.WORKFLOW_PHASE || 'dev') == 'dev' && secrets.AWS_CLOUDFRONT_PYCONKR_PARTICIPANT_PORTAL_DISTRIBUTION_ID_DEV || secrets.AWS_CLOUDFRONT_PYCONKR_PARTICIPANT_PORTAL_DISTRIBUTION_ID_PROD }}
strategy:
matrix:
- application: [pyconkr, pyconkr-admin]
+ application: [pyconkr, pyconkr-admin, pyconkr-participant-portal]
include:
- application: pyconkr
aws_s3_bucket_key: AWS_S3_PYCONKR_FRONTEND_BUCKET
@@ -45,6 +47,9 @@ jobs:
- application: pyconkr-admin
aws_s3_bucket_key: AWS_S3_PYCONKR_ADMIN_BUCKET
aws_cloudfront_distribution_key: AWS_CLOUDFRONT_PYCONKR_ADMIN_DISTRIBUTION_ID
+ - application: pyconkr-participant-portal
+ aws_s3_bucket_key: AWS_S3_PYCONKR_PARTICIPANT_PORTAL_BUCKET
+ aws_cloudfront_distribution_key: AWS_CLOUDFRONT_PYCONKR_PARTICIPANT_PORTAL_DISTRIBUTION_ID
steps:
- uses: actions/checkout@master
diff --git a/apps/pyconkr-participant-portal/index.html b/apps/pyconkr-participant-portal/index.html
new file mode 100644
index 0000000..88f63f2
--- /dev/null
+++ b/apps/pyconkr-participant-portal/index.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PyCon Korea Participant Portal
+
+
+
+
+
+
diff --git a/apps/pyconkr-participant-portal/package.json b/apps/pyconkr-participant-portal/package.json
new file mode 100644
index 0000000..7c17c84
--- /dev/null
+++ b/apps/pyconkr-participant-portal/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@apps/pyconkr-participant-portal",
+ "dependencies": {
+ "@frontend/common": "workspace:*",
+ "@frontend/shop": "workspace:*"
+ },
+ "devDependencies": {
+ "vite": "^6.3.5",
+ "vite-plugin-mdx": "^3.6.1",
+ "vite-plugin-mkcert": "^1.17.8",
+ "vite-plugin-svgr": "^4.3.0"
+ }
+}
diff --git a/apps/pyconkr-participant-portal/public/favicon-180.png b/apps/pyconkr-participant-portal/public/favicon-180.png
new file mode 100755
index 0000000..232c395
Binary files /dev/null and b/apps/pyconkr-participant-portal/public/favicon-180.png differ
diff --git a/apps/pyconkr-participant-portal/public/favicon-192.png b/apps/pyconkr-participant-portal/public/favicon-192.png
new file mode 100755
index 0000000..a48d5be
Binary files /dev/null and b/apps/pyconkr-participant-portal/public/favicon-192.png differ
diff --git a/apps/pyconkr-participant-portal/public/favicon-512.png b/apps/pyconkr-participant-portal/public/favicon-512.png
new file mode 100755
index 0000000..785bbdc
Binary files /dev/null and b/apps/pyconkr-participant-portal/public/favicon-512.png differ
diff --git a/apps/pyconkr-participant-portal/public/favicon.ico b/apps/pyconkr-participant-portal/public/favicon.ico
new file mode 100755
index 0000000..9b77659
Binary files /dev/null and b/apps/pyconkr-participant-portal/public/favicon.ico differ
diff --git a/apps/pyconkr-participant-portal/public/favicon.svg b/apps/pyconkr-participant-portal/public/favicon.svg
new file mode 100755
index 0000000..53e26f3
--- /dev/null
+++ b/apps/pyconkr-participant-portal/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/pyconkr-participant-portal/public/site.webmanifest b/apps/pyconkr-participant-portal/public/site.webmanifest
new file mode 100755
index 0000000..b95280a
--- /dev/null
+++ b/apps/pyconkr-participant-portal/public/site.webmanifest
@@ -0,0 +1,25 @@
+{
+ "name": "PyCon Korea Participant Portal",
+ "icons": [
+ {
+ "src": "favicon-192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "favicon-512.png",
+ "type": "image/png",
+ "sizes": "512x512",
+ "purpose": "maskable"
+ },
+ {
+ "src": "favicon-512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "id": "/",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone"
+}
diff --git a/apps/pyconkr-participant-portal/src/App.tsx b/apps/pyconkr-participant-portal/src/App.tsx
new file mode 100644
index 0000000..79a4604
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/App.tsx
@@ -0,0 +1,22 @@
+import * as React from "react";
+import { Navigate, Route, Routes } from "react-router-dom";
+
+import { Layout } from "./components/layout.tsx";
+import { LandingPage } from "./components/pages/home.tsx";
+import { ProfileEditor } from "./components/pages/profile_editor.tsx";
+import { SessionEditor } from "./components/pages/session_editor";
+import { SignInPage } from "./components/pages/signin.tsx";
+import { SponsorEditor } from "./components/pages/sponsor_editor";
+
+export const App: React.FC = () => (
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+);
diff --git a/apps/pyconkr-participant-portal/src/components/dialogs/change_password.tsx b/apps/pyconkr-participant-portal/src/components/dialogs/change_password.tsx
new file mode 100644
index 0000000..e74597a
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/dialogs/change_password.tsx
@@ -0,0 +1,76 @@
+import * as Common from "@frontend/common";
+import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField } from "@mui/material";
+import { enqueueSnackbar, OptionsObject } from "notistack";
+import * as React from "react";
+
+import { useAppContext } from "../../contexts/app_context";
+
+type ChangePasswordDialogProps = {
+ open: boolean;
+ onClose: () => void;
+};
+
+type PasswordFormDataType = {
+ old_password: string;
+ new_password: string;
+ new_password_confirm: string;
+};
+
+export const ChangePasswordDialog: React.FC = ({ open, onClose }) => {
+ const formRef = React.useRef(null);
+ const { language } = useAppContext();
+ const participantPortalClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
+ const changePasswordMutation = Common.Hooks.BackendParticipantPortalAPI.useChangePasswordMutation(participantPortalClient);
+
+ const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
+ enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
+
+ const titleStr = language === "ko" ? "비밀번호 변경" : "Change Password";
+ const prevPasswordLabel = language === "ko" ? "이전 비밀번호" : "Previous Password";
+ const newPasswordLabel = language === "ko" ? "새 비밀번호" : "New Password";
+ const confirmPasswordLabel = language === "ko" ? "새 비밀번호 확인" : "Confirm New Password";
+ const cancelStr = language === "ko" ? "취소" : "Cancel";
+ const submitStr = language === "ko" ? "수정" : "Apply changes";
+ const passwordChangedStr = language === "ko" ? "비밀번호가 성공적으로 변경되었습니다." : "Password changed successfully.";
+
+ const handleSubmit = () => {
+ if (!Common.Utils.isFormValid(formRef.current)) return;
+
+ const formData = Common.Utils.getFormValue({ form: formRef.current });
+ changePasswordMutation.mutate(formData, {
+ onSuccess: () => {
+ addSnackbar(passwordChangedStr, "success");
+ onClose();
+ },
+ onError: (error) => {
+ console.error("Change password failed:", error);
+
+ let errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
+ if (error instanceof Common.BackendAPIs.BackendAPIClientError) errorMessage = error.message;
+
+ addSnackbar(errorMessage, "error");
+ },
+ });
+ };
+
+ const disabled = changePasswordMutation.isPending;
+
+ return (
+
+ );
+};
diff --git a/apps/pyconkr-participant-portal/src/components/dialogs/modification_audit_cancel_confirm.tsx b/apps/pyconkr-participant-portal/src/components/dialogs/modification_audit_cancel_confirm.tsx
new file mode 100644
index 0000000..4976470
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/dialogs/modification_audit_cancel_confirm.tsx
@@ -0,0 +1,86 @@
+import * as Common from "@frontend/common";
+import { Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
+import { enqueueSnackbar, OptionsObject } from "notistack";
+import * as React from "react";
+
+import { useAppContext } from "../../contexts/app_context";
+
+type ModificationAuditCancelConfirmDialogProps = {
+ modificationAuditId: string;
+ open: boolean;
+ onClose: () => void;
+};
+
+export const ModificationAuditCancelConfirmDialog: React.FC = ({ open, onClose, modificationAuditId }) => {
+ const reasonInputRef = React.useRef(null);
+ const { language } = useAppContext();
+ const participantPortalClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
+ const cancelModificationAuditMutation = Common.Hooks.BackendParticipantPortalAPI.useCancelModificationAuditMutation(participantPortalClient);
+
+ const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
+ enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
+
+ const titleStr = language === "ko" ? "수정 요청 철회 확인" : "Confirm Withdrawal of Modification Request";
+ const content =
+ language === "ko" ? (
+
+ 제출하신 수정 요청을 철회하시겠습니까?
+
+ 철회 후에는 다시 수정 요청을 하셔야 합니다.
+
+ 계속하시려면 버튼을 클릭해 주세요.
+
+ ) : (
+
+ Are you sure you want to withdraw your modification request?
+
+ After withdrawal, you will need to submit a new modification request.
+
+ To continue, please click the button below.
+
+ );
+ // const reasonStr = language === "ko" ? "철회 사유 (선택)" : "Reason for Withdrawal (Optional)";
+ const submitStr = language === "ko" ? "철회" : "Withdraw Request";
+ const cancelStr = language === "ko" ? "취소" : "Cancel";
+ const successStr = language === "ko" ? "수정 요청이 철회되었습니다." : "Modification request has been canceled.";
+
+ const onClick = () => {
+ cancelModificationAuditMutation.mutate(
+ {
+ id: modificationAuditId,
+ reason: reasonInputRef.current?.value || "",
+ },
+ {
+ onSuccess: () => {
+ addSnackbar(successStr, "success");
+ onClose();
+ },
+ onError: (error) => {
+ console.error("Canceling ModAudit failed:", error);
+
+ let errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
+ if (error instanceof Common.BackendAPIs.BackendAPIClientError) errorMessage = error.message;
+
+ addSnackbar(errorMessage, "error");
+ },
+ }
+ );
+ };
+
+ const disabled = cancelModificationAuditMutation.isPending;
+
+ return (
+
+ );
+};
diff --git a/apps/pyconkr-participant-portal/src/components/dialogs/public_file_upload.tsx b/apps/pyconkr-participant-portal/src/components/dialogs/public_file_upload.tsx
new file mode 100644
index 0000000..d9287d3
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/dialogs/public_file_upload.tsx
@@ -0,0 +1,122 @@
+import * as Common from "@frontend/common";
+import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
+import { enqueueSnackbar, OptionsObject } from "notistack";
+import * as React from "react";
+
+import { useAppContext } from "../../contexts/app_context";
+
+type SetUploadedFileAsValueConfirmDialogProps = {
+ language: "ko" | "en";
+ open: boolean;
+ closeAll: () => void;
+ setValueAndCloseAll: () => void;
+};
+
+const SetUploadedFileAsValueConfirmDialog: React.FC = ({
+ language,
+ open,
+ closeAll,
+ setValueAndCloseAll,
+}) => {
+ const titleStr = language === "ko" ? "파일 업로드 완료" : "File Upload Completed";
+ const confirmStr =
+ language === "ko" ? "업로드한 파일을 현재 값으로 설정하시겠습니까?" : "Do you want to set the uploaded file as the current value?";
+ const yesStr = language === "ko" ? "네" : "Yes";
+ const noStr = language === "ko" ? "아니요" : "No";
+
+ return (
+
+ );
+};
+
+type PublicFileUploadDialogProps = {
+ open: boolean;
+ onClose: () => void;
+ setFileIdAsValue?: (fileId: string | undefined) => void;
+};
+
+type PublicFileUploadDialogState = {
+ selectedFile?: File | null;
+ uploadedFileId?: string;
+ openSetValueDialog?: boolean;
+};
+
+export const PublicFileUploadDialog: React.FC = ({ open, onClose, setFileIdAsValue }) => {
+ const { language } = useAppContext();
+ const [dialogState, setDialogState] = React.useState({});
+ const participantPortalClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
+ const uploadPublicFileMutation = Common.Hooks.BackendParticipantPortalAPI.useUploadPublicFileMutation(participantPortalClient);
+
+ const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
+ enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
+
+ const titleStr = language === "ko" ? "파일 업로드" : "Upload File";
+ const cancelStr = language === "ko" ? "취소" : "Cancel";
+ const uploadStr = language === "ko" ? "업로드" : "Upload";
+ const fileNotSelectedStr = language === "ko" ? "파일이 선택되지 않았습니다." : "No file selected.";
+ const failedToUploadStr = language === "ko" ? "파일 업로드에 실패했습니다." : "Failed to upload file.";
+ const loading = uploadPublicFileMutation.isPending;
+
+ const openSetValueDialog = () => setDialogState((ps) => ({ ...ps, openSetValueDialog: true }));
+ const cleanUpDialogState = () => setDialogState({});
+ const setFile = (selectedFile?: File | null) => setDialogState((ps) => ({ ...ps, selectedFile }));
+ const setFileId = (uploadedFileId?: string) => setDialogState((ps) => ({ ...ps, uploadedFileId }));
+
+ const uploadFile = async () => {
+ if (!dialogState.selectedFile) {
+ addSnackbar(fileNotSelectedStr, "error");
+ return;
+ }
+
+ uploadPublicFileMutation.mutate(dialogState.selectedFile, {
+ onSuccess: (data) => {
+ setFileId(data.id);
+ openSetValueDialog();
+ },
+ onError: (error) => {
+ console.error("Uploading file failed:", error);
+
+ let errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
+ if (error instanceof Common.BackendAPIs.BackendAPIClientError) errorMessage = error.message;
+
+ addSnackbar(`${failedToUploadStr}\n${errorMessage}`, "error");
+ },
+ });
+ };
+ const closeAllDialogs = () => {
+ cleanUpDialogState();
+ onClose();
+ };
+ const setValueAndCloseAllDialogs = () => {
+ setFileIdAsValue?.(dialogState.uploadedFileId);
+ closeAllDialogs();
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/apps/pyconkr-participant-portal/src/components/dialogs/submit_confirm.tsx b/apps/pyconkr-participant-portal/src/components/dialogs/submit_confirm.tsx
new file mode 100644
index 0000000..41353e6
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/dialogs/submit_confirm.tsx
@@ -0,0 +1,47 @@
+import { Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
+import * as React from "react";
+
+import { useAppContext } from "../../contexts/app_context";
+
+type SubmitConfirmDialogProps = {
+ open: boolean;
+ onClose: () => void;
+ onSubmit: () => void;
+};
+
+export const SubmitConfirmDialog: React.FC = ({ open, onClose, onSubmit }) => {
+ const { language } = useAppContext();
+
+ const titleStr = language === "ko" ? "제출 확인" : "Confirm Submission";
+ const content =
+ language === "ko" ? (
+
+ 제출하시면 파이콘 준비 위원회에서 검토 후 결과를 알려드립니다.
+
+ 제출 후에는 수정 심사를 철회 후 다시 수정하셔야 하오니, 내용을 한번 더 확인해 주세요.
+
+ 계속하시려면 버튼을 클릭해 주세요.
+
+ ) : (
+
+ Once you submit, the PyCon Korea organizing committee will review your submission and notify you of the results.
+
+ Please double-check your content as you will need to withdraw and resubmit if you wish to make changes after submission.
+
+ To continue, please click the button below.
+
+ );
+ const submitStr = language === "ko" ? "제출" : "Submit";
+ const cancelStr = language === "ko" ? "취소" : "Cancel";
+
+ return (
+
+ );
+};
diff --git a/apps/pyconkr-participant-portal/src/components/elements/blockquote.tsx b/apps/pyconkr-participant-portal/src/components/elements/blockquote.tsx
new file mode 100644
index 0000000..e83d4e9
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/elements/blockquote.tsx
@@ -0,0 +1,8 @@
+import { styled } from "@mui/material";
+
+export const BlockQuote = styled("blockquote")(({ theme }) => ({
+ margin: 0,
+ paddingLeft: theme.spacing(1.5),
+ borderLeft: `4px solid ${theme.palette.grey[700]}`,
+ color: theme.palette.text.secondary,
+}));
diff --git a/apps/pyconkr-participant-portal/src/components/elements/error_page.tsx b/apps/pyconkr-participant-portal/src/components/elements/error_page.tsx
new file mode 100644
index 0000000..4d8e990
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/elements/error_page.tsx
@@ -0,0 +1,15 @@
+import * as Common from "@frontend/common";
+import { Stack } from "@mui/material";
+import * as React from "react";
+
+import { Page } from "../page";
+
+export const ErrorPage: React.FC<{ error: Error; reset: () => void }> = ({ error, reset }) => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/apps/pyconkr-participant-portal/src/components/elements/fieldset.tsx b/apps/pyconkr-participant-portal/src/components/elements/fieldset.tsx
new file mode 100644
index 0000000..35c1319
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/elements/fieldset.tsx
@@ -0,0 +1,26 @@
+import { styled } from "@mui/material";
+import * as React from "react";
+
+type FieldsetProps = React.HTMLAttributes & {
+ legend: string;
+};
+
+const StyledFieldsetBase = styled("fieldset")(({ theme }) => ({
+ color: theme.palette.grey[700],
+ border: `1px solid ${theme.palette.grey[400]}`,
+ padding: "1rem",
+ borderRadius: "0.25rem",
+
+ "&:hover": {
+ borderColor: theme.palette.grey[700],
+ },
+}));
+
+export const Fieldset: React.FC = ({ legend, children, ...props }) => {
+ return (
+
+
+ {children}
+
+ );
+};
diff --git a/apps/pyconkr-participant-portal/src/components/elements/loading_page.tsx b/apps/pyconkr-participant-portal/src/components/elements/loading_page.tsx
new file mode 100644
index 0000000..1716b13
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/elements/loading_page.tsx
@@ -0,0 +1,19 @@
+import { CircularProgress, Stack, Typography } from "@mui/material";
+import * as React from "react";
+
+import { useAppContext } from "../../contexts/app_context";
+import { Page } from "../page";
+
+export const LoadingPage: React.FC = () => {
+ const { language } = useAppContext();
+ const loadingStr = language === "ko" ? "페이지를 불러오는 중입니다..." : "Loading...";
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/apps/pyconkr-participant-portal/src/components/elements/multilang_field.tsx b/apps/pyconkr-participant-portal/src/components/elements/multilang_field.tsx
new file mode 100644
index 0000000..7ce7f4b
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/elements/multilang_field.tsx
@@ -0,0 +1,209 @@
+import * as Common from "@frontend/common";
+import { Box, SelectProps, Stack, styled, Tab, Tabs, TextField, TextFieldProps, Typography, useMediaQuery } from "@mui/material";
+import * as React from "react";
+
+import { BlockQuote } from "./blockquote";
+import { Fieldset } from "./fieldset";
+import { PublicFileSelector } from "./public_file_selector";
+import { useAppContext } from "../../contexts/app_context";
+
+const ButtonWidth: React.CSSProperties["width"] = "4.5rem";
+
+const FieldContainer = styled(Stack)(({ theme }) => ({
+ flexDirection: "row",
+ alignItems: "stretch",
+
+ [theme.breakpoints.down("sm")]: {
+ flexDirection: "column",
+ alignItems: "stretch",
+ gap: 0,
+ },
+}));
+
+const SmallTabs = styled(Tabs)(({ theme }) => ({
+ flexGrow: 1,
+ width: ButtonWidth,
+ minWidth: ButtonWidth,
+ minHeight: "unset",
+
+ "& .MuiTabs-list": {
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+
+ [theme.breakpoints.down("sm")]: {
+ width: "100%",
+ minWidth: "unset",
+
+ "& .MuiTabs-list": {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "flex-start",
+ },
+ },
+}));
+
+const SmallTab = styled(Tab)(({ theme }) => ({
+ width: ButtonWidth,
+ minWidth: ButtonWidth,
+ wordBreak: "keep-all",
+ minHeight: "unset",
+ padding: theme.spacing(0.5, 1),
+
+ [theme.breakpoints.down("sm")]: {
+ padding: theme.spacing(1, 2),
+ },
+}));
+
+type TranslatedText = {
+ ko: string;
+ en: string;
+};
+
+type MultiLanguageCommonProps = {
+ label: TranslatedText;
+ description?: TranslatedText;
+};
+
+type MultiLanguageFieldProps = Omit &
+ MultiLanguageCommonProps & {
+ defaultValue?: TranslatedText;
+ value?: TranslatedText;
+ onChange?: (value: string | undefined, language: "ko" | "en") => void;
+ };
+
+type MultiLanguageFieldState = {
+ selectedFieldLanguage: "ko" | "en";
+};
+
+export const MultiLanguageField: React.FC = ({ label, description, defaultValue, value, onChange, ...props }) => {
+ const { language } = useAppContext();
+ const [fieldState, setFieldState] = React.useState({ selectedFieldLanguage: language });
+ const setFieldLanguage = (_: React.SyntheticEvent, selectedFieldLanguage: "ko" | "en") => setFieldState((ps) => ({ ...ps, selectedFieldLanguage }));
+ const koreanStr = language === "ko" ? "한국어" : "Korean";
+ const englishStr = language === "ko" ? "영어" : "English";
+
+ const isMobile = useMediaQuery((theme) => theme.breakpoints.down("sm"));
+ const tabOrientation = isMobile ? "horizontal" : "vertical";
+
+ const inputDefaultValue = defaultValue && defaultValue[fieldState.selectedFieldLanguage];
+ const inputValue = value && value[fieldState.selectedFieldLanguage];
+ const handleChange = (event: React.ChangeEvent) => onChange?.(event.target.value, fieldState.selectedFieldLanguage);
+
+ return (
+
+ );
+};
+
+type MultiLanguageMarkdownFieldProps = {
+ disabled?: boolean;
+ name?: string;
+ defaultValue?: TranslatedText;
+ value?: TranslatedText;
+ onChange?: (value: string | undefined, language: "ko" | "en") => void;
+} & MultiLanguageCommonProps;
+
+const MDRendererContainer = styled(Box)<{ fullWidth?: boolean }>(({ theme, fullWidth }) => ({
+ width: fullWidth ? "100%" : "50%",
+ maxWidth: fullWidth ? "100%" : "50%",
+ backgroundColor: "#fff",
+
+ "& .markdown-body": {
+ padding: theme.spacing(1, 2),
+ width: "100%",
+ p: { margin: theme.spacing(2, 0) },
+ },
+}));
+
+export const MultiLanguageMarkdownField: React.FC = ({
+ label,
+ description,
+ defaultValue,
+ value,
+ onChange,
+ disabled,
+ ...props
+}) => {
+ const { language } = useAppContext();
+ const [fieldState, setFieldState] = React.useState({ selectedFieldLanguage: language });
+ const setFieldLanguage = (_: React.SyntheticEvent, selectedFieldLanguage: "ko" | "en") => setFieldState((ps) => ({ ...ps, selectedFieldLanguage }));
+ const koreanStr = language === "ko" ? "한국어" : "Korean";
+ const englishStr = language === "ko" ? "영어" : "English";
+
+ const isMobile = useMediaQuery((theme) => theme.breakpoints.down("sm"));
+ const tabOrientation = isMobile ? "horizontal" : "vertical";
+
+ const inputDefaultValue = defaultValue && defaultValue[fieldState.selectedFieldLanguage];
+ const inputValue = value && value[fieldState.selectedFieldLanguage];
+ const handleChange = (value?: string) => onChange?.(value, fieldState.selectedFieldLanguage);
+
+ return (
+
+ );
+};
+
+type MultiLanguagePublicFileSelect = Omit, "label"> & MultiLanguageCommonProps;
+
+export const MultiLanguagePublicFileSelect: React.FC = ({ label, description, ...props }) => {
+ const { language } = useAppContext();
+ const [fieldState, setFieldState] = React.useState({ selectedFieldLanguage: language });
+ const setFieldLanguage = (_: React.SyntheticEvent, selectedFieldLanguage: "ko" | "en") => setFieldState((ps) => ({ ...ps, selectedFieldLanguage }));
+ const koreanStr = language === "ko" ? "한국어" : "Korean";
+ const englishStr = language === "ko" ? "영어" : "English";
+
+ const isMobile = useMediaQuery((theme) => theme.breakpoints.down("sm"));
+ const tabOrientation = isMobile ? "horizontal" : "vertical";
+
+ return (
+
+ );
+};
diff --git a/apps/pyconkr-participant-portal/src/components/elements/public_file_selector.tsx b/apps/pyconkr-participant-portal/src/components/elements/public_file_selector.tsx
new file mode 100644
index 0000000..2f6cd8b
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/elements/public_file_selector.tsx
@@ -0,0 +1,71 @@
+import * as Common from "@frontend/common";
+import { PermMedia } from "@mui/icons-material";
+import { Box, Button, CircularProgress, FormControl, InputLabel, MenuItem, Select, SelectProps, Stack, useMediaQuery } from "@mui/material";
+import { ErrorBoundary, Suspense } from "@suspensive/react";
+import * as React from "react";
+
+import { Fieldset } from "./fieldset";
+import { useAppContext } from "../../contexts/app_context";
+import { PublicFileUploadDialog } from "../dialogs/public_file_upload";
+
+type PublicFileSelectorProps = SelectProps & {
+ setFileIdAsValue?: (fileId?: string | null) => void;
+};
+
+const ImageFallback: React.FC<{ language: "ko" | "en" }> = ({ language }) => (
+
+);
+
+type PublicFileSelectorState = {
+ openUploadDialog?: boolean;
+};
+
+export const PublicFileSelector: React.FC = ErrorBoundary.with(
+ { fallback: Common.Components.ErrorFallback },
+ Suspense.with({ fallback: }, ({ setFileIdAsValue, ...props }) => {
+ const [selectorState, setSelectorState] = React.useState({});
+ const { language } = useAppContext();
+ const participantPortalClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
+ const { data } = Common.Hooks.BackendParticipantPortalAPI.usePublicFilesQuery(participantPortalClient);
+ const isMobile = useMediaQuery((theme) => theme.breakpoints.down("md"));
+
+ const openUploadDialog = () => setSelectorState((ps) => ({ ...ps, openUploadDialog: true }));
+ const closeUploadDialog = () => setSelectorState((ps) => ({ ...ps, openUploadDialog: false }));
+
+ const emptyValueStr = language === "ko" ? "선택 안 함" : "Not selected";
+ const uploadStr = language === "ko" ? "파일 업로드" : "Upload File";
+ const files = [...(props.required ? [] : [{ id: undefined, file: emptyValueStr, name: emptyValueStr }]), ...data];
+ const selectedFile = data.find((file) => file.id === props.value);
+
+ return (
+ <>
+
+
+ >
+ );
+ })
+);
diff --git a/apps/pyconkr-participant-portal/src/components/elements/requested_modification_audit_available_header.tsx b/apps/pyconkr-participant-portal/src/components/elements/requested_modification_audit_available_header.tsx
new file mode 100644
index 0000000..a39f830
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/elements/requested_modification_audit_available_header.tsx
@@ -0,0 +1,54 @@
+import { Button, Card, CardContent, Stack, styled, Typography } from "@mui/material";
+import * as React from "react";
+
+import { ModificationAuditCancelConfirmDialog } from "../dialogs/modification_audit_cancel_confirm";
+
+const StyledAlertCard = styled(Card)(({ theme }) => ({
+ width: "100%",
+ backgroundColor: theme.palette.info.light,
+ color: theme.palette.info.contrastText,
+ marginBottom: theme.spacing(2),
+ fontWeight: 500,
+}));
+
+export const CurrentlyModAuditInProgress: React.FC<{ language: "ko" | "en"; modificationAuditId: string }> = ({ language, modificationAuditId }) => {
+ const [cardState, setCardState] = React.useState<{ openCancelConfirmDialog: boolean }>({ openCancelConfirmDialog: false });
+ const openCancelConfirmDialog = () => setCardState((ps) => ({ ...ps, openCancelConfirmDialog: true }));
+ const closeCancelConfirmDialog = () => setCardState((ps) => ({ ...ps, openCancelConfirmDialog: false }));
+
+ const cancelModAuditStr = language === "ko" ? "수정 요청 취소하기" : "Cancel Request";
+ const sessionModAuditInProgress =
+ language === "ko" ? (
+
+ 현재 정보 수정 요청이 진행 중이에요. 수정 요청이 완료되기 전까지는 정보를 변경할 수 없어요.
+
+ 만약 수정할 내용이 있다면, 수정 요청을 취소하고 다시 수정 요청을 해주세요.
+
+ ) : (
+
+ A modification request is currently in progress.
+
+ You cannot change the information until the modification request is completed.
+
+ If you have changes to make, please cancel the modification request and submit a new one.
+
+ );
+
+ return (
+ <>
+
+
+
+
+ {sessionModAuditInProgress}
+
+
+
+
+ >
+ );
+};
diff --git a/apps/pyconkr-participant-portal/src/components/elements/signin_guard.tsx b/apps/pyconkr-participant-portal/src/components/elements/signin_guard.tsx
new file mode 100644
index 0000000..b440472
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/elements/signin_guard.tsx
@@ -0,0 +1,17 @@
+import * as Common from "@frontend/common";
+import { ErrorBoundary, Suspense } from "@suspensive/react";
+import * as React from "react";
+import { Navigate } from "react-router-dom";
+
+import { ErrorPage } from "./error_page";
+import { LoadingPage } from "./loading_page";
+
+export const SignInGuard: React.FC = ErrorBoundary.with(
+ { fallback: ErrorPage },
+ Suspense.with({ fallback: }, ({ children }) => {
+ const participantPortalClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
+ const { data } = Common.Hooks.BackendParticipantPortalAPI.useSignedInUserQuery(participantPortalClient);
+
+ return data ? children : ;
+ })
+);
diff --git a/apps/pyconkr-participant-portal/src/components/elements/titles.tsx b/apps/pyconkr-participant-portal/src/components/elements/titles.tsx
new file mode 100644
index 0000000..53c5d4b
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/elements/titles.tsx
@@ -0,0 +1,27 @@
+import { styled, Typography } from "@mui/material";
+
+export const PrimaryTitle = styled(Typography)(({ theme }) => ({
+ width: "100%",
+ marginBottom: theme.spacing(2),
+
+ textAlign: "start",
+ fontWeight: 700,
+
+ [theme.breakpoints.down("sm")]: {
+ textAlign: "center",
+ fontSize: "2rem",
+ },
+}));
+
+export const SecondaryTitle = styled(Typography)(({ theme }) => ({
+ width: "100%",
+ marginBottom: theme.spacing(1),
+
+ textAlign: "start",
+ fontWeight: 600,
+
+ [theme.breakpoints.down("sm")]: {
+ textAlign: "center",
+ fontSize: "1.5rem",
+ },
+}));
diff --git a/apps/pyconkr-participant-portal/src/components/layout.tsx b/apps/pyconkr-participant-portal/src/components/layout.tsx
new file mode 100644
index 0000000..6cde96b
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/layout.tsx
@@ -0,0 +1,141 @@
+import * as Common from "@frontend/common";
+import { AccountCircle } from "@mui/icons-material";
+import { AppBar, ButtonBase, CircularProgress, IconButton, Menu, MenuItem, Stack, styled, Toolbar, Tooltip, Typography } from "@mui/material";
+import { ErrorBoundary, Suspense } from "@suspensive/react";
+import * as React from "react";
+import { Link, Outlet, useNavigate } from "react-router-dom";
+
+import { LOCAL_STORAGE_LANGUAGE_KEY } from "../consts/local_stroage";
+import { useAppContext } from "../contexts/app_context";
+
+const FullPage = styled(Stack)({
+ minHeight: "100vh",
+ backgroundColor: "#f0f0f0",
+});
+
+const ToggleContainer = styled("div")(({ theme }) => ({
+ display: "flex",
+ width: "6rem",
+ height: 29,
+ border: `2px solid ${theme.palette.primary.light}`,
+ borderRadius: 15,
+ padding: 2,
+ gap: 2,
+}));
+
+const LanguageButton = styled(ButtonBase)<{ isActive: boolean }>(({ theme, isActive }) => ({
+ flex: 1,
+ height: "100%",
+ borderRadius: 13,
+ fontSize: "0.75rem",
+ fontWeight: 600,
+ transition: "all 0.2s ease",
+ backgroundColor: "transparent",
+ color: isActive ? theme.palette.primary.contrastText : theme.palette.primary.dark,
+
+ ...(isActive && {
+ fontWeight: 700,
+ backgroundColor: theme.palette.primary.dark,
+ }),
+
+ "&:hover": {
+ color: theme.palette.primary.contrastText,
+ backgroundColor: isActive ? theme.palette.primary.dark : theme.palette.primary.light,
+ },
+
+ WebkitFontSmoothing: "antialiased",
+ MozOsxFontSmoothing: "grayscale",
+ textRendering: "optimizeLegibility",
+ WebkitTextStroke: "0.5px transparent",
+}));
+
+type ProfileMenuButtonProps = {
+ loading?: boolean;
+ signedIn?: boolean;
+};
+
+type ProfileMenuButtonState = {
+ anchorEl?: HTMLElement | null;
+};
+
+const InnerProfileMenuButton: React.FC = ({ loading, signedIn }) => {
+ const navigate = useNavigate();
+ const participantPortalClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
+ const [btnState, setBtnState] = React.useState({});
+ const openMenu: React.MouseEventHandler = (evt) => setBtnState((ps) => ({ ...ps, anchorEl: evt.currentTarget }));
+ const closeMenu = () => setBtnState((ps) => ({ ...ps, anchorEl: undefined }));
+ const { language } = useAppContext();
+ const accountStr = language === "ko" ? "계정" : "Account";
+ const signInStr = language === "ko" ? "로그인" : "Sign In";
+ const signOutStr = language === "ko" ? "로그아웃" : "Sign Out";
+ const editProfileStr = language === "ko" ? "프로필 편집" : "Edit Profile";
+
+ const goToProfileEditor = () => {
+ navigate("/user");
+ closeMenu();
+ };
+ const goToSignIn = () => navigate("/signin");
+ const signOutMutation = Common.Hooks.BackendParticipantPortalAPI.useSignOutMutation(participantPortalClient);
+ const onSignInOutClick = () => {
+ if (signedIn) signOutMutation.mutate();
+ else goToSignIn();
+
+ closeMenu();
+ };
+
+ return (
+ <>
+
+ : } disabled={loading} onClick={openMenu} />
+
+
+ >
+ );
+};
+
+const ProfileMenuButton: React.FC = ErrorBoundary.with(
+ { fallback: },
+ Suspense.with({ fallback: }, () => {
+ const participantPortalClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
+ const { data } = Common.Hooks.BackendParticipantPortalAPI.useSignedInUserQuery(participantPortalClient);
+
+ return ;
+ })
+);
+
+export const Layout: React.FC = () => {
+ const { language, setAppContext } = useAppContext();
+ const toggleLanguage = () =>
+ setAppContext((ps) => {
+ const language = ps.language === "ko" ? "en" : "ko";
+ localStorage.setItem(LOCAL_STORAGE_LANGUAGE_KEY, language);
+ return { ...ps, language };
+ });
+
+ const titleStr = language === "ko" ? "PyCon Korea 참가자 포탈" : "PyCon Korea Participant Portal";
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+ );
+};
diff --git a/apps/pyconkr-participant-portal/src/components/page.tsx b/apps/pyconkr-participant-portal/src/components/page.tsx
new file mode 100644
index 0000000..5f9e283
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/page.tsx
@@ -0,0 +1,25 @@
+import { Stack, styled } from "@mui/material";
+
+export const Page = styled(Stack)(({ theme }) => ({
+ height: "100%",
+ width: "100%",
+ maxWidth: "1200px",
+
+ flexGrow: 1,
+
+ justifyContent: "flex-start",
+ alignItems: "center",
+
+ paddingTop: theme.spacing(4),
+ paddingBottom: theme.spacing(4),
+
+ paddingRight: theme.spacing(16),
+ paddingLeft: theme.spacing(16),
+
+ [theme.breakpoints.down("lg")]: {
+ padding: theme.spacing(2, 4),
+ },
+ [theme.breakpoints.down("sm")]: {
+ padding: theme.spacing(1, 2),
+ },
+}));
diff --git a/apps/pyconkr-participant-portal/src/components/pages/home.tsx b/apps/pyconkr-participant-portal/src/components/pages/home.tsx
new file mode 100644
index 0000000..c8bcc1e
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/pages/home.tsx
@@ -0,0 +1,159 @@
+import * as Common from "@frontend/common";
+import { Button, List, ListItem, ListItemButton, ListItemText, Stack, styled, Typography, useMediaQuery } from "@mui/material";
+import { ErrorBoundary, Suspense } from "@suspensive/react";
+import * as React from "react";
+import { useNavigate } from "react-router-dom";
+
+import { useAppContext } from "../../contexts/app_context";
+import { ErrorPage } from "../elements/error_page";
+import { Fieldset } from "../elements/fieldset";
+import { LoadingPage } from "../elements/loading_page";
+import { SignInGuard } from "../elements/signin_guard";
+import { Page } from "../page";
+
+const ProfileImageSize: React.CSSProperties["width" | "height"] = "8rem";
+
+const FieldsetContainer = styled(Stack)({
+ width: "100%",
+ flexWrap: "wrap",
+ flexDirection: "row",
+ gap: "1rem",
+});
+
+const ProperWidthFieldset = styled(Fieldset)({
+ width: "100%",
+ flex: "1 1 450px",
+});
+
+const ProfileImageContainer = styled(Stack)({
+ alignItems: "center",
+ justifyContent: "center",
+
+ width: ProfileImageSize,
+ minWidth: ProfileImageSize,
+ maxWidth: ProfileImageSize,
+ height: ProfileImageSize,
+ minHeight: ProfileImageSize,
+ maxHeight: ProfileImageSize,
+});
+
+const ProfileImageStyle: React.CSSProperties = {
+ border: "1px solid #ccc",
+ width: "100%",
+ height: "100%",
+ borderRadius: "50%",
+ objectFit: "cover",
+ textAlign: "center",
+};
+
+const ProfileImage = styled(Common.Components.FallbackImage)(ProfileImageStyle);
+
+const ProfileImageFallback: React.FC<{ language: "ko" | "en" }> = ({ language }) => {
+ const noProfileImageText = language === "ko" ? "프로필 이미지가 없습니다." : "No profile image.";
+ const registerProfileImageText = language === "ko" ? "이미지를 등록해주세요." : "Please register your profile image.";
+
+ return (
+
+
+ {noProfileImageText}
+
+ {registerProfileImageText}
+
+
+ );
+};
+
+const InnerLandingPage: React.FC = () => {
+ const navigate = useNavigate();
+ const { language } = useAppContext();
+ const isMobile = useMediaQuery((theme) => theme.breakpoints.down("sm"));
+ const participantPortalAPIClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
+ const { data: profile } = Common.Hooks.BackendParticipantPortalAPI.useSignedInUserQuery(participantPortalAPIClient);
+ const { data: modificationAudits } = Common.Hooks.BackendParticipantPortalAPI.useModificationAuditsQuery(participantPortalAPIClient);
+ const { data: sessions } = Common.Hooks.BackendParticipantPortalAPI.useListPresentationsQuery(participantPortalAPIClient);
+
+ if (!profile) {
+ return (
+
+
+ {language === "ko" ? "로그인이 필요합니다." : "Login is required."}
+
+
+ );
+ }
+
+ const greetingStr = language === "ko" ? `안녕하세요, ${profile.nickname}님!` : `Hello, ${profile.nickname}!`;
+ const myInfoStr = language === "ko" ? "내 정보" : "My Information";
+ const auditStr = language === "ko" ? "수정 요청" : "Audit Requests";
+ const sessionsStr = language === "ko" ? "발표 목록" : "Sessions";
+ // const sponsorsStr = language === "ko" ? "후원사 정보" : "Sponsor informations";
+ const userNameStr = language === "ko" ? `계정명 : ${profile.username}` : `Username : ${profile.username}`;
+ const nickNameStr = language === "ko" ? `별칭 : ${profile.nickname}` : `Nickname : ${profile.nickname}`;
+ const emailStr = language === "ko" ? `이메일 : ${profile.email}` : `Email : ${profile.email}`;
+ const editProfileStr = language === "ko" ? "프로필 수정" : "Edit Profile";
+ const auditEmptyStr = language === "ko" ? "수정 요청이 없습니다." : "No audit requests.";
+
+ return (
+
+
+
+
+
+
+
+ {modificationAudits.length > 0 ? (
+ modificationAudits.map((audit) => (
+
+ }
+ onClick={() => navigate(`/session/${audit.instance_id}/modification-audit/${audit.id}`)}
+ />
+
+ ))
+ ) : (
+
+ } />
+
+ )}
+
+
+ {sessions && (
+
+
+ {sessions.map((s) => (
+
+ } onClick={() => navigate(`/session/${s.id}`)} />
+
+ ))}
+
+
+ )}
+ {/*
+
+ */}
+
+
+
+ );
+};
+
+export const LandingPage: React.FC = ErrorBoundary.with(
+ { fallback: ErrorPage },
+ Suspense.with({ fallback: }, () => } />)
+);
diff --git a/apps/pyconkr-participant-portal/src/components/pages/profile_editor.tsx b/apps/pyconkr-participant-portal/src/components/pages/profile_editor.tsx
new file mode 100644
index 0000000..6c0175f
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/pages/profile_editor.tsx
@@ -0,0 +1,152 @@
+import * as Common from "@frontend/common";
+import { Key, SendAndArchive } from "@mui/icons-material";
+import { Button, SelectChangeEvent, Stack } from "@mui/material";
+import { ErrorBoundary, Suspense } from "@suspensive/react";
+import { enqueueSnackbar, OptionsObject } from "notistack";
+import * as React from "react";
+
+import { useAppContext } from "../../contexts/app_context";
+import { ChangePasswordDialog } from "../dialogs/change_password";
+import { SubmitConfirmDialog } from "../dialogs/submit_confirm";
+import { ErrorPage } from "../elements/error_page";
+import { LoadingPage } from "../elements/loading_page";
+import { MultiLanguageField } from "../elements/multilang_field";
+import { PublicFileSelector } from "../elements/public_file_selector";
+import { CurrentlyModAuditInProgress } from "../elements/requested_modification_audit_available_header";
+import { SignInGuard } from "../elements/signin_guard";
+import { PrimaryTitle } from "../elements/titles";
+import { Page } from "../page";
+
+type ProfileType = {
+ email: string;
+ nickname_ko: string | null;
+ nickname_en: string | null;
+ image?: string | null;
+};
+
+type ProfileEditorState = ProfileType & {
+ openChangePasswordDialog: boolean;
+ openSubmitConfirmDialog: boolean;
+};
+
+const DummyProfile: ProfileType = {
+ email: "",
+ nickname_ko: "",
+ nickname_en: "",
+ image: null,
+};
+
+const InnerProfileEditor: React.FC = () => {
+ const { language } = useAppContext();
+ const participantPortalClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
+ const { data: profile } = Common.Hooks.BackendParticipantPortalAPI.useSignedInUserQuery(participantPortalClient);
+ const updateMeMutation = Common.Hooks.BackendParticipantPortalAPI.useUpdateMeMutation(participantPortalClient);
+ const [editorState, setEditorState] = React.useState({
+ openChangePasswordDialog: false,
+ openSubmitConfirmDialog: false,
+ ...(profile || DummyProfile),
+ });
+
+ const titleStr = language === "ko" ? "프로필 정보 수정" : "Edit Profile Information";
+ const submitStr = language === "ko" ? "제출" : "Submit";
+ const speakerImageStr = language === "ko" ? "프로필 이미지" : "Profile Image";
+ const changePasswordStr = language === "ko" ? "비밀번호 변경" : "Change Password";
+ const submitSucceedStr =
+ language === "ko"
+ ? "프로필 정보 수정을 요청했어요. 검토 후 반영될 예정이에요."
+ : "Profile information update requested. It will be applied after review.";
+
+ const openSubmitConfirmDialog = () => setEditorState((ps) => ({ ...ps, openSubmitConfirmDialog: true }));
+ const closeSubmitConfirmDialog = () => setEditorState((ps) => ({ ...ps, openSubmitConfirmDialog: false }));
+
+ const openChangePasswordDialog = () => setEditorState((ps) => ({ ...ps, openChangePasswordDialog: true }));
+ const closeChangePasswordDialog = () => setEditorState((ps) => ({ ...ps, openChangePasswordDialog: false }));
+
+ const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
+ enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
+
+ const setImageId = (image: string | null | undefined) => setEditorState((ps) => ({ ...ps, image }));
+ const onImageSelectChange = (e: SelectChangeEvent) => setImageId(e.target.value);
+ const setNickname = (value: string | undefined, lang: "ko" | "en") => setEditorState((ps) => ({ ...ps, [`nickname_${lang}`]: value }));
+
+ const updateMe = () => {
+ const { nickname_ko, nickname_en, image } = editorState;
+ updateMeMutation.mutate(
+ {
+ nickname_ko: nickname_ko || null,
+ nickname_en: nickname_en || null,
+ image: image || null,
+ },
+ {
+ onSuccess: () => {
+ addSnackbar(submitSucceedStr, "success");
+ closeSubmitConfirmDialog();
+ },
+ onError: (error) => {
+ console.error("Updating profile failed:", error);
+
+ let errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
+ if (error instanceof Common.BackendAPIs.BackendAPIClientError) errorMessage = error.message;
+
+ addSnackbar(errorMessage, "error");
+ },
+ }
+ );
+ };
+
+ const modificationAuditId = profile?.requested_modification_audit_id || "";
+ const formDisabled = profile?.has_requested_modification_audit || updateMeMutation.isPending;
+
+ return (
+ <>
+
+
+
+ {profile?.has_requested_modification_audit && }
+
+
+
+
+
+ }
+ color="error"
+ onClick={openChangePasswordDialog}
+ children={changePasswordStr}
+ disabled={formDisabled}
+ />
+ }
+ onClick={openSubmitConfirmDialog}
+ children={submitStr}
+ disabled={formDisabled}
+ />
+
+
+
+ >
+ );
+};
+
+export const ProfileEditor: React.FC = ErrorBoundary.with(
+ { fallback: ErrorPage },
+ Suspense.with({ fallback: }, () => } />)
+);
diff --git a/apps/pyconkr-participant-portal/src/components/pages/session_editor.tsx b/apps/pyconkr-participant-portal/src/components/pages/session_editor.tsx
new file mode 100644
index 0000000..1c50363
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/pages/session_editor.tsx
@@ -0,0 +1,232 @@
+import * as Common from "@frontend/common";
+import { SendAndArchive } from "@mui/icons-material";
+import { Box, Button, Divider, SelectChangeEvent, Stack, Typography } from "@mui/material";
+import { ErrorBoundary, Suspense } from "@suspensive/react";
+import { enqueueSnackbar, OptionsObject } from "notistack";
+import * as React from "react";
+import { Navigate, useParams } from "react-router-dom";
+import * as R from "remeda";
+
+import { useAppContext } from "../../contexts/app_context";
+import { SubmitConfirmDialog } from "../dialogs/submit_confirm";
+import { ErrorPage } from "../elements/error_page";
+import { LoadingPage } from "../elements/loading_page";
+import { MultiLanguageField, MultiLanguageMarkdownField } from "../elements/multilang_field";
+import { PublicFileSelector } from "../elements/public_file_selector";
+import { CurrentlyModAuditInProgress } from "../elements/requested_modification_audit_available_header";
+import { SignInGuard } from "../elements/signin_guard";
+import { PrimaryTitle, SecondaryTitle } from "../elements/titles";
+import { Page } from "../page";
+
+type SessionUpdateSchema = {
+ title_ko: string;
+ title_en: string;
+ summary_ko: string;
+ summary_en: string;
+ description_ko: string;
+ description_en: string;
+ image?: string | null;
+
+ speakers: {
+ id: string; // UUID of the speaker
+ biography_ko: string; // Biography in Korean
+ biography_en: string; // Biography in English
+ image?: string | null; // PK of the speaker's image
+ }[];
+};
+
+type SessionEditorState = SessionUpdateSchema & {
+ openSubmitConfirmDialog: boolean;
+};
+
+const DummySessionInfo: SessionUpdateSchema = {
+ title_ko: "",
+ title_en: "",
+ summary_ko: "",
+ summary_en: "",
+ description_ko: "",
+ description_en: "",
+ image: null,
+
+ speakers: [],
+};
+
+const InnerSessionEditor: React.FC = () => {
+ const { sessionId } = useParams<{ sessionId?: string }>();
+ const { language } = useAppContext();
+ const participantPortalClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
+ const updateSessionMutation = Common.Hooks.BackendParticipantPortalAPI.useUpdatePresentationMutation(participantPortalClient);
+ const { data: session } = Common.Hooks.BackendParticipantPortalAPI.useRetrievePresentationQuery(participantPortalClient, sessionId || "");
+ const [editorState, setEditorState] = React.useState({
+ openSubmitConfirmDialog: false,
+ ...(session || DummySessionInfo),
+ });
+
+ if (!sessionId || !session || !(R.isArray(editorState.speakers) && !R.isEmpty(editorState.speakers))) return ;
+
+ // 유저는 하나의 세션에 발표자가 한번만 가능하고, 백엔드에서 본 유저의 세션 발표자 정보만 제공하므로, 첫 번째 발표자 정보를 사용해도 안전합니다.
+ const speaker = editorState.speakers[0];
+
+ const titleStr = language === "ko" ? "발표 정보 수정" : "Edit Session Information";
+ const submitStr = language === "ko" ? "제출" : "Submit";
+ const sessionEditDescription =
+ language === "ko" ? (
+
+ 청취자가 발표에 흥미를 가질 수 있도록 제목과 요약, 설명을 작성해주세요.
+
+ (한국어와 영어 둘 중 하나만 작성하면, 홈페이지가 다른 언어로 설정되어 있더라도 작성한 언어의 내용만 보여져요. 해외에서 오시는 분들을 위해
+ 양 언어를 작성하는 것을 추천합니다.)
+
+ ) : (
+
+ Please write the title, summary, and description to spark the interest of the audience in your session.
+
+ (If you write in only one of the languages, the content will be displayed in that language even if the website is set to a different language.
+ We recommend writing in both languages for the benefit of international attendees.)
+
+ );
+ const titleStrForSpeaker = language === "ko" ? "발표자 정보 수정" : "Edit Speaker Information";
+ const speakerEditDescription =
+ language === "ko" ? (
+
+ 발표자 소개는 청취자가 발표자를 이해하는 데 도움이 됩니다.
+
+ (한국어와 영어 둘 중 하나만 작성하면, 홈페이지가 다른 언어로 설정되어 있더라도 작성한 언어의 내용만 보여져요. 해외에서 오시는 분들을 위해
+ 양 언어를 작성하는 것을 추천합니다.)
+
+ ) : (
+
+ The speaker biography helps the audience understand the speaker.
+
+ (If you write in only one of the languages, the content will be displayed in that language even if the website is set to a different language.
+ We recommend writing in both languages for the benefit of international attendees.)
+
+ );
+ const sessionImageStr = language === "ko" ? "발표 이미지" : "Session Image";
+ const speakerImageStr = language === "ko" ? "발표자 이미지" : "Speaker Image";
+ const submitSucceedStr =
+ language === "ko"
+ ? "발표 정보 수정을 요청했어요. 검토 후 반영될 예정이에요."
+ : "Presentation information update requested. It will be applied after review.";
+
+ const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
+ enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
+
+ const openSubmitConfirmDialog = () => setEditorState((ps) => ({ ...ps, openSubmitConfirmDialog: true }));
+ const closeSubmitConfirmDialog = () => setEditorState((ps) => ({ ...ps, openSubmitConfirmDialog: false }));
+
+ const setTitle = (value: string | undefined, lang: "ko" | "en") => setEditorState((ps) => ({ ...ps, [`title_${lang}`]: value }));
+ const setSummary = (value: string | undefined, lang: "ko" | "en") => setEditorState((ps) => ({ ...ps, [`summary_${lang}`]: value }));
+ const setDescription = (value: string | undefined, lang: "ko" | "en") => setEditorState((ps) => ({ ...ps, [`description_${lang}`]: value }));
+ const setImage = (image: string | null | undefined) => setEditorState((ps) => ({ ...ps, image }));
+ const setSpeakerImage = (image: string | null | undefined) => setEditorState((ps) => ({ ...ps, speakers: [{ ...speaker, image }] }));
+ const setSpeakerBiography = (value: string | undefined, lang: "ko" | "en") =>
+ setEditorState((ps) => ({ ...ps, speakers: [{ ...speaker, [`biography_${lang}`]: value }] }));
+
+ const onImageSelectChange = (e: SelectChangeEvent) => setImage(e.target.value);
+ const onSpeakerImageSelectChange = (e: SelectChangeEvent) => setSpeakerImage(e.target.value);
+
+ const updateSession = () => {
+ updateSessionMutation.mutate(
+ {
+ id: sessionId,
+ title_ko: editorState.title_ko,
+ title_en: editorState.title_en,
+ summary_ko: editorState.summary_ko,
+ summary_en: editorState.summary_en,
+ description_ko: editorState.description_ko,
+ description_en: editorState.description_en,
+ image: editorState.image || null,
+ },
+ {
+ onSuccess: () => {
+ addSnackbar(submitSucceedStr, "success");
+ closeSubmitConfirmDialog();
+ },
+ onError: (error) => {
+ console.error("Updating session failed:", error);
+
+ let errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
+ if (error instanceof Common.BackendAPIs.BackendAPIClientError) errorMessage = error.message;
+
+ addSnackbar(errorMessage, "error");
+ },
+ }
+ );
+ };
+
+ const modificationAuditId = session.requested_modification_audit_id || "";
+ const formDisabled = session.has_requested_modification_audit || updateSessionMutation.isPending;
+
+ return (
+ <>
+
+
+ {session.has_requested_modification_audit && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onClick={openSubmitConfirmDialog}
+ loading={updateSessionMutation.isPending}
+ disabled={formDisabled}
+ children={submitStr}
+ />
+
+
+
+ >
+ );
+};
+
+export const SessionEditor: React.FC = ErrorBoundary.with(
+ { fallback: ErrorPage },
+ Suspense.with({ fallback: }, () => } />)
+);
diff --git a/apps/pyconkr-participant-portal/src/components/pages/signin.tsx b/apps/pyconkr-participant-portal/src/components/pages/signin.tsx
new file mode 100644
index 0000000..b8f71bf
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/pages/signin.tsx
@@ -0,0 +1,91 @@
+import * as Common from "@frontend/common";
+import { Button, Stack, TextField, Typography } from "@mui/material";
+import { enqueueSnackbar, OptionsObject } from "notistack";
+import * as React from "react";
+import { Navigate, useNavigate } from "react-router-dom";
+
+import { useAppContext } from "../../contexts/app_context";
+import { Page } from "../page";
+
+export const SignInPage: React.FC = () => {
+ const formRef = React.useRef(null);
+ const navigate = useNavigate();
+ const { sendEmail } = Common.Hooks.Common.useEmail();
+ const { language } = useAppContext();
+ const participantPortalClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
+ const { data } = Common.Hooks.BackendParticipantPortalAPI.useSignedInUserQuery(participantPortalClient);
+ if (data) return ;
+
+ const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
+ enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
+
+ const signInStr = language === "ko" ? "로그인" : "Sign In";
+ const emailStr = language === "ko" ? "이메일" : "Email";
+ const passwordStr = language === "ko" ? "비밀번호" : "Password";
+ const signInSucceedStr = language === "ko" ? "로그인에 성공했습니다!" : "Sign in succeeded!";
+ const signInFailedStr = language === "ko" ? "로그인에 실패했습니다." : "Sign in failed.";
+ const contactToTeamStr =
+ language === "ko" ? (
+ <>
+ 계정 관련 문의는
+
+ 파이콘 준비 위원회 (pyconkr@pycon.kr)
+
+ 로 부탁드립니다.
+ >
+ ) : (
+ <>
+ For account-related inquiries,
+
+ please contact the
+
+ PyCon Korea Organizing Committee (pyconkr@pycon.kr)
+
+ >
+ );
+
+ const signInMutation = Common.Hooks.BackendParticipantPortalAPI.useSignInMutation(participantPortalClient);
+ const signIn = () => {
+ if (!Common.Utils.isFormValid(formRef.current)) return;
+
+ const formData = Common.Utils.getFormValue<{ identity: string; password: string }>({ form: formRef.current });
+ signInMutation.mutate(formData, {
+ onSuccess: () => {
+ addSnackbar(signInSucceedStr, "success");
+ navigate("/");
+ },
+ onError: (error) => {
+ console.error("Sign in failed:", error);
+
+ let errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
+ if (error instanceof Common.BackendAPIs.BackendAPIClientError) errorMessage = error.message;
+
+ addSnackbar(
+ <>
+ {signInFailedStr}
+
+ {errorMessage}
+ >,
+ "error"
+ );
+ },
+ });
+ };
+ const disabled = signInMutation.isPending;
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/pyconkr-participant-portal/src/components/pages/sponsor_editor.tsx b/apps/pyconkr-participant-portal/src/components/pages/sponsor_editor.tsx
new file mode 100644
index 0000000..50663e7
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/components/pages/sponsor_editor.tsx
@@ -0,0 +1,37 @@
+import { FormControl, InputLabel, Select, Stack, Tab, Tabs, TextField, Typography } from "@mui/material";
+import { ErrorBoundary, Suspense } from "@suspensive/react";
+import * as React from "react";
+
+import { ErrorPage } from "../elements/error_page";
+import { LoadingPage } from "../elements/loading_page";
+import { SignInGuard } from "../elements/signin_guard";
+import { Page } from "../page";
+
+const InnerSponsorEditor: React.FC = () => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export const SponsorEditor: React.FC = ErrorBoundary.with(
+ { fallback: ErrorPage },
+ Suspense.with({ fallback: }, () => } />)
+);
diff --git a/apps/pyconkr-participant-portal/src/consts/index.ts b/apps/pyconkr-participant-portal/src/consts/index.ts
new file mode 100644
index 0000000..3b678c6
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/consts/index.ts
@@ -0,0 +1 @@
+export const IS_DEBUG_ENV = import.meta.env.MODE === "development";
diff --git a/apps/pyconkr-participant-portal/src/consts/local_stroage.ts b/apps/pyconkr-participant-portal/src/consts/local_stroage.ts
new file mode 100644
index 0000000..d2e88da
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/consts/local_stroage.ts
@@ -0,0 +1 @@
+export const LOCAL_STORAGE_LANGUAGE_KEY = "language";
diff --git a/apps/pyconkr-participant-portal/src/contexts/app_context.tsx b/apps/pyconkr-participant-portal/src/contexts/app_context.tsx
new file mode 100644
index 0000000..70d13f0
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/contexts/app_context.tsx
@@ -0,0 +1,18 @@
+import * as React from "react";
+
+type LanguageType = "ko" | "en";
+
+export type AppContextType = {
+ language: LanguageType;
+ setAppContext: React.Dispatch>>;
+};
+
+export const AppContext = React.createContext(undefined);
+
+export const useAppContext = (): AppContextType => {
+ const context = React.useContext(AppContext);
+ if (!context) {
+ throw new Error("useAppContext must be used within an AppContextProvider");
+ }
+ return context;
+};
diff --git a/apps/pyconkr-participant-portal/src/main.tsx b/apps/pyconkr-participant-portal/src/main.tsx
new file mode 100644
index 0000000..8623564
--- /dev/null
+++ b/apps/pyconkr-participant-portal/src/main.tsx
@@ -0,0 +1,82 @@
+import * as Common from "@frontend/common";
+import { CircularProgress, createTheme, CssBaseline, ThemeProvider } from "@mui/material";
+import { ErrorBoundary, Suspense } from "@suspensive/react";
+import { matchQuery, MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { SnackbarProvider } from "notistack";
+import * as React from "react";
+import * as ReactDom from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+
+import { App } from "./App.tsx";
+import { ErrorPage } from "./components/elements/error_page.tsx";
+import { IS_DEBUG_ENV } from "./consts/index.ts";
+import { LOCAL_STORAGE_LANGUAGE_KEY } from "./consts/local_stroage.ts";
+import { AppContext, AppContextType } from "./contexts/app_context.tsx";
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 60 * 1000,
+ gcTime: 5 * 60 * 1000,
+ retry: 1,
+ },
+ },
+ mutationCache: new MutationCache({
+ onSuccess: (_data, _variables, _context, mutation) => {
+ queryClient.resetQueries({
+ predicate: (query) => mutation.meta?.invalidates?.some((queryKey) => matchQuery({ queryKey }, query)) ?? true,
+ });
+ },
+ }),
+});
+
+export const muiTheme = createTheme();
+
+const CommonOptions: Common.Contexts.ContextOptions = {
+ language: "ko",
+ debug: IS_DEBUG_ENV,
+ baseUrl: ".",
+ backendApiDomain: import.meta.env.VITE_PYCONKR_BACKEND_API_DOMAIN,
+ backendApiTimeout: 10000,
+ backendApiCSRFCookieName: import.meta.env.VITE_PYCONKR_BACKEND_CSRF_COOKIE_NAME,
+ mdxComponents: {},
+};
+
+const SuspenseFallback = (
+
+
+
+);
+
+const MainApp: React.FC = () => {
+ const [appState, setAppContext] = React.useState>({
+ language: (localStorage.getItem(LOCAL_STORAGE_LANGUAGE_KEY) as "ko" | "en" | null) ?? "ko",
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+ReactDom.createRoot(document.getElementById("root")!).render();
diff --git a/apps/pyconkr-participant-portal/tsconfig.json b/apps/pyconkr-participant-portal/tsconfig.json
new file mode 100644
index 0000000..0e0e049
--- /dev/null
+++ b/apps/pyconkr-participant-portal/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+ "forceConsistentCasingInFileNames": false,
+
+ /* Paths */
+ "baseUrl": ".",
+ "paths": {
+ "@apps/pyconkr-participant-portal/*": ["apps/pyconkr-participant-portal/src/*"],
+ }
+ },
+ "include": ["src", "vite.config.mts", "vite-env.d.ts", "../../types"],
+}
diff --git a/apps/pyconkr-participant-portal/vite-env.d.ts b/apps/pyconkr-participant-portal/vite-env.d.ts
new file mode 100644
index 0000000..1a74337
--- /dev/null
+++ b/apps/pyconkr-participant-portal/vite-env.d.ts
@@ -0,0 +1,13 @@
+///
+///
+interface ViteTypeOptions {
+ strictImportEnv: unknown;
+}
+
+interface ImportMetaEnv {
+ readonly VITE_PYCONKR_BACKEND_API_DOMAIN: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/apps/pyconkr-participant-portal/vite.config.mts b/apps/pyconkr-participant-portal/vite.config.mts
new file mode 100644
index 0000000..d8314af
--- /dev/null
+++ b/apps/pyconkr-participant-portal/vite.config.mts
@@ -0,0 +1,21 @@
+import path from "path";
+
+import mdx from "@mdx-js/rollup";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+import mkcert from "vite-plugin-mkcert";
+import svgr from "vite-plugin-svgr";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ base: "/",
+ envDir: "../../dotenv",
+ plugins: [react(), mdx(), mkcert({ hosts: ["local.dev.pycon.kr"] }), svgr()],
+ resolve: {
+ alias: {
+ "@frontend/common": path.resolve(__dirname, "../../packages/common/src/index.ts"),
+ "@frontend/shop": path.resolve(__dirname, "../../packages/shop/src/index.ts"),
+ "@apps/pyconkr-participant-portal": path.resolve(__dirname, "./src"),
+ },
+ },
+});
diff --git a/package.json b/package.json
index f9ca0c5..745c51b 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,10 @@
"preview:@apps/pyconkr": "pnpm vite preview ./apps/pyconkr",
"dev:@apps/pyconkr-admin": "pnpm vite ./apps/pyconkr-admin",
"build:@apps/pyconkr-admin": "tsc -b && pnpm vite build ./apps/pyconkr-admin",
- "preview:@apps/pyconkr-admin": "pnpm vite preview ./apps/pyconkr-admin",
+ "preview:@apps/pyconkr-admin": "pnpm vite preview ./apps/pyconkr-participant-portal",
+ "dev:@apps/pyconkr-participant-portal": "pnpm vite ./apps/pyconkr-participant-portal",
+ "build:@apps/pyconkr-participant-portal": "tsc -b && pnpm vite build ./apps/pyconkr-participant-portal",
+ "preview:@apps/pyconkr-participant-portal": "pnpm vite preview ./apps/pyconkr-admin",
"lint": "eslint .",
"format": "prettier --write \"{apps,packages}/**/*.{js,jsx,ts,tsx}\"",
"format:check": "prettier --check \"{apps,packages}/**/*.{js,jsx,ts,tsx}\"",
diff --git a/packages/common/src/apis/participant_portal_api.ts b/packages/common/src/apis/participant_portal_api.ts
new file mode 100644
index 0000000..acf936d
--- /dev/null
+++ b/packages/common/src/apis/participant_portal_api.ts
@@ -0,0 +1,71 @@
+import { BackendAPIClient } from "./client";
+import ParticipantPortalAPISchemas from "../schemas/backendParticipantPortalAPI";
+
+namespace BackendParticipantPortalAPIs {
+ export const me = (client: BackendAPIClient) => async () => {
+ try {
+ return await client.get("v1/participant-portal/user/me/");
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ } catch (_) {
+ return null;
+ }
+ };
+
+ export const updateMe = (client: BackendAPIClient) => (data: ParticipantPortalAPISchemas.UserUpdateSchema) =>
+ client.patch("v1/participant-portal/user/me/", data);
+
+ export const previewMeModAudit = (client: BackendAPIClient) => async () =>
+ client.get("v1/participant-portal/user/me/preview/");
+
+ export const signIn = (client: BackendAPIClient) => (data: ParticipantPortalAPISchemas.UserSignInSchema) =>
+ client.post("v1/participant-portal/user/signin/", data);
+
+ export const signOut = (client: BackendAPIClient) => () => client.delete("v1/participant-portal/user/signout/");
+
+ export const changePassword = (client: BackendAPIClient) => (data: ParticipantPortalAPISchemas.UserChangePasswordSchema) =>
+ client.post("v1/participant-portal/user/password/", data);
+
+ export const listPublicFiles = (client: BackendAPIClient) => () =>
+ client.get("v1/participant-portal/public-file/");
+
+ export const uploadPublicFile = (client: BackendAPIClient) => (file: File) => {
+ const formData = new FormData();
+ formData.append("file", file);
+ return client.post(`v1/participant-portal/public-file/upload/`, formData, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+ };
+
+ export const listPresentations = (client: BackendAPIClient) => () =>
+ client.get("v1/participant-portal/presentation/");
+
+ export const retrievePresentation = (client: BackendAPIClient, id: string) => () => {
+ if (!id) return Promise.resolve(null);
+ return client.get(`v1/participant-portal/presentation/${id}/`);
+ };
+
+ export const previewPresentationModAudit = (client: BackendAPIClient, id: string) => () => {
+ if (!id) return Promise.resolve(null);
+ return client.get(`v1/participant-portal/presentation/${id}/preview/`);
+ };
+
+ export const patchPresentation = (client: BackendAPIClient) => (data: ParticipantPortalAPISchemas.PresentationUpdateSchema) =>
+ client.patch(
+ `v1/participant-portal/presentation/${data.id}/`,
+ data
+ );
+
+ export const listModificationAudits = (client: BackendAPIClient) => () =>
+ client.get("v1/participant-portal/modification-audit/");
+
+ export const retrieveModificationAudit = (client: BackendAPIClient, id: string) => () =>
+ client.get(`v1/participant-portal/modification-audit/${id}`);
+
+ export const cancelModificationAudit = (client: BackendAPIClient) => (data: ParticipantPortalAPISchemas.ModificationAuditCancelRequestSchema) =>
+ client.patch(
+ `v1/participant-portal/modification-audit/${data.id}/cancel/`,
+ data
+ );
+}
+
+export default BackendParticipantPortalAPIs;
diff --git a/packages/common/src/components/dnd_file_input.tsx b/packages/common/src/components/dnd_file_input.tsx
new file mode 100644
index 0000000..d79817e
--- /dev/null
+++ b/packages/common/src/components/dnd_file_input.tsx
@@ -0,0 +1,267 @@
+import { Cancel, PermMedia } from "@mui/icons-material";
+import { Box, Button, Input, Stack, styled } from "@mui/material";
+import { enqueueSnackbar, OptionsObject } from "notistack";
+import * as React from "react";
+
+import { useCommonContext } from "../hooks/useCommonContext";
+
+const ignoreEvent = (e: React.BaseSyntheticEvent | Event) => {
+ e.preventDefault();
+ e.stopPropagation();
+};
+
+const FileDragBox = styled(Box)<{ isMouseHover?: boolean }>(({ theme, isMouseHover }) => ({
+ width: "100%",
+ minWidth: "20rem",
+ flexGrow: 1,
+
+ padding: theme.spacing(2),
+
+ border: "2px dashed #ccc",
+ borderRadius: "0.5rem",
+ backgroundColor: isMouseHover ? "#ddd" : "#fff",
+
+ transition: "background-color 0.3s ease-in-out",
+}));
+
+type DndFileInputProps = {
+ onFileChange?: (file: File | null) => void;
+};
+
+type DndFileInputState = {
+ isMouseHoverOnDragBox?: boolean;
+ openSetValueDialog?: boolean;
+};
+
+export const DndFileInput: React.FC = ({ onFileChange }) => {
+ const fileInputRef = React.useRef(null);
+ const fileDragBoxRef = React.useRef(null);
+ const { language } = useCommonContext();
+ const [state, setState] = React.useState({});
+ const [, forceRender] = React.useReducer((x) => x + 1, 0);
+
+ const selectFileStr = language === "ko" ? "파일 선택" : "Select File";
+ const resetSelectStr = language === "ko" ? "파일 선택 초기화" : "Reset File Selection";
+ const fileNotSelectedStr = language === "ko" ? "파일을 선택하지 않았습니다." : "No file selected.";
+ const fileNotInClipboardStr = language === "ko" ? "클립보드에 파일이 없습니다." : "No file in clipboard.";
+ const imageFileNotInClipboardStr = language === "ko" ? "클립보드에 이미지 파일이 없습니다." : "No image file in clipboard.";
+ const fileIsEmptyStr = language === "ko" ? "파일을 찾을 수 없거나, 파일 크기가 0입니다." : "File not found or file is empty.";
+ const fileIsNotImageStr = language === "ko" ? "이미지 파일만 업로드가 가능합니다." : "Only image file can be uploaded.";
+ const fileReadErrorStr = language === "ko" ? "파일을 읽는 중 오류가 발생했습니다." : "An error occurred while reading the file.";
+
+ const selectedFile = (fileInputRef.current?.files?.length && fileInputRef.current.files[0]) || null;
+ const selectedFilePreview = selectedFile && (
+
+
+
+ );
+ const content =
+ language === "ko" ? (
+ <>
+ 아래 버튼을 눌러 사진을 선택하거나,
+
+ 이 상자에 직접 파일을 드래그 앤 드롭하여 업로드하거나,
+
+ 또는 Ctrl+V로 사진을 붙여넣어 주세요!
+
+
+ - 이미지 파일만 업로드가 가능합니다.
+ - 업로드 후에는 파일을 수정할 수 없습니다.
+ - 파일은 공개적으로 접근 가능한 URL로 제공됩니다.
+
+ {selectedFilePreview}
+
+ 현재 선택된 파일 : {(selectedFile && selectedFile.name) || "없음"}
+ >
+ ) : (
+ <>
+ Click the button below to select a photo, or drag and drop a file directly into this box to upload it, or
+ paste a photo using Ctrl+V!
+
+
+ - Only image files can be uploaded.
+ - You cannot modify the file after uploading.
+ - The file will be provided as a publicly accessible URL.
+
+ {selectedFilePreview}
+
+ Currently selected file: {(selectedFile && selectedFile.name) || "None"}
+ >
+ );
+
+ const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
+ enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
+
+ const onDragEnter = (e: React.DragEvent) => {
+ ignoreEvent(e);
+ setState((ps) => ({ ...ps, isMouseHoverOnDragBox: true }));
+ };
+ const onDragLeave = (e: React.DragEvent) => {
+ // onDragLeave 이벤트는 자식 요소에 마우스가 들어갈 때도 발생합니다.
+ // 따라서, 드래그 박스에 마우스가 있는지 확인하기 위해 마우스 위치를 확인하여 실제 onDragLeave 이벤트가 트리거되어야 하는지 확인합니다.
+ // (e.relatedTarget는 Safari에서 지원되지 않아 사용할 수 없습니다.)
+ ignoreEvent(e);
+ if (!fileDragBoxRef.current) return;
+
+ const x = e.clientX;
+ const y = e.clientY;
+ const currentHoveredElement = document.elementFromPoint(x, y);
+
+ if (!fileDragBoxRef.current.contains(currentHoveredElement) || (x === 0 && y === 0)) setState((ps) => ({ ...ps, isMouseHoverOnDragBox: false }));
+ };
+
+ const resetFileSelect = React.useCallback(() => {
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""; // 파일 선택 초기화
+ fileInputRef.current.files = null; // 파일 목록 초기화
+ forceRender();
+ }
+ }, [forceRender]);
+
+ const handleFile = React.useCallback(
+ (file: File) => {
+ if (!file || file.size === 0) {
+ addSnackbar(fileIsEmptyStr, "error");
+ return;
+ } else if (!file.type.startsWith("image/")) {
+ addSnackbar(fileIsNotImageStr, "error");
+ return;
+ }
+
+ const fileReader = new FileReader();
+ fileReader.onload = (event) => {
+ if (fileInputRef.current && event.target?.result) {
+ addSnackbar(language === "ko" ? `파일 ${file.name} 선택 완료` : `File ${file.name} selected successfully`, "info");
+
+ const list = new DataTransfer();
+ list.items.add(file);
+ fileInputRef.current.files = list.files;
+ onFileChange?.(file);
+ forceRender();
+ } else {
+ addSnackbar(fileReadErrorStr, "error");
+ console.error("파일 읽기 오류:", event);
+ }
+ };
+ fileReader.onerror = (error) => {
+ addSnackbar(`${fileReadErrorStr}: ${error}`, "error");
+ console.error("파일 읽기 중 오류 발생:", error);
+ };
+ fileReader.readAsDataURL(file);
+ },
+ [forceRender, onFileChange, fileIsEmptyStr, fileIsNotImageStr, fileReadErrorStr, language]
+ );
+
+ const onFileSelect = (e: React.ChangeEvent) => {
+ ignoreEvent(e);
+ setState((prev) => ({ ...prev, isMouseHoverOnDragBox: false }));
+
+ const files = e.target.files;
+ if (!files || files.length === 0) {
+ addSnackbar(fileNotSelectedStr, "error");
+ resetFileSelect();
+ return;
+ }
+
+ const file = files[0];
+ if (file.size === 0) {
+ addSnackbar(fileIsEmptyStr, "error");
+ resetFileSelect();
+ return;
+ } else if (!file.type.startsWith("image/")) {
+ addSnackbar(fileIsNotImageStr, "error");
+ resetFileSelect();
+ return;
+ }
+
+ handleFile(file);
+ };
+
+ const onFileSelectButtonClick: React.MouseEventHandler = () => {
+ if (fileInputRef.current) {
+ fileInputRef.current.click();
+ } else {
+ addSnackbar("파일 선택 버튼을 찾을 수 없습니다.", "error");
+ }
+ };
+
+ const onDrop: React.DragEventHandler = (event) => {
+ ignoreEvent(event);
+ setState((prev) => ({ ...prev, isMouseOnDragBox: false }));
+
+ const items = event.dataTransfer.files;
+ if (!items || items.length === 0) {
+ addSnackbar(fileNotSelectedStr, "error");
+ return;
+ }
+
+ handleFile(items[0]);
+ };
+
+ const onClipboardPaste = React.useCallback(
+ (event: DocumentEventMap["paste"]) => {
+ ignoreEvent(event);
+ setState((prev) => ({ ...prev, isMouseOnDragBox: false }));
+
+ const items = event.clipboardData?.items;
+ if (!items || items.length === 0) {
+ addSnackbar(fileNotInClipboardStr, "error");
+ return;
+ }
+
+ if (items instanceof DataTransferItemList) {
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].kind === "file") {
+ const file = items[i].getAsFile();
+ if (!file || !file.size || !file.type.startsWith("image/")) continue;
+
+ handleFile(file);
+ return;
+ }
+ }
+ addSnackbar(imageFileNotInClipboardStr, "error");
+ }
+ },
+ [handleFile, fileNotInClipboardStr, imageFileNotInClipboardStr]
+ );
+
+ React.useEffect(() => {
+ document.addEventListener("paste", onClipboardPaste);
+ return () => document.removeEventListener("paste", onClipboardPaste);
+ }, [onClipboardPaste, state.isMouseHoverOnDragBox]);
+
+ return (
+
+
+
+
+ }
+ onClick={resetFileSelect}
+ children={resetSelectStr}
+ />
+ }
+ onClick={onFileSelectButtonClick}
+ children={selectFileStr}
+ />
+
+
+ );
+};
diff --git a/packages/common/src/components/index.ts b/packages/common/src/components/index.ts
index 9789a75..f6e0950 100644
--- a/packages/common/src/components/index.ts
+++ b/packages/common/src/components/index.ts
@@ -1,5 +1,6 @@
import { CenteredPage as CenteredPageComponent } from "./centered_page";
import { CommonContextProvider as CommonContextProviderComponent } from "./common_context";
+import { DndFileInput as DndFileInputComponent } from "./dnd_file_input";
import { ErrorFallback as ErrorFallbackComponent } from "./error_handler";
import { FallbackImage as FallbackImageComponent } from "./fallback_image";
import { LinkHandler as LinkHandlerComponent } from "./link_handler";
@@ -41,6 +42,7 @@ namespace Components {
export const ErrorFallback = ErrorFallbackComponent;
export const FallbackImage = FallbackImageComponent;
export const LinkHandler = LinkHandlerComponent;
+ export const DndFileInput = DndFileInputComponent;
export namespace MDX {
export const Confetti = ConfettiComponent;
diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts
index fc7f55a..7dee57f 100644
--- a/packages/common/src/hooks/index.ts
+++ b/packages/common/src/hooks/index.ts
@@ -2,6 +2,7 @@ import BackendAPIHooks from "./useAPI";
import BackendAdminAPIHooks from "./useAdminAPI";
import { useCommonContext as useCommonContextHook } from "./useCommonContext";
import { useEmail as useEmailHook } from "./useEmail";
+import BackendParticipantPortalAPIHooks from "./useParticipantPortalAPI";
export namespace CommonHooks {
export const useCommonContext = useCommonContextHook;
@@ -12,6 +13,7 @@ namespace Hooks {
export const Common = CommonHooks;
export const BackendAPI = BackendAPIHooks;
export const BackendAdminAPI = BackendAdminAPIHooks;
+ export const BackendParticipantPortalAPI = BackendParticipantPortalAPIHooks;
}
export default Hooks;
diff --git a/packages/common/src/hooks/useParticipantPortalAPI.ts b/packages/common/src/hooks/useParticipantPortalAPI.ts
new file mode 100644
index 0000000..308f76d
--- /dev/null
+++ b/packages/common/src/hooks/useParticipantPortalAPI.ts
@@ -0,0 +1,123 @@
+import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
+
+import BackendAPIHooks from "./useAPI";
+import { BackendAPIClient } from "../apis/client";
+import ParticipantPortalAPI from "../apis/participant_portal_api";
+
+const QUERY_KEYS = {
+ PARTICIPANT_ME: ["query", "participant", "me"],
+ PARTICIPANT_PUBLIC_FILES: ["query", "participant", "list", "public-file"],
+ PARTICIPANT_LIST_PRESENTATION: ["query", "participant", "list", "presentation"],
+ PARTICIPANT_RETRIEVE_PRESENTATION: ["query", "participant", "retrieve", "presentation"],
+ PARTICIPANT_LIST_MODIFICATION_AUDIT: ["query", "participant", "list", "modification-audit"],
+ PARTICIPANT_RETRIEVE_MODIFICATION_AUDIT: ["query", "participant", "retrieve", "modification-audit"],
+};
+
+const MUTATION_KEYS = {
+ PARTICIPANT_SIGN_IN: ["mutation", "participant", "sign-in"],
+ PARTICIPANT_SIGN_OUT: ["mutation", "participant", "sign-out"],
+ PARTICIPANT_CHANGE_PASSWORD: ["mutation", "participant", "change-password"],
+ PARTICIPANT_UPDATE_ME: ["mutation", "participant", "update-me"],
+ PARTICIPANT_UPLOAD_PUBLIC_FILE: ["mutation", "participant", "public-file", "upload"],
+ PARTICIPANT_UPDATE_PRESENTATION: ["mutation", "participant", "update", "presentation"],
+ PARTICIPANT_CANCEL_MODIFICATION_AUDIT: ["mutation", "participant", "cancel", "modification-audit"],
+};
+
+namespace BackendParticipantPortalAPIHooks {
+ export const useParticipantPortalClient = () => {
+ const { backendApiDomain, backendApiTimeout, backendApiCSRFCookieName, language } = BackendAPIHooks.useBackendContext();
+ return new BackendAPIClient(backendApiDomain, backendApiTimeout, backendApiCSRFCookieName, true, language);
+ };
+
+ export const useSignedInUserQuery = (client: BackendAPIClient) =>
+ useSuspenseQuery({
+ queryKey: [...QUERY_KEYS.PARTICIPANT_ME, client.language],
+ queryFn: ParticipantPortalAPI.me(client),
+ });
+
+ export const usePreviewMeModAuditQuery = (client: BackendAPIClient) =>
+ useSuspenseQuery({
+ queryKey: [...QUERY_KEYS.PARTICIPANT_ME, "preview", client.language],
+ queryFn: ParticipantPortalAPI.previewMeModAudit(client),
+ });
+
+ export const useUpdateMeMutation = (client: BackendAPIClient) =>
+ useMutation({
+ mutationKey: [...MUTATION_KEYS.PARTICIPANT_UPDATE_ME],
+ mutationFn: ParticipantPortalAPI.updateMe(client),
+ });
+
+ export const useSignInMutation = (client: BackendAPIClient) =>
+ useMutation({
+ mutationKey: [...MUTATION_KEYS.PARTICIPANT_SIGN_IN],
+ mutationFn: ParticipantPortalAPI.signIn(client),
+ });
+
+ export const useSignOutMutation = (client: BackendAPIClient) =>
+ useMutation({
+ mutationKey: [...MUTATION_KEYS.PARTICIPANT_SIGN_OUT],
+ mutationFn: ParticipantPortalAPI.signOut(client),
+ });
+
+ export const useChangePasswordMutation = (client: BackendAPIClient) =>
+ useMutation({
+ mutationKey: [...MUTATION_KEYS.PARTICIPANT_CHANGE_PASSWORD],
+ mutationFn: ParticipantPortalAPI.changePassword(client),
+ });
+
+ export const usePublicFilesQuery = (client: BackendAPIClient) =>
+ useSuspenseQuery({
+ queryKey: [...QUERY_KEYS.PARTICIPANT_PUBLIC_FILES, client.language],
+ queryFn: ParticipantPortalAPI.listPublicFiles(client),
+ });
+
+ export const useUploadPublicFileMutation = (client: BackendAPIClient) =>
+ useMutation({
+ mutationKey: [...MUTATION_KEYS.PARTICIPANT_UPLOAD_PUBLIC_FILE, "upload"],
+ mutationFn: ParticipantPortalAPI.uploadPublicFile(client),
+ });
+
+ export const useListPresentationsQuery = (client: BackendAPIClient) =>
+ useSuspenseQuery({
+ queryKey: [...QUERY_KEYS.PARTICIPANT_LIST_PRESENTATION, client.language],
+ queryFn: ParticipantPortalAPI.listPresentations(client),
+ });
+
+ export const useRetrievePresentationQuery = (client: BackendAPIClient, id: string) =>
+ useSuspenseQuery({
+ queryKey: [...QUERY_KEYS.PARTICIPANT_RETRIEVE_PRESENTATION, id, client.language],
+ queryFn: ParticipantPortalAPI.retrievePresentation(client, id),
+ });
+
+ export const useUpdatePresentationMutation = (client: BackendAPIClient) =>
+ useMutation({
+ mutationKey: [...MUTATION_KEYS.PARTICIPANT_UPDATE_PRESENTATION],
+ mutationFn: ParticipantPortalAPI.patchPresentation(client),
+ });
+
+ export const usePreviewPresentationModAuditQuery = (client: BackendAPIClient, id: string) =>
+ useSuspenseQuery({
+ queryKey: [...QUERY_KEYS.PARTICIPANT_RETRIEVE_PRESENTATION, id, "preview", client.language],
+ queryFn: ParticipantPortalAPI.previewPresentationModAudit(client, id),
+ });
+
+ export const useModificationAuditsQuery = (client: BackendAPIClient) =>
+ useSuspenseQuery({
+ queryKey: [...QUERY_KEYS.PARTICIPANT_LIST_MODIFICATION_AUDIT, client.language],
+ queryFn: ParticipantPortalAPI.listModificationAudits(client),
+ });
+
+ export const useRetrieveModificationAuditQuery = (client: BackendAPIClient, id: string) =>
+ useSuspenseQuery({
+ queryKey: [...QUERY_KEYS.PARTICIPANT_RETRIEVE_MODIFICATION_AUDIT, id, client.language],
+ queryFn: ParticipantPortalAPI.retrieveModificationAudit(client, id),
+ });
+
+ export const useCancelModificationAuditMutation = (client: BackendAPIClient) =>
+ useMutation({
+ mutationKey: [...MUTATION_KEYS.PARTICIPANT_CANCEL_MODIFICATION_AUDIT],
+ mutationFn: ParticipantPortalAPI.cancelModificationAudit(client),
+ });
+}
+
+export default BackendParticipantPortalAPIHooks;
diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts
index 4a00754..3517381 100644
--- a/packages/common/src/index.ts
+++ b/packages/common/src/index.ts
@@ -1,5 +1,6 @@
export { default as BackendAdminAPIs } from "./apis/admin_api";
export { default as BackendAPIs } from "./apis/index";
+export { default as BackendParticipantPortalAPIs } from "./apis/participant_portal_api";
export { default as Components } from "./components/index";
export { default as Contexts } from "./contexts/index";
export { default as Hooks } from "./hooks/index";
diff --git a/packages/common/src/schemas/backendParticipantPortalAPI.ts b/packages/common/src/schemas/backendParticipantPortalAPI.ts
new file mode 100644
index 0000000..9652a5b
--- /dev/null
+++ b/packages/common/src/schemas/backendParticipantPortalAPI.ts
@@ -0,0 +1,116 @@
+namespace BackendParticipantPortalAPISchemas {
+ export type EmptyObject = Record;
+
+ export type DetailedErrorSchema = {
+ code: string;
+ detail: string;
+ attr: string | null;
+ };
+
+ export type ErrorResponseSchema = {
+ type: string;
+ errors: DetailedErrorSchema[];
+ };
+
+ export type UserSchema = {
+ id: number;
+ email: string;
+ username: string;
+ nickname: string | null;
+ nickname_ko: string | null;
+ nickname_en: string | null;
+ image: string | null; // PK of the user's profile image
+ profile_image: string | null; // URL to the user's profile image
+
+ has_requested_modification_audit: boolean;
+ requested_modification_audit_id: string | null;
+ };
+
+ export type UserUpdateSchema = {
+ nickname_ko: string | null;
+ nickname_en: string | null;
+ image?: string | null; // PK of the user's profile image
+ };
+
+ export type UserSignInSchema = {
+ identity: string; // email
+ password: string;
+ };
+
+ export type UserChangePasswordSchema = {
+ old_password: string;
+ new_password: string;
+ new_password_confirm: string;
+ };
+
+ export type PublicFileSchema = {
+ id: string; // UUID
+ file: string; // URL to the public file
+ name: string; // Name of the public file
+ };
+
+ export type PresentationRetrieveSchema = {
+ id: string; // UUID
+ title: string; // Title of the presentation, translated to the current language
+ title_ko: string; // Title in Korean
+ title_en: string; // Title in English
+ summary: string; // Summary of the presentation, translated to the current language
+ summary_ko: string; // Summary in Korean
+ summary_en: string; // Summary in English
+ description: string; // Description of the presentation, translated to the current language
+ description_ko: string; // Description in Korean
+ description_en: string; // Description in English
+ image: string | null; // PK of the presentation's image
+ speakers: {
+ id: string; // UUID of the speaker
+ biography_ko: string; // Biography in Korean
+ biography_en: string; // Biography in English
+ image: string | null; // PK of the speaker's image
+ }[];
+
+ has_requested_modification_audit: boolean;
+ requested_modification_audit_id: string | null;
+ };
+
+ export type PresentationUpdateSchema = {
+ id: string;
+ title_ko: string;
+ title_en: string;
+ summary_ko: string;
+ summary_en: string;
+ description_ko: string;
+ description_en: string;
+ image: string | null;
+ };
+
+ export type ModificationAuditSchema = {
+ id: string; // UUID
+ str_repr: string; // String representation of the modification audit, e.g., "Presentation Title - Status"
+ status: "requested" | "approved" | "rejected" | "cancelled"; // Status of the modification request
+ created_at: string; // ISO 8601 timestamp
+ updated_at: string; // ISO 8601 timestamp
+
+ instance_type: string; // Type of the instance being modified (e.g., "presentation")
+ instance_id: string; // UUID of the instance being modified (e.g., presentation ID)
+ modification_data: string; // JSON string containing the modification data
+
+ comments: {
+ id: string; // UUID of the comment
+ content: string; // Content of the comment
+ created_at: string; // ISO 8601 timestamp
+ created_by: {
+ id: number; // User ID of the commenter
+ nickname: string; // Nickname of the commenter
+ is_superuser: boolean; // Whether the commenter is a staff member
+ };
+ updated_at: string; // ISO 8601 timestamp
+ }[];
+ };
+
+ export type ModificationAuditCancelRequestSchema = {
+ id: string; // UUID of the modification audit
+ reason: string | null; // Reason for cancelling the modification request
+ };
+}
+
+export default BackendParticipantPortalAPISchemas;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cb78005..16a1b5b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -270,6 +270,28 @@ importers:
specifier: ^4.3.0
version: 4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.15.18))
+ apps/pyconkr-participant-portal:
+ dependencies:
+ '@frontend/common':
+ specifier: workspace:*
+ version: link:../../packages/common
+ '@frontend/shop':
+ specifier: workspace:*
+ version: link:../../packages/shop
+ devDependencies:
+ vite:
+ specifier: ^6.3.5
+ version: 6.3.5(@types/node@22.15.18)
+ vite-plugin-mdx:
+ specifier: ^3.6.1
+ version: 3.6.1(@mdx-js/mdx@3.1.0(acorn@8.14.1))(vite@6.3.5(@types/node@22.15.18))
+ vite-plugin-mkcert:
+ specifier: ^1.17.8
+ version: 1.17.8(vite@6.3.5(@types/node@22.15.18))
+ vite-plugin-svgr:
+ specifier: ^4.3.0
+ version: 4.3.0(rollup@4.40.2)(typescript@5.7.3)(vite@6.3.5(@types/node@22.15.18))
+
packages/common:
dependencies:
'@apps/pyconkr':