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 = () => { }} > -

+

- + {/* */}

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/CheckLink.jsx b/shared/components/CheckLink.jsx new file mode 100644 index 00000000..ba781565 --- /dev/null +++ b/shared/components/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..62faf19f --- /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/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} - /> -
- - - - - - )} -
+
); } 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"