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 ( + + + + {content} + {/*
+ */} +
+ +
+ ); +}; 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 ( +
+ + {description &&
} />} + + + + + + + + +
+ ); +}; + +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 ( +
+ + {description &&
} />} + + + + + + + {!disabled && ( + + + + )} + + + + + + +
+ ); +}; + +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 ( +
+ + {description &&
} />} + + + + + + + + +
+ ); +}; 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 ( + <> + +
+ + } /> + + + {props.label} + + +
+ + ); + }) +); 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} +