From a8dc19a02af201c4fe94efa32a103acae012eec5 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 29 Jun 2025 20:57:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=B0=B8=EA=B0=80=EC=9E=90=20?= =?UTF-8?q?=ED=8F=AC=ED=83=88=20App=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pyconkr-participant-portal/index.html | 50 ++++ apps/pyconkr-participant-portal/package.json | 13 + .../public/favicon-180.png | Bin 0 -> 3871 bytes .../public/favicon-192.png | Bin 0 -> 4371 bytes .../public/favicon-512.png | Bin 0 -> 15538 bytes .../public/favicon.ico | Bin 0 -> 1380 bytes .../public/favicon.svg | 1 + .../public/site.webmanifest | 25 ++ apps/pyconkr-participant-portal/src/App.tsx | 22 ++ .../components/dialogs/change_password.tsx | 76 +++++ .../components/dialogs/public_file_upload.tsx | 122 ++++++++ .../src/components/dialogs/submit_confirm.tsx | 47 +++ .../src/components/elements/blockquote.tsx | 8 + .../src/components/elements/error_page.tsx | 15 + .../src/components/elements/fieldset.tsx | 26 ++ .../src/components/elements/loading_page.tsx | 19 ++ .../components/elements/multilang_field.tsx | 205 ++++++++++++++ .../elements/public_file_selector.tsx | 70 +++++ .../src/components/elements/signin_guard.tsx | 17 ++ .../src/components/elements/titles.tsx | 27 ++ .../src/components/layout.tsx | 141 +++++++++ .../src/components/page.tsx | 25 ++ .../src/components/pages/home.tsx | 142 ++++++++++ .../src/components/pages/profile_editor.tsx | 131 +++++++++ .../src/components/pages/session_editor.tsx | 216 ++++++++++++++ .../src/components/pages/signin.tsx | 91 ++++++ .../src/components/pages/sponsor_editor.tsx | 37 +++ .../src/consts/index.ts | 1 + .../src/consts/local_stroage.ts | 1 + .../src/contexts/app_context.tsx | 18 ++ apps/pyconkr-participant-portal/src/main.tsx | 82 ++++++ apps/pyconkr-participant-portal/tsconfig.json | 32 +++ apps/pyconkr-participant-portal/vite-env.d.ts | 13 + .../vite.config.mts | 21 ++ package.json | 5 +- .../common/src/apis/participant_portal_api.ts | 51 ++++ .../common/src/components/dnd_file_input.tsx | 267 ++++++++++++++++++ packages/common/src/components/index.ts | 2 + packages/common/src/hooks/index.ts | 2 + .../src/hooks/useParticipantPortalAPI.ts | 90 ++++++ packages/common/src/index.ts | 1 + .../schemas/backendParticipantPortalAPI.ts | 81 ++++++ pnpm-lock.yaml | 22 ++ 43 files changed, 2214 insertions(+), 1 deletion(-) create mode 100644 apps/pyconkr-participant-portal/index.html create mode 100644 apps/pyconkr-participant-portal/package.json create mode 100755 apps/pyconkr-participant-portal/public/favicon-180.png create mode 100755 apps/pyconkr-participant-portal/public/favicon-192.png create mode 100755 apps/pyconkr-participant-portal/public/favicon-512.png create mode 100755 apps/pyconkr-participant-portal/public/favicon.ico create mode 100755 apps/pyconkr-participant-portal/public/favicon.svg create mode 100755 apps/pyconkr-participant-portal/public/site.webmanifest create mode 100644 apps/pyconkr-participant-portal/src/App.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/dialogs/change_password.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/dialogs/public_file_upload.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/dialogs/submit_confirm.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/elements/blockquote.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/elements/error_page.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/elements/fieldset.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/elements/loading_page.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/elements/multilang_field.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/elements/public_file_selector.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/elements/signin_guard.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/elements/titles.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/layout.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/page.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/pages/home.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/pages/profile_editor.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/pages/session_editor.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/pages/signin.tsx create mode 100644 apps/pyconkr-participant-portal/src/components/pages/sponsor_editor.tsx create mode 100644 apps/pyconkr-participant-portal/src/consts/index.ts create mode 100644 apps/pyconkr-participant-portal/src/consts/local_stroage.ts create mode 100644 apps/pyconkr-participant-portal/src/contexts/app_context.tsx create mode 100644 apps/pyconkr-participant-portal/src/main.tsx create mode 100644 apps/pyconkr-participant-portal/tsconfig.json create mode 100644 apps/pyconkr-participant-portal/vite-env.d.ts create mode 100644 apps/pyconkr-participant-portal/vite.config.mts create mode 100644 packages/common/src/apis/participant_portal_api.ts create mode 100644 packages/common/src/components/dnd_file_input.tsx create mode 100644 packages/common/src/hooks/useParticipantPortalAPI.ts create mode 100644 packages/common/src/schemas/backendParticipantPortalAPI.ts diff --git a/apps/pyconkr-participant-portal/index.html b/apps/pyconkr-participant-portal/index.html new file mode 100644 index 0000000..397d51e --- /dev/null +++ b/apps/pyconkr-participant-portal/index.html @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000000000000000000000000000000000000..232c395e2fba3f13fa964e4248ec53d75a3a3558 GIT binary patch literal 3871 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 RXQL14t`%u)G_&P$(_BY2n8{-H^ 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 \ 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..2a55bde --- /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/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..10e5aad --- /dev/null +++ b/apps/pyconkr-participant-portal/src/components/elements/multilang_field.tsx @@ -0,0 +1,205 @@ +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: "flex-start", + + [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)(({ theme }) => ({ + width: "50%", + maxWidth: "50%", + backgroundColor: "#fff", + + "& .markdown-body": { + width: "100%", + p: { margin: theme.spacing(2, 0) }, + }, +})); + +export const MultiLanguageMarkdownField: 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 = (value?: string) => onChange?.(value, fieldState.selectedFieldLanguage); + + return ( +
+ + {description &&
} />} + + + + + + + + + + + + + + + +
+ ); +}; + +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..c5f71cd --- /dev/null +++ b/apps/pyconkr-participant-portal/src/components/elements/public_file_selector.tsx @@ -0,0 +1,70 @@ +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/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} /> + + + {signedIn && } + + + + ); +}; + +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..4b2fb97 --- /dev/null +++ b/apps/pyconkr-participant-portal/src/components/pages/home.tsx @@ -0,0 +1,142 @@ +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: 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"; + + return ( + + + +
+ + + + } /> + + + + + + + +
+ + + + 준비 중입니다. + + + + + {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..6fd21cf --- /dev/null +++ b/apps/pyconkr-participant-portal/src/components/pages/profile_editor.tsx @@ -0,0 +1,131 @@ +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 { 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"); + }, + } + ); + }; + + return ( + <> + + + + + + + + +