From f4f72fad402d638aec136010ec0316c06ecb39b0 Mon Sep 17 00:00:00 2001 From: Johnson Mao <86179381+JohnsonMao@users.noreply.github.com> Date: Fri, 15 Nov 2024 23:10:33 +0800 Subject: [PATCH 1/5] fix: about page image style --- components/About/Cooperate/index.jsx | 79 ++++++---------------------- components/About/TechStack/index.jsx | 18 +++---- 2 files changed, 26 insertions(+), 71 deletions(-) diff --git a/components/About/Cooperate/index.jsx b/components/About/Cooperate/index.jsx index 6851049d..b0c626b7 100644 --- a/components/About/Cooperate/index.jsx +++ b/components/About/Cooperate/index.jsx @@ -22,24 +22,8 @@ const Cooperate = () => { > 合作夥伴 - - +
+
{ > g0v零時政府 - - +
+
{ > g0v零時小學校 - - +
+
{ > 雜學校 - - +
+
{ > 親子天下 - - +
+
{ > Coding bar - - +
+
); }; diff --git a/components/About/TechStack/index.jsx b/components/About/TechStack/index.jsx index b795e7f4..4657feca 100644 --- a/components/About/TechStack/index.jsx +++ b/components/About/TechStack/index.jsx @@ -28,39 +28,39 @@ const Thanks = () => { }} > -

+

- + {/* */}

From 4b0f5462802d6a157fbbca742c32129004d3e553 Mon Sep 17 00:00:00 2001 From: Johnson Mao <86179381+JohnsonMao@users.noreply.github.com> Date: Fri, 15 Nov 2024 23:11:39 +0800 Subject: [PATCH 2/5] feat(shared): add markdown editor and typography --- next.config.js | 5 + package.json | 1 + .../components/MarkdownEditor/CheckLink.jsx | 177 ++++++++++++++++++ .../components/MarkdownEditor/ImageDialog.jsx | 127 +++++++++++++ .../MarkdownEditor/MarkdownEditor.jsx | 111 +++++++++++ shared/components/MarkdownEditor/index.js | 1 + .../MarkdownEditor/locales/zh-tw.js | 106 +++++++++++ shared/styles/global.css | 6 + tailwind.config.js | 4 +- yarn.lock | 28 +++ 10 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 shared/components/MarkdownEditor/CheckLink.jsx create mode 100644 shared/components/MarkdownEditor/ImageDialog.jsx create mode 100644 shared/components/MarkdownEditor/MarkdownEditor.jsx create mode 100644 shared/components/MarkdownEditor/index.js create mode 100644 shared/components/MarkdownEditor/locales/zh-tw.js diff --git a/next.config.js b/next.config.js index 17bcb1d3..67854e54 100644 --- a/next.config.js +++ b/next.config.js @@ -7,9 +7,14 @@ const withPWA = require('next-pwa')({ module.exports = withPWA({ reactStrictMode: false, staticPageGenerationTimeout: 600, + transpilePackages: ['@mdxeditor/editor'], images: { domains: ['imgur.com', 'images.unsplash.com', 'lh3.googleusercontent.com'], }, + webpack: (config) => { + const experiments = { ...config.experiments, topLevelAwait: true }; + return Object.assign(config, { experiments }); + }, env: { HOSTNAME: 'https://www.daoedu.tw', }, diff --git a/package.json b/package.json index d7d95880..463aa404 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "devDependencies": { "@emotion/babel-plugin": "^11.9.2", "@next/eslint-plugin-next": "^13.2.1", + "@tailwindcss/typography": "^0.5.15", "@types/chrome": "^0.0.206", "autoprefixer": "^10.4.20", "babel-plugin-import": "^1.13.8", diff --git a/shared/components/MarkdownEditor/CheckLink.jsx b/shared/components/MarkdownEditor/CheckLink.jsx new file mode 100644 index 00000000..ba781565 --- /dev/null +++ b/shared/components/MarkdownEditor/CheckLink.jsx @@ -0,0 +1,177 @@ +import { useState, forwardRef, useId, useImperativeHandle } from 'react'; +import Link from 'next/link'; +import { + Dialog, + DialogTitle, + Box, + Button, + Slide, + Typography, + useMediaQuery, + FormControlLabel, + Checkbox, +} from '@mui/material'; +import { getTrustWebsitesStorage } from '@/utils/storage'; + +const TransitionSlide = forwardRef((props, ref) => { + return ; +}); + +function InternalCheckLink(props, ref) { + const id = useId(); + const isMobileScreen = useMediaQuery('(max-width: 560px)'); + const [link, setLink] = useState(null); + const [isTrust, setIsTrust] = useState(false); + const titleId = `modal-title-${id}`; + const descriptionId = `modal-description-${id}`; + + const handleClose = () => { + setLink(null); + setIsTrust(false); + }; + + const handleGoToWebsite = () => { + const trustWebsites = getTrustWebsitesStorage().get(); + const data = Array.isArray(trustWebsites) ? trustWebsites : []; + + if (isTrust && link) { + data.push(link.hostname); + } + + getTrustWebsitesStorage().set(data); + handleClose(); + }; + + useImperativeHandle( + ref, + () => ({ + check: (href) => { + try { + const trustWebsites = getTrustWebsitesStorage().get(); + const data = Array.isArray(trustWebsites) ? trustWebsites : []; + const newLink = new URL(href); + + if (data.includes(newLink.hostname)) { + window.open(newLink.href, '_blank'); + return; + } + setLink(newLink); + } catch { + window.open(href, '_blank'); + } + } + }), + [] + ); + + return ( + + + 正在離開島島阿學 + + {link && ( + <> +
+ 這個連結將帶您前往以下網站 + + {decodeURI(link.href)} + +
+
+ setIsTrust((pre) => !pre)} />} + label={ + {`從現在開始信任 ${link.hostname} 連結`} + } + checked={isTrust} + /> +
+ + + + + + )} +
+ ); +} + +const CheckLink = forwardRef(InternalCheckLink); + +export default CheckLink; diff --git a/shared/components/MarkdownEditor/ImageDialog.jsx b/shared/components/MarkdownEditor/ImageDialog.jsx new file mode 100644 index 00000000..b2b4dc3b --- /dev/null +++ b/shared/components/MarkdownEditor/ImageDialog.jsx @@ -0,0 +1,127 @@ +import { Dialog, DialogTitle, DialogContent } from '@mui/material'; +import { + closeImageDialog$, + imageDialogState$, + saveImage$, + useCellValues, + usePublisher, + useTranslation, +} from '@mdxeditor/editor'; +import { useState } from 'react'; + +export const ImageDialog = () => { + const [state] = useCellValues(imageDialogState$); + const saveImage = usePublisher(saveImage$); + const closeImageDialog = usePublisher(closeImageDialog$); + const t = useTranslation(); + const [error, setError] = useState(''); + const [imageData, setImageData] = useState({ + src: '', + title: '', + altText: '', + file: [] + }); + + const handleChange = (key) => (e) => { + const { value } = e.target; + if (key === 'src') { + if (value.startsWith('https://')) { + setError(''); + } else { + setError('僅支援 https 開頭的 URL'); + } + } + setImageData((pre) => ({ ...pre, [key]: value })); + }; + + const reset = () => { + closeImageDialog(); + setError(''); + setImageData({ src: '', title: '', altText: '', file: [] }); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (error) return; + saveImage(imageData); + reset(); + }; + + return ( + + {t('uploadImage.dialogTitle', 'Upload an image')} + +
+
+ + + {error &&

{error}

} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/shared/components/MarkdownEditor/MarkdownEditor.jsx b/shared/components/MarkdownEditor/MarkdownEditor.jsx new file mode 100644 index 00000000..454d7842 --- /dev/null +++ b/shared/components/MarkdownEditor/MarkdownEditor.jsx @@ -0,0 +1,111 @@ +import { useEffect, useId, useRef } from 'react'; +import { + BlockTypeSelect, + BoldItalicUnderlineToggles, + CodeToggle, + CreateLink, + DiffSourceToggleWrapper, + InsertThematicBreak, + ListsToggle, + UndoRedo, + MDXEditor, + codeBlockPlugin, + diffSourcePlugin, + headingsPlugin, + linkDialogPlugin, + linkPlugin, + listsPlugin, + markdownShortcutPlugin, + quotePlugin, + thematicBreakPlugin, + toolbarPlugin, + Separator, + InsertImage, + imagePlugin, +} from '@mdxeditor/editor'; +import '@mdxeditor/editor/style.css'; +import zhTW from './locales/zh-tw'; +import { ImageDialog } from './ImageDialog'; +import CheckLink from './CheckLink'; + +const toolbarContents = () => ( + + + + + + + + + + + + + + + +); + +const generatePlugins = (diffMarkdown = '') => [ + codeBlockPlugin(), + diffSourcePlugin({ viewMode: 'rich-text', diffMarkdown }), + headingsPlugin({ allowedHeadingLevels: [1, 2, 3] }), + imagePlugin({ ImageDialog }), + linkDialogPlugin(), + linkPlugin(), + listsPlugin(), + markdownShortcutPlugin(), + quotePlugin(), + thematicBreakPlugin() +]; + +const generatePluginWithToolbar = (diffMarkdown = '') => [ + ...generatePlugins(diffMarkdown), + toolbarPlugin({ toolbarContents }) +]; + +export default function MarkdownEditor({ readOnly = false, value = '123123', onChange }) { + const id = useId(); + const checkLinkRef = useRef(null); + + useEffect(() => { + const editor = document.getElementById(id).querySelector('.prose'); + + const handleClick = (e) => { + let { target } = e; + e.preventDefault(); + while (target.tagName !== 'A') { + if (editor === target) break; + target = target.parentElement; + } + if (target.tagName === 'A') { + checkLinkRef.current?.check(target.href); + } + }; + + editor.addEventListener('click', handleClick); + return () => editor.removeEventListener('click', handleClick); + }, []); + + return ( +
+ { + const keys = keyString.split('.'); + const text = keys.reduce((acc, key) => acc?.[key], zhTW); + return typeof text === 'string' + ? text.replace(/{{([^{}]+)}}/g, (_, p1) => interpolations?.[p1] || '') + : defaultValue; + }} + /> + +
+ ); +} diff --git a/shared/components/MarkdownEditor/index.js b/shared/components/MarkdownEditor/index.js new file mode 100644 index 00000000..71a4d6cd --- /dev/null +++ b/shared/components/MarkdownEditor/index.js @@ -0,0 +1 @@ +export { default } from './MarkdownEditor'; diff --git a/shared/components/MarkdownEditor/locales/zh-tw.js b/shared/components/MarkdownEditor/locales/zh-tw.js new file mode 100644 index 00000000..3ddb2296 --- /dev/null +++ b/shared/components/MarkdownEditor/locales/zh-tw.js @@ -0,0 +1,106 @@ +const zhTW = { + dialogControls: { + save: "儲存", + cancel: "取消" + }, + uploadImage: { + dialogTitle: "上傳圖片", + uploadInstructions: "從您的裝置上傳圖片:", + addViaUrlInstructions: "或從網址新增圖片:", + addViaUrlInstructionsNoUpload: "從網址新增圖片:", + autoCompletePlaceholder: "選擇或貼上圖片網址", + alt: "替代文字:", + title: "標題:" + }, + image: { + delete: "刪除圖片" + }, + imageEditor: { + editImage: "編輯圖片" + }, + createLink: { + url: "網址", + urlPlaceholder: "選擇或貼上網址", + title: "標題", + saveTooltip: "設定網址", + cancelTooltip: "取消修改" + }, + linkPreview: { + open: "在新視窗中開啟 {{url}}", + edit: "編輯連結網址", + copyToClipboard: "複製到剪貼簿", + copied: "已複製!", + remove: "移除連結" + }, + table: { + deleteTable: "刪除表格", + columnMenu: "欄選單", + textAlignment: "文字對齊", + alignLeft: "靠左對齊", + alignCenter: "置中對齊", + alignRight: "靠右對齊", + insertColumnLeft: "在此左側插入一欄", + insertColumnRight: "在此右側插入一欄", + deleteColumn: "刪除此欄", + rowMenu: "列選單", + insertRowAbove: "在上方插入一列", + insertRowBelow: "在下方插入一列", + deleteRow: "刪除此列" + }, + toolbar: { + blockTypes: { + paragraph: "段落", + quote: "引用", + heading: "標題 {{level}}" + }, + blockTypeSelect: { + selectBlockTypeTooltip: "選擇塊類型", + placeholder: "塊類型" + }, + toggleGroup: "切換群組", + removeBold: "移除粗體", + bold: "粗體", + removeItalic: "移除斜體", + italic: "斜體", + underline: "移除底線", + removeUnderline: "底線", + removeInlineCode: "移除程式碼格式", + inlineCode: "內聯程式碼格式", + link: "建立連結", + richText: "富文字", + diffMode: "差異模式", + source: "原始碼模式", + admonition: "插入註解區塊", + codeBlock: "插入程式碼區塊", + editFrontmatter: "編輯中繼資料", + insertFrontmatter: "插入中繼資料", + image: "插入圖片", + insertSandpack: "插入 Sandpack", + table: "插入表格", + thematicBreak: "插入主題斷行", + bulletedList: "項目清單", + numberedList: "編號清單", + checkList: "核取清單", + deleteSandpack: "刪除 Sandpack", + undo: "復原 {{shortcut}}", + redo: "重做 {{shortcut}}" + }, + admonitions: { + note: "注意", + tip: "提示", + danger: "危險", + info: "資訊", + caution: "警告", + changeType: "選擇註解區塊類型", + placeholder: "註解區塊類型" + }, + codeBlock: { + language: "程式碼區塊語言", + selectLanguage: "選擇程式碼區塊語言" + }, + contentArea: { + editableMarkdown: "可編輯的 Markdown" + } +}; + +export default zhTW; diff --git a/shared/styles/global.css b/shared/styles/global.css index b5c61c95..e0f476e8 100644 --- a/shared/styles/global.css +++ b/shared/styles/global.css @@ -1,3 +1,9 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer base { + .prose li[role=checkbox] { + @apply indent-1 relative before:translate-y-1 after:absolute after:top-1/2 after:-translate-y-2/3 after:rotate-45; + } +} \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index 14721749..d06950dd 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,4 +1,5 @@ const plugin = require("tailwindcss/plugin"); +const typography = require('@tailwindcss/typography'); /** @type {import('tailwindcss').Config} */ module.exports = { @@ -27,7 +28,7 @@ module.exports = { 500: "#293A3D", black: "#011416", }, - alert: "#B7DFDE", + alert: "#EF5364", tips: "#FF9526", success: "#86C84A", }, @@ -35,6 +36,7 @@ module.exports = { }, plugins: [ /** Typography */ + typography, plugin(({ addComponents, theme }) => { const sizes = ["lg", "md", "sm"]; const headingFontSizes = [ diff --git a/yarn.lock b/yarn.lock index df4aa1ad..feaec9db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2797,6 +2797,16 @@ dependencies: tslib "^2.4.0" +"@tailwindcss/typography@^0.5.15": + version "0.5.15" + resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.15.tgz#007ab9870c86082a1c76e5b3feda9392c7c8d648" + integrity sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA== + dependencies: + lodash.castarray "^4.4.0" + lodash.isplainobject "^4.0.6" + lodash.merge "^4.6.2" + postcss-selector-parser "6.0.10" + "@types/acorn@^4.0.0": version "4.0.6" resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-4.0.6.tgz#d61ca5480300ac41a7d973dd5b84d0a591154a22" @@ -5587,11 +5597,21 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.castarray@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115" + integrity sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q== + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" @@ -6655,6 +6675,14 @@ postcss-nested@^6.0.1: dependencies: postcss-selector-parser "^6.1.1" +postcss-selector-parser@6.0.10: + version "6.0.10" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" + integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.1.1: version "6.1.2" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de" From 94f051f377afc357f48082cd4448573cc1b32d22 Mon Sep 17 00:00:00 2001 From: Johnson Mao <86179381+JohnsonMao@users.noreply.github.com> Date: Fri, 15 Nov 2024 23:23:44 +0800 Subject: [PATCH 3/5] refactor: extract shared logic for link checking --- .../{MarkdownEditor => }/CheckLink.jsx | 0 .../MarkdownEditor/MarkdownEditor.jsx | 2 +- shared/components/TextWithLinks.jsx | 157 +----------------- 3 files changed, 6 insertions(+), 153 deletions(-) rename shared/components/{MarkdownEditor => }/CheckLink.jsx (100%) diff --git a/shared/components/MarkdownEditor/CheckLink.jsx b/shared/components/CheckLink.jsx similarity index 100% rename from shared/components/MarkdownEditor/CheckLink.jsx rename to shared/components/CheckLink.jsx diff --git a/shared/components/MarkdownEditor/MarkdownEditor.jsx b/shared/components/MarkdownEditor/MarkdownEditor.jsx index 454d7842..62faf19f 100644 --- a/shared/components/MarkdownEditor/MarkdownEditor.jsx +++ b/shared/components/MarkdownEditor/MarkdownEditor.jsx @@ -26,7 +26,7 @@ import { import '@mdxeditor/editor/style.css'; import zhTW from './locales/zh-tw'; import { ImageDialog } from './ImageDialog'; -import CheckLink from './CheckLink'; +import CheckLink from '../CheckLink'; const toolbarContents = () => ( diff --git a/shared/components/TextWithLinks.jsx b/shared/components/TextWithLinks.jsx index 3b6b7c40..4c2cca27 100644 --- a/shared/components/TextWithLinks.jsx +++ b/shared/components/TextWithLinks.jsx @@ -1,22 +1,7 @@ -import { useState, forwardRef, useId } from 'react'; +import { useRef } from 'react'; import Link from 'next/link'; import styled from '@emotion/styled'; -import { - Dialog, - DialogTitle, - Box, - Button, - Slide, - Typography, - useMediaQuery, - FormControlLabel, - Checkbox, -} from '@mui/material'; -import { getTrustWebsitesStorage } from '@/utils/storage'; - -const TransitionSlide = forwardRef((props, ref) => { - return ; -}); +import CheckLink from './CheckLink'; const StyledText = styled.p` a { @@ -25,34 +10,10 @@ const StyledText = styled.p` `; export default function TextWithLinks({ children }) { - const id = useId(); - const isMobileScreen = useMediaQuery('(max-width: 560px)'); - const [externalLink, setExternalLink] = useState(null); - const [isTrust, setIsTrust] = useState(false); - const titleId = `modal-title-${id}`; - const descriptionId = `modal-description-${id}`; + const checkLinkRef = useRef(null); const urlRegex = /(https:\/\/[^\s]+)/g; const text = typeof children === 'string' ? children : ''; - const checkbox = ( - setIsTrust((pre) => !pre)} /> - ); - - const handleClose = () => { - setExternalLink(null); - setIsTrust(false); - }; - - const handleGoToWebsite = () => { - const trustWebsites = getTrustWebsitesStorage().get(); - const data = Array.isArray(trustWebsites) ? trustWebsites : []; - - if (isTrust && externalLink) data.push(externalLink.hostname); - - getTrustWebsitesStorage().set(data); - handleClose(); - }; - const parts = text.split(urlRegex).map((part) => { if (!urlRegex.test(part)) return part; @@ -68,9 +29,6 @@ export default function TextWithLinks({ children }) { ); } - const trustWebsites = getTrustWebsitesStorage().get(); - const data = Array.isArray(trustWebsites) ? trustWebsites : []; - return ( { - if (data.includes(link.hostname)) return; e.preventDefault(); - setExternalLink(link); + checkLinkRef.current?.check(href); }} > {href} @@ -94,111 +51,7 @@ export default function TextWithLinks({ children }) { return ( {parts} - - - - 正在離開島島阿學 - - {externalLink && ( - <> -
- 這個連結將帶您前往以下網站 - - {decodeURI(externalLink.href)} - -
-
- {`從現在開始信任 ${externalLink.hostname} 連結`} - } - checked={isTrust} - /> -
- - - - - - )} -
+
); } From 27149f770f90d69bafad3b00fc77e7ed6e9e7678 Mon Sep 17 00:00:00 2001 From: Johnson Mao <86179381+JohnsonMao@users.noreply.github.com> Date: Sat, 16 Nov 2024 20:52:56 +0800 Subject: [PATCH 4/5] feat(group): filter out invalid data --- components/Group/Form/Fields/AreaCheckbox.jsx | 1 + components/Group/Form/index.jsx | 8 +- components/Group/Form/useGroupForm.jsx | 105 +++++++++++------- contexts/Snackbar.jsx | 6 +- 4 files changed, 76 insertions(+), 44 deletions(-) diff --git a/components/Group/Form/Fields/AreaCheckbox.jsx b/components/Group/Form/Fields/AreaCheckbox.jsx index bee8f07e..f82f1b4e 100644 --- a/components/Group/Form/Fields/AreaCheckbox.jsx +++ b/components/Group/Form/Fields/AreaCheckbox.jsx @@ -71,6 +71,7 @@ export default function AreaCheckbox({ control={physicalAreaControl} />
+ {isPhysicalArea && !physicalAreaValue && 請選擇地點}
handleCheckboxChange('線上')} />} diff --git a/components/Group/Form/index.jsx b/components/Group/Form/index.jsx index 72c4a0a9..7f5ee3db 100644 --- a/components/Group/Form/index.jsx +++ b/components/Group/Form/index.jsx @@ -99,13 +99,13 @@ export default function GroupForm({ label="揪團類型" name="activityCategory" handleValues={(action, value, activityCategory) => { - if (action === 'add' && value === 'Other') { - return ['Other']; + if (action === 'add' && value === '其他') { + return ['其他']; } if (action === 'remove' && !activityCategory.length) { - return ['Other']; + return ['其他']; } - return activityCategory.filter((item) => item !== 'Other'); + return activityCategory.filter((item) => item !== '其他'); }} control={control} value={values.activityCategory} diff --git a/components/Group/Form/useGroupForm.jsx b/components/Group/Form/useGroupForm.jsx index 115c1cee..8008d0da 100644 --- a/components/Group/Form/useGroupForm.jsx +++ b/components/Group/Form/useGroupForm.jsx @@ -2,6 +2,7 @@ import dayjs from 'dayjs'; import { useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { ZodType, z } from 'zod'; +import { useSnackbar } from '@/contexts/Snackbar'; import { CATEGORIES } from '@/constants/category'; import { AREAS } from '@/constants/areas'; import { EDUCATION_STEP } from '@/constants/member'; @@ -57,7 +58,9 @@ const rules = { participator: z .string() .regex(/^(100|[1-9]?\d)$/, '請輸入整數,需大於 0,不可超過 100'), - area: z.array(z.string()).min(1, '請選擇地點'), + area: z + .array(z.enum(AREAS.concat({ label: '待討論' }).map(({ label }) => label))) + .min(1, '請選擇地點'), time: z.string().max(50, '請勿輸入超過 50 字'), partnerStyle: z .string() @@ -83,12 +86,19 @@ export default function useGroupForm(defaultValue) { const [isDirty, setIsDirty] = useState(false); const me = useSelector((state) => state.user); const notLogin = !me?._id; - const [values, setValues] = useState({ + const [values, setValues] = useState(() => ({ ...INITIAL_VALUES, ...defaultValue, + ...Object.fromEntries( + Object.entries(rules).map(([key, rule]) => [ + key, + rule.safeParse(defaultValue[key])?.data || INITIAL_VALUES[key], + ]) + ), userId: me?._id, - }); + })); const [errors, setErrors] = useState({}); + const { pushSnackbar } = useSnackbar(); const refs = useRef({}); const schema = z.object(rules); @@ -119,15 +129,56 @@ export default function useGroupForm(defaultValue) { onBlur, }; + const removePhoto = (url) => { + const pathArray = url.split('/'); + fetch(`${BASE_URL}/image/${pathArray[pathArray.length - 1]}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${me.token}`, + }, + }); + }; + + const uploadPhoto = async (oldPhoto, newPhoto) => { + if (oldPhoto) { + removePhoto(oldPhoto); + } + if (newPhoto instanceof Blob) { + const formData = new FormData(); + + formData.append('file', newPhoto); + + try { + const response = await fetch(`${BASE_URL}/image`, { + method: 'POST', + headers: { + Authorization: `Bearer ${me.token}`, + }, + body: formData, + }); + const data = await response.json(); + + return typeof data.url === 'string' ? data.url : ''; + } catch { + return ''; + } + } else { + return ''; + } + }; + const handleSubmit = (onValid) => async () => { - if (!schema.safeParse(values).success) { + const result = schema.safeParse(values); + + if (!result.success) { let isFocus = false; const updatedErrors = Object.fromEntries( Object.entries(rules).map(([key, rule]) => { const errorMessage = rule.safeParse(values[key]).error?.issues?.[0] ?.message; - if (errorMessage && !isFocus) { + if (errorMessage && !isFocus && refs.current[key]) { isFocus = true; refs.current[key]?.focus(); } @@ -136,47 +187,25 @@ export default function useGroupForm(defaultValue) { }), ); setErrors(updatedErrors); + if (!isFocus) { + pushSnackbar({ + message: Object.values(updatedErrors)[0], + vertical: 'top', + horizontal: 'center', + type: 'error', + }); + } return; } if (values.originPhotoURL === values.photoURL) { - onValid(values); + onValid(result.data); return; } - if (values.originPhotoURL) { - const pathArray = values.originPhotoURL.split('/'); - fetch(`${BASE_URL}/image/${pathArray[pathArray.length - 1]}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${me.token}`, - }, - }); - } - - let photoURL = ''; + const photoURL = await uploadPhoto(values.originPhotoURL, values.photoURL); - if (values.photoURL instanceof Blob) { - const formData = new FormData(); - - formData.append('file', values.photoURL); - - try { - photoURL = await fetch(`${BASE_URL}/image`, { - method: 'POST', - headers: { - Authorization: `Bearer ${me.token}`, - }, - body: formData, - }) - .then((response) => response.json()) - .then((data) => data.url); - } catch { - photoURL = ''; - } - } - onValid({ ...values, photoURL }); + onValid({ ...result.data, photoURL }); }; useEffect(() => { diff --git a/contexts/Snackbar.jsx b/contexts/Snackbar.jsx index 303a6526..d4151443 100644 --- a/contexts/Snackbar.jsx +++ b/contexts/Snackbar.jsx @@ -25,11 +25,11 @@ function CloseButton({ onClick }) { export default function SnackbarProvider({ children }) { const [queue, setQueue] = useState([]); - const pushSnackbar = ({ message }) => + const pushSnackbar = ({ message, type, vertical = 'bottom', horizontal = 'left' }) => new Promise((resolve) => { setQueue((pre) => [ ...pre, - { id: Math.random(), open: true, message, resolve }, + { id: Math.random(), open: true, message, resolve, type, vertical, horizontal }, ]); }); @@ -49,7 +49,9 @@ export default function SnackbarProvider({ children }) { {queue.map((data) => ( } From b687b0f07fd91ea48c62799f20769035f8ce4d2b Mon Sep 17 00:00:00 2001 From: Johnson Mao <86179381+JohnsonMao@users.noreply.github.com> Date: Sun, 17 Nov 2024 00:51:50 +0800 Subject: [PATCH 5/5] feat(group): add markdown editor for group module --- components/Group/Form/Fields/TextField.jsx | 38 ++++--- components/Group/GroupList/GroupCard.jsx | 5 +- components/Group/detail/NoticeCard.jsx | 6 +- components/Group/detail/OrganizerCard.jsx | 4 +- components/Profile/MyGroup/GroupCard.jsx | 5 +- shared/components/CheckLink.jsx | 2 +- .../MarkdownEditor/MarkdownEditor.jsx | 103 ++++++++++++------ 7 files changed, 103 insertions(+), 60 deletions(-) diff --git a/components/Group/Form/Fields/TextField.jsx b/components/Group/Form/Fields/TextField.jsx index 7409e59a..95f32f6e 100644 --- a/components/Group/Form/Fields/TextField.jsx +++ b/components/Group/Form/Fields/TextField.jsx @@ -1,4 +1,5 @@ import MuiTextField from '@mui/material/TextField'; +import MarkdownEditor from '@/shared/components/MarkdownEditor/MarkdownEditor'; export default function TextField({ id, @@ -12,20 +13,29 @@ export default function TextField({ }) { return ( <> - control.setRef?.(name, element)} - fullWidth - id={id} - name={name} - sx={{ '& legend': { display: 'none' } }} - size="small" - placeholder={placeholder} - value={value} - multiline={multiline} - rows={multiline && 6} - helperText={helperText} - {...control} - /> + {multiline ? ( + control.setRef?.(name, element)} + value={value} + placeholder={placeholder} + onChange={(markdown) => control.onChange({ target: { name, value: markdown } })} + /> + ) : ( + control.setRef?.(name, element)} + fullWidth + id={id} + name={name} + sx={{ '& legend': { display: 'none' } }} + size="small" + placeholder={placeholder} + value={value} + helperText={helperText} + {...control} + /> + )} {error} ); diff --git a/components/Group/GroupList/GroupCard.jsx b/components/Group/GroupList/GroupCard.jsx index cc337e5c..0f431568 100644 --- a/components/Group/GroupList/GroupCard.jsx +++ b/components/Group/GroupList/GroupCard.jsx @@ -2,6 +2,7 @@ import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; import Image from '@/shared/components/Image'; import { timeDuration } from '@/utils/date'; import emptyCoverWithBackgroundImg from '@/public/assets/empty-cover-with-background.png'; +import MarkdownEditor from '@/shared/components/MarkdownEditor'; import { StyledAreas, StyledContainer, @@ -47,8 +48,8 @@ function GroupCard({ {formatToString(partnerEducationStep, '皆可')} - - {content} + + diff --git a/components/Group/detail/NoticeCard.jsx b/components/Group/detail/NoticeCard.jsx index 1636e9fb..c845f627 100644 --- a/components/Group/detail/NoticeCard.jsx +++ b/components/Group/detail/NoticeCard.jsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import Skeleton from '@mui/material/Skeleton'; import { timeDuration } from '@/utils/date'; -import TextWithLinks from '@/shared/components/TextWithLinks'; +import MarkdownEditor from '@/shared/components/MarkdownEditor'; export const StyledTitle = styled.h2` font-weight: bold; @@ -49,9 +49,7 @@ function NoticeCard({ data = {}, isLoading }) {
) : ( -
- {data?.notice} -
+ )} diff --git a/components/Group/detail/OrganizerCard.jsx b/components/Group/detail/OrganizerCard.jsx index fb3be0b0..2e283c6f 100644 --- a/components/Group/detail/OrganizerCard.jsx +++ b/components/Group/detail/OrganizerCard.jsx @@ -5,7 +5,7 @@ import Avatar from '@mui/material/Avatar'; import { EDUCATION_STEP, ROLE } from '@/constants/member'; import locationSvg from '@/public/assets/icons/location.svg'; import Chip from '@/shared/components/Chip'; -import TextWithLinks from '@/shared/components/TextWithLinks'; +import MarkdownEditor from '@/shared/components/MarkdownEditor'; const StyledHeader = styled.div` display: flex; @@ -139,7 +139,7 @@ function OrganizerCard({ data = {}, isLoading }) { ) : ( - {data?.content} + )} diff --git a/components/Profile/MyGroup/GroupCard.jsx b/components/Profile/MyGroup/GroupCard.jsx index f2ed64b0..f49bfa31 100644 --- a/components/Profile/MyGroup/GroupCard.jsx +++ b/components/Profile/MyGroup/GroupCard.jsx @@ -9,6 +9,7 @@ import Image from '@/shared/components/Image'; import emptyCoverImg from '@/public/assets/empty-cover.png'; import useMutation from '@/hooks/useMutation'; import { timeDuration } from '@/utils/date'; +import MarkdownEditor from '@/shared/components/MarkdownEditor'; import { StyledAreas, StyledContainer, @@ -86,8 +87,8 @@ function GroupCard({ {title} - - {content} + + diff --git a/shared/components/CheckLink.jsx b/shared/components/CheckLink.jsx index ba781565..0965b0be 100644 --- a/shared/components/CheckLink.jsx +++ b/shared/components/CheckLink.jsx @@ -51,7 +51,7 @@ function InternalCheckLink(props, ref) { const data = Array.isArray(trustWebsites) ? trustWebsites : []; const newLink = new URL(href); - if (data.includes(newLink.hostname)) { + if (data.includes(newLink.hostname) || newLink.hostname === window.location.hostname) { window.open(newLink.href, '_blank'); return; } diff --git a/shared/components/MarkdownEditor/MarkdownEditor.jsx b/shared/components/MarkdownEditor/MarkdownEditor.jsx index 62faf19f..86aa93b3 100644 --- a/shared/components/MarkdownEditor/MarkdownEditor.jsx +++ b/shared/components/MarkdownEditor/MarkdownEditor.jsx @@ -1,15 +1,13 @@ -import { useEffect, useId, useRef } from 'react'; +import { forwardRef, useEffect, useId, useMemo, useRef } from 'react'; import { BlockTypeSelect, BoldItalicUnderlineToggles, - CodeToggle, CreateLink, DiffSourceToggleWrapper, InsertThematicBreak, ListsToggle, UndoRedo, MDXEditor, - codeBlockPlugin, diffSourcePlugin, headingsPlugin, linkDialogPlugin, @@ -24,6 +22,7 @@ import { imagePlugin, } from '@mdxeditor/editor'; import '@mdxeditor/editor/style.css'; +import { cn } from '@/utils/cn'; import zhTW from './locales/zh-tw'; import { ImageDialog } from './ImageDialog'; import CheckLink from '../CheckLink'; @@ -32,53 +31,76 @@ const toolbarContents = () => ( - - - - - - - + + ); -const generatePlugins = (diffMarkdown = '') => [ - codeBlockPlugin(), - diffSourcePlugin({ viewMode: 'rich-text', diffMarkdown }), - headingsPlugin({ allowedHeadingLevels: [1, 2, 3] }), - imagePlugin({ ImageDialog }), - linkDialogPlugin(), - linkPlugin(), - listsPlugin(), - markdownShortcutPlugin(), - quotePlugin(), - thematicBreakPlugin() -]; - -const generatePluginWithToolbar = (diffMarkdown = '') => [ - ...generatePlugins(diffMarkdown), - toolbarPlugin({ toolbarContents }) -]; +const generatePluginsSettings = ({ diffMarkdown = '' }) => ({ + diffSource: diffSourcePlugin({ viewMode: 'rich-text', diffMarkdown }), + headings: headingsPlugin({ allowedHeadingLevels: [1, 2, 3] }), + image: imagePlugin({ ImageDialog }), + linkDialog: linkDialogPlugin(), + link: linkPlugin(), + lists: listsPlugin(), + quote: quotePlugin(), + markdownShortcut: markdownShortcutPlugin(), + thematicBreak: thematicBreakPlugin(), + toolbar: toolbarPlugin({ toolbarContents }), +}); -export default function MarkdownEditor({ readOnly = false, value = '123123', onChange }) { +function InternalMarkdownEditor( + { + readOnly = false, + hasHeadings, + value = '', + placeholder, + rootClassName, + className, + editorClassName, + onChange, + suppressLinkDefaultPrevent = false, + }, + ref +) { const id = useId(); const checkLinkRef = useRef(null); + const markdown = useRef(value); + const pluginsSettings = useMemo( + () => + Object.entries(generatePluginsSettings({ diffMarkdown: markdown.current })) + .filter(([key]) => { + if (readOnly) { + return key !== 'toolbar'; + } + if (key === 'headings') { + return hasHeadings; + } + return true; + }) + .map(([_, plugin]) => plugin), + [markdown.current] + ); useEffect(() => { const editor = document.getElementById(id).querySelector('.prose'); const handleClick = (e) => { + if (suppressLinkDefaultPrevent) { + return; + } + let { target } = e; - e.preventDefault(); while (target.tagName !== 'A') { if (editor === target) break; target = target.parentElement; } if (target.tagName === 'A') { + e.preventDefault(); checkLinkRef.current?.check(target.href); } }; @@ -88,24 +110,35 @@ export default function MarkdownEditor({ readOnly = false, value = '123123', onC }, []); return ( -
+
{ + className={className} + contentEditableClassName={cn( + 'prose', + readOnly && '!p-0', + editorClassName + )} + plugins={pluginsSettings} + translation={(keyString, defaultText, interpolations) => { const keys = keyString.split('.'); const text = keys.reduce((acc, key) => acc?.[key], zhTW); return typeof text === 'string' ? text.replace(/{{([^{}]+)}}/g, (_, p1) => interpolations?.[p1] || '') - : defaultValue; + : defaultText; }} />
); } + +const MarkdownEditor = forwardRef(InternalMarkdownEditor); + +export default MarkdownEditor;