diff --git a/README.md b/README.md index be4a28c20..f7a05e902 100644 --- a/README.md +++ b/README.md @@ -556,6 +556,7 @@ The following summarizes the various [apps](#adding-a-new-platform-ui-applicatio - [Gamification Admin](#gamification-admin) - [Learn](#learn) - [Self Service](#self-service) +- [Review](#review) ## Platform App @@ -599,3 +600,10 @@ Application that allows customers to submit/start challenges self-service. [Work README TBD](./src/apps/self-service/README.md) [Work Routes](./src/apps/self-service/src/self-service.routes.tsx) + +## Review + +The application that allows managing the review submissions. + +[Review README TBD](./src/apps/review/README.md) +[Review Routes](./src/apps/review/src/review-app.routes.tsx) diff --git a/package.json b/package.json index 93a0323e3..6881377ef 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@storybook/react": "7.6.10", "@stripe/react-stripe-js": "1.13.0", "@stripe/stripe-js": "1.41.0", + "@types/codemirror": "^5.60.15", "apexcharts": "^3.36.0", "axios": "^1.7.9", "browser-cookies": "^1.2.0", @@ -43,6 +44,7 @@ "draft-js-export-html": "^1.2.0", "draft-js-markdown-shortcuts-plugin": "^0.3.0", "draft-js-plugins-editor": "^2.0.3", + "easymde": "^2.20.0", "express": "^4.21.2", "express-fileupload": "^1.4.0", "express-interceptor": "^1.2.0", @@ -92,9 +94,12 @@ "redux-promise": "^0.6.0", "redux-promise-middleware": "^6.1.3", "redux-thunk": "^2.4.1", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", "remark-breaks": "^3.0.2", "remark-frontmatter": "^4.0.1", "remark-gfm": "^3.0.1", + "remark-parse": "^11.0.0", "remove": "^0.1.5", "sanitize-html": "^2.12.1", "sass": "^1.79.0", diff --git a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.module.scss b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.module.scss index 2e04aee7f..5a513a5c8 100644 --- a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.module.scss +++ b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.module.scss @@ -59,3 +59,10 @@ text-transform: uppercase; white-space: nowrap !important; } + +.blockCellLastValue { + :global(.TableCell_blockCell) { + justify-content: flex-end; + text-align: right; + } +} diff --git a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx index f1354bccb..b39760485 100644 --- a/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx +++ b/src/apps/admin/src/lib/components/common/TableMobile/TableMobile.tsx @@ -81,6 +81,9 @@ export const TableMobile: ( [styles.blockCellLabel]: itemItemColumns.mobileType === 'label', + [styles.blockCellLastValue]: + itemItemColumns.mobileType + === 'last-value', }, styles.blockCell, )} diff --git a/src/apps/admin/src/lib/models/MobileTableColumn.model.ts b/src/apps/admin/src/lib/models/MobileTableColumn.model.ts index 4f24b98a4..01229aa43 100644 --- a/src/apps/admin/src/lib/models/MobileTableColumn.model.ts +++ b/src/apps/admin/src/lib/models/MobileTableColumn.model.ts @@ -4,5 +4,5 @@ import { TableColumn } from '~/libs/ui' export interface MobileTableColumn extends TableColumn { - readonly mobileType?: 'label' + readonly mobileType?: 'label' | 'last-value' } diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index 3f85dbbc5..0692b7553 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -12,6 +12,7 @@ import { walletRoutes } from '~/apps/wallet' import { walletAdminRoutes } from '~/apps/wallet-admin' import { copilotsRoutes } from '~/apps/copilots' import { adminRoutes } from '~/apps/admin' +import { reviewRoutes } from '~/apps/review' const Home: LazyLoadedComponent = lazyLoad( () => import('./routes/home'), @@ -41,6 +42,7 @@ export const platformRoutes: Array = [ ...walletAdminRoutes, ...accountsRoutes, ...skillsManagerRoutes, + ...reviewRoutes, ...homeRoutes, ...adminRoutes, ] diff --git a/src/apps/review/README.md b/src/apps/review/README.md new file mode 100644 index 000000000..de43e93f1 --- /dev/null +++ b/src/apps/review/README.md @@ -0,0 +1,23 @@ +# Instructions For Running The Review Locally + +### Build and run: + +- Run this script to start the app, you may have to type the admin password for the `sudo` command: + +```bash +nvm use +export NVM_DIR=~/.nvm +yarn install +sudo yarn start +``` + +- If you have any problem when running the above script, please check `README.md` in the root of the project for more info. +- After running successfully, please open `https://local.topcoder-dev.com/review` in the browser to start the admin app + +### Configuration: + +- Configuration files are under src/apps/review/src/config + +### Mock data: + +- Mock data files are under src/apps/review/src/mock-datas diff --git a/src/apps/review/index.ts b/src/apps/review/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/review/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/review/src/ReviewApp.tsx b/src/apps/review/src/ReviewApp.tsx new file mode 100644 index 000000000..66f25fc70 --- /dev/null +++ b/src/apps/review/src/ReviewApp.tsx @@ -0,0 +1,35 @@ +/** + * The review app. + */ +import { FC, useContext, useEffect, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { Layout, SWRConfigProvider } from './lib' +import { toolTitle } from './review-app.routes' +import './lib/styles/index.scss' + +const ReviewApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + // eslint-disable-next-line react-hooks/exhaustive-deps -- missing dependency: getChildRoutes + const childRoutes = useMemo(() => getChildRoutes(toolTitle), []) + + useEffect(() => { + document.body.classList.add('review-app') + return () => { + document.body.classList.remove('review-app') + } + }, []) + + return ( + + + + {childRoutes} + + + ) +} + +export default ReviewApp diff --git a/src/apps/review/src/config/index.config.ts b/src/apps/review/src/config/index.config.ts new file mode 100644 index 000000000..c1365d4c0 --- /dev/null +++ b/src/apps/review/src/config/index.config.ts @@ -0,0 +1,47 @@ +/** + * Common config for ui. + */ + +import { InputSelectOption } from '~/libs/ui' + +export const CHALLENGE_TYPE_SELECT_OPTIONS: InputSelectOption[] = [ + { + label: 'All', + value: '', + }, + ...[ + 'Design', + 'Code', + 'Bug Hunt', + 'Test Suite', + 'Copilot Opportunity', + 'Marathon Match', + 'First2Finish', + 'Other', + ].map(item => ({ label: item, value: item })), +] +export const QUESTION_YES_NO_OPTIONS: InputSelectOption[] = ['Yes', 'No'].map( + item => ({ label: item, value: item }), +) +export const QUESTION_RESPONSE_OPTIONS: InputSelectOption[] = [ + { + label: 'Comment', + value: 'COMMENT', + }, + { + label: 'Required', + value: 'REQUIRED', + }, + { + label: 'Recommended', + value: 'RECOMMENDED', + }, +] +export const QUESTION_RESPONSE_TYPE_MAPPING_DISPLAY: { [key: string]: string } += { + COMMENT: 'Comment', + RECOMMENDED: 'Recommended', + REQUIRED: 'Required', +} +export const TABLE_DATE_FORMAT = 'MMM DD, HH:mm A' +export const THRESHOLD_SHORT_TIME = 2 * 60 * 60 * 1000 // in miliseconds diff --git a/src/apps/review/src/config/routes.config.ts b/src/apps/review/src/config/routes.config.ts new file mode 100644 index 000000000..5fe06baad --- /dev/null +++ b/src/apps/review/src/config/routes.config.ts @@ -0,0 +1,13 @@ +/** + * Common config for routes in review app. + */ +import { AppSubdomain, EnvironmentConfig } from '~/config' + +export const rootRoute: string + = EnvironmentConfig.SUBDOMAIN === AppSubdomain.review + ? '' + : `/${AppSubdomain.review}` + +export const activeReviewAssigmentsRouteId = 'active-review-assigments' +export const openOpportunitiesRouteId = 'open-opportunities' +export const pastReviewAssignmentsRouteId = 'past-review-assignments' diff --git a/src/apps/review/src/declarations.d.ts b/src/apps/review/src/declarations.d.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/review/src/index.ts b/src/apps/review/src/index.ts new file mode 100644 index 000000000..1070aef1f --- /dev/null +++ b/src/apps/review/src/index.ts @@ -0,0 +1,2 @@ +export { reviewRoutes } from './review-app.routes' +export { rootRoute as reviewRootRoute } from './config/routes.config' diff --git a/src/apps/review/src/lib/assets/icons/arrow-left.svg b/src/apps/review/src/lib/assets/icons/arrow-left.svg new file mode 100644 index 000000000..b6fe3376c --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/chevron-down.svg b/src/apps/review/src/lib/assets/icons/chevron-down.svg new file mode 100644 index 000000000..82b096f96 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/editor/bold.ts b/src/apps/review/src/lib/assets/icons/editor/bold.ts new file mode 100644 index 000000000..424a7d281 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/bold.ts @@ -0,0 +1,3 @@ +/* eslint-disable max-len */ +export const IconBold += '' diff --git a/src/apps/review/src/lib/assets/icons/editor/code.ts b/src/apps/review/src/lib/assets/icons/editor/code.ts new file mode 100644 index 000000000..5de1d83ed --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/code.ts @@ -0,0 +1,5 @@ +/* eslint-disable max-len */ +export const IconCode = ` + + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/heading-1.ts b/src/apps/review/src/lib/assets/icons/editor/heading-1.ts new file mode 100644 index 000000000..9d4e1b71c --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/heading-1.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconHeading1 = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/heading-2.ts b/src/apps/review/src/lib/assets/icons/editor/heading-2.ts new file mode 100644 index 000000000..4ebde5589 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/heading-2.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconHeading2 = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/heading-3.ts b/src/apps/review/src/lib/assets/icons/editor/heading-3.ts new file mode 100644 index 000000000..cac94bc5c --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/heading-3.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconHeading3 = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/image.ts b/src/apps/review/src/lib/assets/icons/editor/image.ts new file mode 100644 index 000000000..85923791e --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/image.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconImage = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/italic.ts b/src/apps/review/src/lib/assets/icons/editor/italic.ts new file mode 100644 index 000000000..2930710e9 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/italic.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconItalic = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/link.ts b/src/apps/review/src/lib/assets/icons/editor/link.ts new file mode 100644 index 000000000..4e1247681 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/link.ts @@ -0,0 +1,13 @@ +/* eslint-disable max-len */ +export const IconLink = ` + + + + + + + + + + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/mentions.ts b/src/apps/review/src/lib/assets/icons/editor/mentions.ts new file mode 100644 index 000000000..a7b96df56 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/mentions.ts @@ -0,0 +1,3 @@ +/* eslint-disable max-len */ +export const IconMentions += '' diff --git a/src/apps/review/src/lib/assets/icons/editor/ordered-list.ts b/src/apps/review/src/lib/assets/icons/editor/ordered-list.ts new file mode 100644 index 000000000..5d8a3397f --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/ordered-list.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconOrderedList = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/quote.ts b/src/apps/review/src/lib/assets/icons/editor/quote.ts new file mode 100644 index 000000000..3dbca3ad6 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/quote.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconQuote = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/strikethrough.ts b/src/apps/review/src/lib/assets/icons/editor/strikethrough.ts new file mode 100644 index 000000000..02734f693 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/strikethrough.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconStrikethrough = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/table.ts b/src/apps/review/src/lib/assets/icons/editor/table.ts new file mode 100644 index 000000000..e9820a787 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/table.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconTable = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/unordered-list.ts b/src/apps/review/src/lib/assets/icons/editor/unordered-list.ts new file mode 100644 index 000000000..15a0fbad7 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/unordered-list.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconUnorderedList = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/editor/upload-file.ts b/src/apps/review/src/lib/assets/icons/editor/upload-file.ts new file mode 100644 index 000000000..a043fb1db --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/editor/upload-file.ts @@ -0,0 +1,4 @@ +/* eslint-disable max-len */ +export const IconUploadFile = ` + +` diff --git a/src/apps/review/src/lib/assets/icons/external-link.svg b/src/apps/review/src/lib/assets/icons/external-link.svg new file mode 100644 index 000000000..8b094347b --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/external-link.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/assets/icons/index.ts b/src/apps/review/src/lib/assets/icons/index.ts new file mode 100644 index 000000000..bb70137c2 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/index.ts @@ -0,0 +1,21 @@ +import { ReactComponent as IconArrowLeft } from './arrow-left.svg' +import { ReactComponent as IconExternalLink } from './external-link.svg' +import { ReactComponent as IconChevronDown } from './chevron-down.svg' + +export * from './editor/bold' +export * from './editor/code' +export * from './editor/heading-1' +export * from './editor/heading-2' +export * from './editor/heading-3' +export * from './editor/image' +export * from './editor/italic' +export * from './editor/link' +export * from './editor/mentions' +export * from './editor/ordered-list' +export * from './editor/quote' +export * from './editor/strikethrough' +export * from './editor/table' +export * from './editor/unordered-list' +export * from './editor/upload-file' + +export { IconArrowLeft, IconExternalLink, IconChevronDown } diff --git a/src/apps/review/src/lib/assets/icons/selector.svg b/src/apps/review/src/lib/assets/icons/selector.svg new file mode 100644 index 000000000..5fe7c73a8 --- /dev/null +++ b/src/apps/review/src/lib/assets/icons/selector.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/review/src/lib/components/AppealComment/AppealComment.module.scss b/src/apps/review/src/lib/components/AppealComment/AppealComment.module.scss new file mode 100644 index 000000000..10bad02a6 --- /dev/null +++ b/src/apps/review/src/lib/components/AppealComment/AppealComment.module.scss @@ -0,0 +1,46 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + gap: 22px; + align-items: flex-start; +} + +.blockAppealComment { + background-color: var(--Tertiary); +} + +.blockAppealResponse { + background-color: var(--VeryLightBlue); +} + +.blockAppealResponse, +.blockAppealComment { + padding: 20px 20px 35px; + display: flex; + flex-direction: column; + gap: 11px; + width: 100%; +} + +.markdownEditor { + width: 100%; +} + +.textTitle { + font-weight: $font-weight-semibold; +} + +.blockBtns { + display: flex; + gap: 24px; + flex-wrap: wrap; +} + +.blockForm { + display: flex; + flex-direction: column; + gap: 22px; + width: 100%; +} diff --git a/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx b/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx new file mode 100644 index 000000000..785fb3bd8 --- /dev/null +++ b/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx @@ -0,0 +1,123 @@ +/** + * AppealComment. + */ +import { FC, useCallback, useState } from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { Button } from '~/libs/ui' +import { yupResolver } from '@hookform/resolvers/yup' + +import { MarkdownReview } from '../MarkdownReview' +import { FieldMarkdownEditor } from '../FieldMarkdownEditor' +import { AppealInfo, FormAppealResponse } from '../../models' +import { formAppealResponseSchema } from '../../utils' + +import styles from './AppealComment.module.scss' + +interface Props { + className?: string + data: AppealInfo +} + +export const AppealComment: FC = (props: Props) => { + const [appealResponse, setAppealResponse] = useState('') + const [showResponseForm, setShowResponseForm] = useState(false) + const [showAppealResponse, setShowAppealResponse] = useState(false) + + const { + handleSubmit, + control, + formState: { errors }, + }: UseFormReturn = useForm({ + defaultValues: { + response: '', + }, + mode: 'all', + resolver: yupResolver(formAppealResponseSchema), + }) + + const onSubmit = useCallback((data: FormAppealResponse) => { + setAppealResponse(data.response) + setShowResponseForm(false) + setShowAppealResponse(true) + }, []) + + return ( +
+
+ Appeal Comment + +
+ {showAppealResponse && ( +
+ Appeal Response + +
+ )} + + {!showResponseForm && !showAppealResponse && ( +
+ + )} + + ) +} + +export default AppealComment diff --git a/src/apps/review/src/lib/components/AppealComment/index.ts b/src/apps/review/src/lib/components/AppealComment/index.ts new file mode 100644 index 000000000..77087532f --- /dev/null +++ b/src/apps/review/src/lib/components/AppealComment/index.ts @@ -0,0 +1 @@ +export { default as AppealComment } from './AppealComment' diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx new file mode 100644 index 000000000..2fbfbf031 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx @@ -0,0 +1,47 @@ +/** + * Challenge Details Content. + */ +import { FC } from 'react' + +import { ProjectResult, RegistrationInfo, SubmissionInfo } from '../../models' +import { TableRegistration } from '../TableRegistration' +import { TableNoRecord } from '../TableNoRecord' +import { TableSubmissionScreening } from '../TableSubmissionScreening' +import { TableReviewAppeals } from '../TableReviewAppeals' +import { TableWinners } from '../TableWinners' + +interface Props { + selectedTab: number + registrations: RegistrationInfo[] + submissions: SubmissionInfo[] + projectResults: ProjectResult[] +} + +export const ChallengeDetailsContent: FC = (props: Props) => { + const selectedTab = props.selectedTab + const registrations = props.registrations + const submissions = props.submissions + const projectResults = props.projectResults + return ( + <> + {selectedTab === 0 && registrations.length !== 0 && ( + + )} + {((selectedTab === 0 && registrations.length === 0) + || (selectedTab === 2 && submissions.length === 0) + || (selectedTab === 3 && projectResults.length === 0)) && ( + + )} + + {selectedTab === 1 && } + {selectedTab === 2 && submissions.length !== 0 && ( + + )} + {selectedTab === 3 && projectResults.length !== 0 && ( + + )} + + ) +} + +export default ChallengeDetailsContent diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/index.ts b/src/apps/review/src/lib/components/ChallengeDetailsContent/index.ts new file mode 100644 index 000000000..6f09c01fe --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/index.ts @@ -0,0 +1 @@ +export { default as ChallengeDetailsContent } from './ChallengeDetailsContent' diff --git a/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.module.scss b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.module.scss new file mode 100644 index 000000000..3fa1ccb7d --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.module.scss @@ -0,0 +1,15 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + gap: 20px; +} + +.blockLink { + text-decoration-line: underline; + font-size: 16px; + line-height: 22px; + letter-spacing: -0.02em; + + @include font-weight-semibold; +} diff --git a/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx new file mode 100644 index 000000000..15ec053d4 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeLinks/ChallengeLinks.tsx @@ -0,0 +1,20 @@ +/** + * Challenge Links. + */ +import { FC } from 'react' +import classNames from 'classnames' + +import styles from './ChallengeLinks.module.scss' + +interface Props { + className?: string +} + +export const ChallengeLinks: FC = (props: Props) => ( +
+ + +
+) + +export default ChallengeLinks diff --git a/src/apps/review/src/lib/components/ChallengeLinks/index.ts b/src/apps/review/src/lib/components/ChallengeLinks/index.ts new file mode 100644 index 000000000..05d4b8e7c --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengeLinks/index.ts @@ -0,0 +1 @@ +export { default as ChallengeLinks } from './ChallengeLinks' diff --git a/src/apps/review/src/lib/components/ChallengePhaseInfo/ChallengePhaseInfo.module.scss b/src/apps/review/src/lib/components/ChallengePhaseInfo/ChallengePhaseInfo.module.scss new file mode 100644 index 000000000..39265ee81 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengePhaseInfo/ChallengePhaseInfo.module.scss @@ -0,0 +1,31 @@ +@import "@libs/ui/styles/includes"; + +.container { + display: flex; + gap: 48px; + margin-top: 24px; + flex-wrap: wrap; + + @include ltelg { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 15px 30px; + } + + @include ltesm { + grid-template-columns: 1fr; + } +} + +.blockItem { + display: flex; + font-size: 14px; + line-height: 19px; + /* identical to box height, or 136% */ + letter-spacing: -0.02em; + gap: 5px; + + strong { + @include font-weight-semibold; + } +} diff --git a/src/apps/review/src/lib/components/ChallengePhaseInfo/ChallengePhaseInfo.tsx b/src/apps/review/src/lib/components/ChallengePhaseInfo/ChallengePhaseInfo.tsx new file mode 100644 index 000000000..2283ba44a --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengePhaseInfo/ChallengePhaseInfo.tsx @@ -0,0 +1,58 @@ +/** + * Challenge Phase Info. + */ +import { FC, useMemo } from 'react' +import classNames from 'classnames' + +import { ChallengeInfo } from '../../models' + +import styles from './ChallengePhaseInfo.module.scss' + +interface Props { + className?: string + challengeInfo: ChallengeInfo +} + +export const ChallengePhaseInfo: FC = (props: Props) => { + const uiItems = useMemo(() => { + const data = props.challengeInfo + return [ + { + title: 'Phase', + value: data.currentPhase, + }, + { + title: 'Phase End Date', + value: data.currentPhaseEndDateString, + }, + { + style: { + color: data.timeLeftColor, + }, + title: 'Time Left', + value: data.timeLeft, + }, + { + title: 'Review Progress', + value: data.reviewProgress + ? `${data.reviewProgress}% Completed` + : '-', + }, + ] + }, [props.challengeInfo]) + return ( +
+ {uiItems.map(item => ( +
+ + {item.title} + : + + {item.value} +
+ ))} +
+ ) +} + +export default ChallengePhaseInfo diff --git a/src/apps/review/src/lib/components/ChallengePhaseInfo/index.ts b/src/apps/review/src/lib/components/ChallengePhaseInfo/index.ts new file mode 100644 index 000000000..7e930cfc3 --- /dev/null +++ b/src/apps/review/src/lib/components/ChallengePhaseInfo/index.ts @@ -0,0 +1 @@ +export { default as ChallengePhaseInfo } from './ChallengePhaseInfo' diff --git a/src/apps/review/src/lib/components/ConfirmModal/ConfirmModal.module.scss b/src/apps/review/src/lib/components/ConfirmModal/ConfirmModal.module.scss new file mode 100644 index 000000000..3c295c38e --- /dev/null +++ b/src/apps/review/src/lib/components/ConfirmModal/ConfirmModal.module.scss @@ -0,0 +1,4 @@ +.bodyClassName { + margin: 0; + padding: 0; +} diff --git a/src/apps/review/src/lib/components/ConfirmModal/ConfirmModal.tsx b/src/apps/review/src/lib/components/ConfirmModal/ConfirmModal.tsx new file mode 100644 index 000000000..fbd73cebd --- /dev/null +++ b/src/apps/review/src/lib/components/ConfirmModal/ConfirmModal.tsx @@ -0,0 +1,22 @@ +/** + * Confirm Modal. + */ +import { FC } from 'react' + +import { ConfirmModalProps } from '~/libs/ui/lib/components/modals/confirm/ConfirmModal' +import { ConfirmModal as ConfirmModalOriginal } from '~/libs/ui' + +import styles from './ConfirmModal.module.scss' + +export const ConfirmModal: FC = ( + props: ConfirmModalProps, +) => ( + +) + +export default ConfirmModal diff --git a/src/apps/review/src/lib/components/ConfirmModal/index.ts b/src/apps/review/src/lib/components/ConfirmModal/index.ts new file mode 100644 index 000000000..b00ce52b7 --- /dev/null +++ b/src/apps/review/src/lib/components/ConfirmModal/index.ts @@ -0,0 +1 @@ +export { default as ConfirmModal } from './ConfirmModal' diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss new file mode 100644 index 000000000..5e5092611 --- /dev/null +++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.module.scss @@ -0,0 +1,161 @@ +@import '@libs/ui/styles/typography'; +@import '@libs/ui/styles/includes'; + +$error-line-height: 14px; + +.container { + display: flex; + flex-direction: column; + height: 366px; + gap: 9px; + + &.showBorder { + :global { + .CodeMirror.CodeMirror-wrap { + border-right: 1px solid var(--GrayBorder); + border-left: 1px solid var(--GrayBorder); + border-bottom: 1px solid var(--GrayBorder); + } + .editor-toolbar { + border-top: 1px solid var(--GrayBorder); + border-left: 1px solid var(--GrayBorder); + border-right: 1px solid var(--GrayBorder); + } + } + } + + &.isError { + :global { + .CodeMirror.CodeMirror-wrap { + border-right: 1px solid var(--RedError); + border-left: 1px solid var(--RedError); + border-bottom: 1px solid var(--RedError); + } + .editor-toolbar { + border-top: 1px solid var(--RedError); + border-left: 1px solid var(--RedError); + border-right: 1px solid var(--RedError); + } + } + } + + :global { + .EasyMDEContainer { + height: 100px; + flex: 1; + display: flex; + flex-direction: column; + } + + .CodeMirror.CodeMirror-wrap { + min-height: 0; + flex: 1; + box-sizing: border-box; + height: auto; + border-right: 1px solid white; + border-left: 1px solid white; + border-bottom: 1px solid white; + border-top: 1px solid var(--GrayBorder); + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + color: var(--Primary); + } + + .editor-toolbar { + opacity: 1; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + border-top: 1px solid white; + border-left: 1px solid white; + border-right: 1px solid white; + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0 8px; + background-color: white; + + &::after, + &::before { + content: none; + } + + button.table { + width: auto; + } + + button { + margin: 8px 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 9px 0; + + &::after { + content: none; + } + } + + i.separator { + border-left: 1px solid var(--GrayBorder); + } + } + + .editor-statusbar { + font-size: 14px; + line-height: 19px; + padding: 9px 0 0 0; + overflow: hidden; + display: flex; + + span { + min-width: 0; + } + + .upload-image { + margin-left: 0; + margin-right: auto; + display: flex; + color: var(--Secondary); + font-size: 14px; + line-height: 19px; + } + + .countOfRemainingChars { + margin-left: 0; + min-width: 0; + display: flex; + } + + .message { + display: none; + } + } + + .cm-s-easymde { + .cm-link, + .cm-url { + color: var(--Link); + } + } + } +} + +.error { + display: flex; + align-items: center; + color: $red-100; + // extend body ultra small and override it + font-size: 14px; + line-height: 19px; + line-height: $error-line-height; + margin-top: $sp-1; + + svg { + @include icon-md; + fill: $red-100; + margin-right: $sp-1; + } +} diff --git a/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx new file mode 100644 index 000000000..c1aa2a24e --- /dev/null +++ b/src/apps/review/src/lib/components/FieldMarkdownEditor/FieldMarkdownEditor.tsx @@ -0,0 +1,805 @@ +/** + * Field Markdown Editor. + */ +import { FC, useCallback, useEffect, useRef } from 'react' +import _ from 'lodash' +import CodeMirror from 'codemirror' +import EasyMDE from 'easymde' +import classNames from 'classnames' +import 'easymde/dist/easymde.min.css' + +import { useOnComponentDidMount } from '~/apps/admin/src/lib/hooks' +import { IconSolid } from '~/libs/ui' + +import { + IconBold, + IconCode, + IconHeading1, + IconHeading2, + IconHeading3, + IconImage, + IconItalic, + IconLink, + IconMentions, + IconOrderedList, + IconQuote, + IconStrikethrough, + IconTable, + IconUnorderedList, + IconUploadFile, +} from '../../assets/icons' +import { MockUploadUrl } from '../../../mock-datas' +import { humanFileSize } from '../../utils' + +import styles from './FieldMarkdownEditor.module.scss' + +interface Props { + className?: string + placeholder?: string + initialValue?: string + onChange?: (value: string) => void + onBlur?: () => void + error?: string + showBorder?: boolean +} +const errorMessages = { + fileTooLarge: + 'Uploading #image_name# was failed. The file is too big (#image_size#).\n' + + 'Maximum file size is #image_max_size#.', + importError: + 'Uploading #image_name# was failed. Something went wrong when uploading the file.', + noFileGiven: 'Select a file.', + typeNotAllowed: + 'Uploading #image_name# was failed. The file type (#image_type#) is not supported.', +} +const maxUploadSize = 40 * 1024 * 1024 +const imageExtensions = ['gif', 'png', 'jpeg', 'jpg', 'bmp', 'svg'] +const allowedImageExtensions = [ + ...imageExtensions, + ...imageExtensions.map(key => `image/${key}`), +] +const allowedOtherExtensions = [ + 'application/zip', + 'zip', + 'application/octet-stream', + 'application/x-zip-compressed', + 'multipart/x-zip', + 'text/plain', + 'txt', + 'mov', + 'video/mpeg', + 'mp4', + 'video/mp4', + 'webm', + 'video/webm', + 'doc', + 'docx', + 'pdf', + 'application/pdf', + 'csv', + 'text/csv', + 'htm', + 'html', + 'text/html', + 'js', + 'json', + 'application/json', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'xls', + 'xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt', + 'application/vnd.ms-powerpoint', + 'pptx', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +] + +export const FieldMarkdownEditor: FC = (props: Props) => { + const elementRef = useRef(null) + const easyMDE = useRef(null) + + /** + * The state of CodeMirror at the given position. + */ + const getState = useCallback( + // eslint-disable-next-line complexity + (cm: CodeMirror.Editor, posInput?: CodeMirror.Position | undefined) => { + const pos = posInput || cm.getCursor('start') + const stat = cm.getTokenAt(pos) + if (!stat.type) return {} + + const types = stat.type.split(' ') + + const ret: any = {} + + let data + let text + for (let i = 0; i < types.length; i++) { + data = types[i] + if (data === 'strong') { + ret.bold = true + } else if (data === 'variable-2') { + text = cm.getLine(pos.line) + if (/^\s*\d+\.\s/.test(text)) { + ret['ordered-list'] = true + } else { + ret['unordered-list'] = true + } + } else if (data === 'atom') { + ret.quote = true + } else if (data === 'em') { + ret.italic = true + } else if (data === 'quote') { + ret.quote = true + } else if (data === 'strikethrough') { + ret.strikethrough = true + } else if (data === 'comment') { + ret.code = true + } else if (data === 'link') { + ret.link = true + } else if (data === 'tag') { + ret.image = true + } else if (data.match(/^header(-[1-6])?$/)) { + ret[data.replace('header', 'heading')] = true + } + } + + return ret + }, + [], + ) + + /** + * Handle toggle block + */ + const toggleBlock = useCallback( + // eslint-disable-next-line complexity + (editor: any, type: string, startChars: any, endCharsInput?: any) => { + if ( + /editor-preview-active/.test( + editor.codemirror.getWrapperElement().lastChild.className, + ) + ) { + return + } + + const endChars = typeof endCharsInput === 'undefined' ? startChars : endCharsInput + const cm = editor.codemirror + const stat = getState(cm) + + let text = '' + let start = startChars + let end = endChars + + const startPoint = cm.getCursor('start') + const endPoint = cm.getCursor('end') + + if (stat[type]) { + text = cm.getLine(startPoint.line) + start = text.slice(0, startPoint.ch) + end = text.slice(startPoint.ch) + if (type === 'bold') { + start = start.replace(/(\*\*|__)(?![\s\S]*(\*\*|__))/, '') + end = end.replace(/(\*\*|__)/, '') + } else if (type === 'italic') { + start = start.replace(/(\*|_)(?![\s\S]*(\*|_))/, '') + end = end.replace(/(\*|_)/, '') + } else if (type === 'strikethrough') { + start = start.replace(/(\*\*|~~)(?![\s\S]*(\*\*|~~))/, '') + end = end.replace(/(\*\*|~~)/, '') + } + + cm.replaceRange( + start + end, + { + ch: 0, + line: startPoint.line, + }, + { + ch: 99999999999999, + line: startPoint.line, + }, + ) + + if (type === 'bold' || type === 'strikethrough') { + startPoint.ch -= 2 + if (startPoint !== endPoint) { + endPoint.ch -= 2 + } + } else if (type === 'italic') { + startPoint.ch -= 1 + if (startPoint !== endPoint) { + endPoint.ch -= 1 + } + } + } else { + text = cm.getSelection() + let trimText = text.trim() + let lastSpaces = '' + for (let i = trimText.length; i < text.length; i++) { + lastSpaces += text[i] + } + + if (type === 'bold') { + trimText = trimText.split('**') + .join('') + } else if (type === 'italic') { + trimText = trimText.split('*') + .join('') + } else if (type === 'strikethrough') { + trimText = trimText.split('~~') + .join('') + } + + cm.replaceSelection(start + trimText + end + lastSpaces) + + startPoint.ch += startChars.length + endPoint.ch = startPoint.ch + text.length + } + + cm.setSelection(startPoint, endPoint) + cm.focus() + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) + + /** + * Show hint after '@' + */ + const completeAfter = useCallback((cm: CodeMirror.Editor) => { + if (!cm.state.completionActive) { + if (cm.getCursor().ch === 0) { + cm.replaceSelection('@') + } else { + const from = { + ch: 0, + line: cm.getCursor().line, + } + const to = cm.getCursor() + const line = cm.getRange(from, to) + const lastIndexOf = line.lastIndexOf(' ') + const tokenIndex = lastIndexOf > -1 ? lastIndexOf + 1 : 0 + cm.replaceRange('@', { + ch: tokenIndex, + line: cm.getCursor().line, + }) + } + } + + return CodeMirror.Pass + }, []) + + /** + * Update file tag + */ + const updateFileTag = useCallback( + (cm: CodeMirror.Editor, position: any, startEnd: any, data: any) => { + if ( + /editor-preview-active/.test( + (cm.getWrapperElement()?.lastChild as any)?.className, + ) + ) { + return + } + + let start = startEnd[0] + let end = startEnd[1] + const startPoint: any = {} + const endPoint: any = {} + if (data && (data.url || data.name)) { + start = start.replace('#name#', data.name) // url is in start for upload-image + start = start.replace('#url#', data.url) // url is in start for upload-image + end = end.replace('#name#', data.name) + end = end.replace('#url#', data.url) + } + + Object.assign(startPoint, { + ch: position.start.ch, + line: position.start.line, + }) + Object.assign(endPoint, { + ch: position.end.ch, + line: position.end.line, + }) + cm.replaceRange(start + end, startPoint, endPoint) + + const selectionPosition = { + ch: start.length + end.length, + line: position.start.line, + } + cm.setSelection(selectionPosition, selectionPosition) + cm.focus() + }, + [], + ) + + /** + * After file uploaded + */ + const afterFileUploaded = useCallback((jsonData: any, position: any) => { + const editor = easyMDE.current + const cm = editor.codemirror + const options = editor.options + const imageName = jsonData.name + const ext = imageName.substring(imageName.lastIndexOf('.') + 1) + + // Check if file type is an image + if (allowedImageExtensions.includes(ext)) { + updateFileTag( + cm, + position, + options.insertTexts.uploadedImage, + jsonData, + ) + } else { + updateFileTag( + cm, + position, + options.insertTexts.uploadedFile, + jsonData, + ) + } + + // show uploaded image filename for 1000ms + editor.updateStatusBar( + 'upload-image', + editor.options.imageTexts.sbOnUploaded.replace( + '#image_name#', + imageName, + ), + ) + setTimeout(() => { + editor.updateStatusBar( + 'upload-image', + editor.options.imageTexts.sbInit, + ) + }, 1000) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + /** + * Reset file input + */ + const resetFileInput = useCallback(() => { + const imageInput + = easyMDE.current.gui.toolbar.getElementsByClassName('imageInput')[0] + imageInput.value = '' + }, []) + + /** + * Replace selection + */ + const replaceSelection = useCallback( + ( + cm: CodeMirror.Editor, + active: boolean, + startEnd: string[], + data: any, + onPosition: any, + ) => { + if ( + /editor-preview-active/.test( + (cm.getWrapperElement()?.lastChild as any)?.className, + ) + ) { + return + } + + let text + let start = startEnd[0] + let end = startEnd[1] + const startPoint: any = {} + const endPoint: any = {} + const currentPosition = cm.getCursor() + + // Start uploading from a new line + if (currentPosition.ch !== 0) { + cm.replaceSelection('\n') + } + + Object.assign(startPoint, cm.getCursor('start')) + Object.assign(endPoint, cm.getCursor('end')) + if (data && data.name) { + start = start.replace('#name#', data.name) + end = end.replace('#name#', data.name) + } + + const initStartPosition = { + ch: startPoint.ch, + line: startPoint.line, + } + + if (active) { + text = cm.getLine(startPoint.line) + start = text.slice(0, startPoint.ch) + end = text.slice(startPoint.ch) + cm.replaceRange(start + end, { + ch: 0, + line: startPoint.line, + }) + } else { + text = cm.getSelection() + cm.replaceSelection(start + text + end) + startPoint.ch += start.length + if (startPoint !== endPoint) { + endPoint.ch += start.length + } + } + + onPosition(initStartPosition, endPoint) + + const line = cm.getLine(cm.getCursor().line) + const appendedTextLength = start.length + text.length + end.length + if (line.length > appendedTextLength) { + cm.replaceSelection('\n') + cm.setSelection( + { + ch: line.length - appendedTextLength, + line: startPoint.line + 1, + }, + { + ch: line.length - appendedTextLength, + line: startPoint.line + 1, + }, + ) + } else { + // Set a cursor at the end of line + cm.setSelection(startPoint, endPoint) + } + + cm.focus() + }, + [], + ) + + /** + * Before uploading file + */ + const beforeUploadingFile = useCallback((file: File, onPosition: any) => { + const editor = easyMDE.current + const cm = editor.codemirror + const stat = getState(cm) + const options = editor.options + const fileName = file.name + const ext = fileName.substring(fileName.lastIndexOf('.') + 1) + // Check if file type is an image + if (allowedImageExtensions.includes(ext)) { + replaceSelection( + cm, + stat.image, + options.insertTexts.uploadingImage, + { name: fileName }, + onPosition, + ) + } else { + replaceSelection( + cm, + stat.link, + options.insertTexts.uploadingFile, + { name: fileName }, + onPosition, + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + /** + * Upload image + */ + const customUploadImage = useCallback((file: File) => { + const position: any = {} + + const onSuccess: (jsonData: any) => void = (jsonData: any) => { + afterFileUploaded(jsonData, position) + resetFileInput() + } + + const onError: () => void = () => { + if (position && position.start && position.end) { + easyMDE.current.codemirror.replaceRange( + '', + position.start, + position.end, + ) + } + + resetFileInput() + } + + const onErrorSup: (errorMessage: string) => void = (errorMessage: string) => { + // show reset status bar + easyMDE.current.updateStatusBar( + 'upload-image', + easyMDE.current.options.imageTexts.sbInit, + ) + // run custom error handler + if (onError && typeof onError === 'function') { + onError() + } + + // run error handler from options + easyMDE.current.options.errorCallback(errorMessage) + } + + // Sometimes a browser couldn't define mime/types, use file extension + const getFileType: () => string = () => (file.type + ? file.type + : file.name.substring(file.name.lastIndexOf('.') + 1)) + + // Parse a message + const fillErrorMessage: (errorMessage: string) => string = (errorMessage: string) => { + const units + = easyMDE.current.options.imageTexts.sizeUnits.split(',') + + const error = errorMessage + .replace('#image_type#', getFileType()) + .replace('#image_name#', file.name) + .replace('#image_size#', humanFileSize(file.size, units)) + .replace( + '#image_max_size#', + humanFileSize(easyMDE.current.options.imageMaxSize, units), + ) + + return ( + `
  • ${error}
` + ) + } + + // Save a position of image/file tag + const onPosition: (start: any, end: any) => void = (start: any, end: any) => { + position.start = start + position.end = end + } + + // Check mime types + if (!easyMDE.current.options.imageAccept.includes(getFileType())) { + onErrorSup( + fillErrorMessage( + easyMDE.current.options.errorMessages.typeNotAllowed, + ), + ) + return + } + + // Check max file size before uploading + if (file.size > easyMDE.current.options.imageMaxSize) { + onErrorSup( + fillErrorMessage( + easyMDE.current.options.errorMessages.fileTooLarge, + ), + ) + return + } + + beforeUploadingFile(file, onPosition) + + onSuccess({ + name: file.name, + url: MockUploadUrl, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useOnComponentDidMount(() => { + easyMDE.current = new EasyMDE({ + autofocus: false, + element: elementRef.current as HTMLElement, + errorCallback: _.noop, // A callback function used to define how to display an error message. + errorMessages, + forceSync: true, // true, force text changes made in EasyMDE to be immediately stored in original text area. + hideIcons: ['guide', 'heading', 'preview', 'side-by-side'], + imageAccept: [ + ...allowedImageExtensions, + ...allowedOtherExtensions, + ].join(', '), // A comma-separated list of mime-types and extensions + imageMaxSize: maxUploadSize, // Maximum image size in bytes + imageTexts: { + sbInit: 'Attach files by dragging & dropping, selecting or pasting them.', + sbOnDragEnter: 'Drop file to upload it.', + sbOnDrop: 'Uploading file #images_names#...', + sbOnUploaded: 'Uploaded #image_name#', + sbProgress: 'Uploading #file_name#: #progress#%', + sizeUnits: ' B, KB, MB', + }, + imageUploadFunction: file => { + setTimeout(() => { + customUploadImage(file) + }) + }, + initialValue: props.initialValue, + insertTexts: { + file: ['[](', '#url#)'], + horizontalRule: ['', '\n\n-----\n\n'], + image: ['![](', '#url#)'], + link: ['[', '](#url#)'], + table: [ + '', + // eslint-disable-next-line max-len + '\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Text | Text | Text |\n\n', + ], + uploadedFile: ['[#name#](#url#)', ''], + uploadedImage: ['![#name#](#url#)', ''], + uploadingFile: ['[Uploading #name#]()', ''], + uploadingImage: ['![Uploading #name#]()', ''], + } as any, + placeholder: '', + shortcuts: { + toggleHeading1: '', + toggleHeading2: '', + toggleHeading3: '', + }, + status: [ + { + className: 'message', + defaultValue: el => { + el.innerHTML = '' + }, + onUpdate: el => { + el.innerHTML = '' + }, + }, + 'upload-image', + ], + toolbar: [ + { + action: (editor: any) => { + toggleBlock( + editor, + 'bold', + editor.options.blockStyles.bold, + ) + }, + className: 'fa fa-bold', + icon: IconBold, + name: 'toggleBold', + title: 'Bold', + }, + { + action: (editor: any) => { + toggleBlock( + editor, + 'italic', + editor.options.blockStyles.italic, + ) + }, + className: 'fa fa-italic', + icon: IconItalic, + name: 'toggleItalic', + title: 'Italic', + }, + { + action: EasyMDE.toggleStrikethrough, + className: 'fa fa-bold', + icon: IconStrikethrough, + name: 'strikethrough', + title: 'Strikethrough', + }, + '|', + { + action: EasyMDE.toggleHeading1, + className: 'fa fa-bold', + icon: IconHeading1, + name: 'heading-1', + title: 'Big Heading', + }, + { + action: EasyMDE.toggleHeading2, + className: 'fa fa-bold', + icon: IconHeading2, + name: 'heading-2', + title: 'Medium Heading', + }, + { + action: EasyMDE.toggleHeading3, + className: 'fa fa-bold', + icon: IconHeading3, + name: 'heading-3', + title: 'Small Heading', + }, + '|', + { + action: EasyMDE.toggleOrderedList, + className: 'fa fa-bold', + icon: IconOrderedList, + name: 'ordered-list', + title: 'Numbered List', + }, + { + action: EasyMDE.toggleUnorderedList, + className: 'fa fa-bold', + icon: IconUnorderedList, + name: 'unordered-list', + title: 'Generic List', + }, + '|', + { + action: EasyMDE.drawLink, + className: 'fa fa-bold', + icon: IconLink, + name: 'link', + title: 'Create Link', + }, + { + action: EasyMDE.drawUploadedImage, + className: 'fa fa-upload', + icon: IconUploadFile, + name: 'upload-image', + title: 'Upload a file', + }, + { + action: EasyMDE.drawImage, + className: 'fa fa-bold', + icon: IconImage, + name: 'image', + title: 'Insert Image', + }, + { + action: EasyMDE.toggleCodeBlock, + className: 'fa fa-bold', + icon: IconCode, + name: 'code', + title: 'Code', + }, + { + action: EasyMDE.drawTable, + className: 'fa fa-bold', + icon: IconTable, + name: 'table', + title: 'Insert Table', + }, + { + action: function mentions(editor: EasyMDE) { + completeAfter(editor.codemirror) + }, + className: 'fa fa-at', + icon: IconMentions, + name: 'mentions', + title: 'Mention a Topcoder User', + }, + { + action: EasyMDE.toggleBlockquote, + className: 'fa fa-bold', + icon: IconQuote, + name: 'quote', + title: 'Quote', + }, + ], + uploadImage: true, + }) + + easyMDE.current.codemirror.on('change', (cm: CodeMirror.Editor) => { + props.onChange?.(cm.getValue()) + }) + + easyMDE.current.codemirror.on('blur', () => { + props.onBlur?.() + }) + }) + + useEffect(() => { + easyMDE.current?.value(props.initialValue) + }, [props.initialValue]) + + return ( +
+