From 5104a2c624d5fffaca55254f868d343fa4d7cf9f Mon Sep 17 00:00:00 2001 From: Steve Yu Date: Mon, 17 Apr 2023 07:57:50 +0800 Subject: [PATCH] feat: editor support aplayer (#349) --- next.config.js | 1 + package.json | 3 ++ pnpm-lock.yaml | 55 ++++++++++++++++++++ src/components/ui/APlayer.tsx | 25 ++++++++++ src/markdown/index.ts | 4 ++ src/markdown/rehype-audio.ts | 58 ++++++++++++++++++++++ src/pages/_app.tsx | 1 + src/pages/dashboard/[subdomain]/editor.tsx | 48 +++++++++++++++--- 8 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 src/components/ui/APlayer.tsx create mode 100644 src/markdown/rehype-audio.ts diff --git a/next.config.js b/next.config.js index 2da4df45..c1f48dd4 100644 --- a/next.config.js +++ b/next.config.js @@ -87,6 +87,7 @@ module.exports = withBundleAnalyzer( productionBrowserSourceMaps: true, webpack(config) { + config.resolve.fallback = { fs: false } // polyfill node-id3 config.plugins.push(new UnoCSS()) return config }, diff --git a/package.json b/package.json index 35838eb2..a4fbffcf 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "@uiw/codemirror-extensions-events": "4.19.16", "@uiw/react-codemirror": "4.19.16", "@urql/core": "4.0.4", + "aplayer": "^1.10.1", + "aplayer-react": "^1.0.5", "axios": "1.3.5", "canvas-confetti": "^1.6.0", "chroma-js": "^2.4.2", @@ -83,6 +85,7 @@ "next": "13.2.4", "next-i18next": "^13.2.2", "nextjs-progressbar": "0.0.16", + "node-id3": "^0.2.6", "pinyin": "3.0.0-alpha.5", "prismjs": "1.29.0", "react": "18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eac46757..b8520d3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,12 @@ dependencies: '@urql/core': specifier: 4.0.4 version: 4.0.4(graphql@16.6.0) + aplayer: + specifier: ^1.10.1 + version: 1.10.1 + aplayer-react: + specifier: ^1.0.5 + version: 1.0.5(aplayer@1.10.1)(react@18.2.0) axios: specifier: 1.3.5 version: 1.3.5 @@ -172,6 +178,9 @@ dependencies: nextjs-progressbar: specifier: 0.0.16 version: 0.0.16(next@13.2.4)(react@18.2.0) + node-id3: + specifier: ^0.2.6 + version: 0.2.6 pinyin: specifier: 3.0.0-alpha.5 version: 3.0.0-alpha.5 @@ -5646,6 +5655,27 @@ packages: normalize-path: 3.0.0 picomatch: 2.3.1 + /aplayer-react@1.0.5(aplayer@1.10.1)(react@18.2.0): + resolution: {integrity: sha512-iVMOJmI+NxY2AIgTkZlhVyiesLYR1Oqmyp2pUjPVhZpvzZjPKshLralGsTUy4gQlDs0X4yahSzHK7+TA5vA9KQ==} + peerDependencies: + aplayer: ^1.10.1 + react: ^18.2.0 + dependencies: + aplayer: 1.10.1 + clsx: 1.2.1 + fast-average-color: 9.3.0 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false + + /aplayer@1.10.1: + resolution: {integrity: sha512-HAfyxgCUTLAqtYlxzzK9Fyqg6y+kZ9CqT1WfeWE8FSzwspT6oBqWOZHANPHF3RGTtC33IsyEgrfthPDzU5r9kQ==} + dependencies: + balloon-css: 0.5.2 + promise-polyfill: 7.1.0 + smoothscroll: 0.4.0 + dev: false + /aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} dev: false @@ -5963,6 +5993,10 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /balloon-css@0.5.2: + resolution: {integrity: sha512-zheJpzwyNrG4t39vusA67v3BYg1HTVXOF8cErPEHzWK88PEOFwgo6Ea9VHOgOWNMgeuOtFVtB73NE2NWl9uDyQ==} + dev: false + /base-x@3.0.9: resolution: {integrity: sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==} dependencies: @@ -8955,6 +8989,13 @@ packages: '@babel/runtime': 7.21.0 dev: false + /iconv-lite@0.6.2: + resolution: {integrity: sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: false + /iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -10895,6 +10936,12 @@ packages: hasBin: true dev: false + /node-id3@0.2.6: + resolution: {integrity: sha512-w8GuKXLlPpDjTxLowCt/uYMhRQzED3cg2GdSG1i6RSGKeDzPvxlXeLQuQInKljahPZ0aDnmyX7FX8BbJOM7REg==} + dependencies: + iconv-lite: 0.6.2 + dev: false + /node-releases@2.0.10: resolution: {integrity: sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==} @@ -11579,6 +11626,10 @@ packages: resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} dev: false + /promise-polyfill@7.1.0: + resolution: {integrity: sha512-P6NJ2wU/8fac44ENORsuqT8TiolKGB2u0fEClPtXezn7w5cmLIjM/7mhPlTebke2EPr6tmqZbXvnX0TxwykGrg==} + dev: false + /prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} dependencies: @@ -12718,6 +12769,10 @@ packages: is-fullwidth-code-point: 4.0.0 dev: true + /smoothscroll@0.4.0: + resolution: {integrity: sha512-sggQ3U2Un38b3+q/j1P4Y4fCboCtoUIaBYoge+Lb6Xg1H8RTIif/hugVr+ErMtIDpvBbhQfTjtiTeYAfbw1ZGQ==} + dev: false + /sonic-boom@2.8.0: resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==} dependencies: diff --git a/src/components/ui/APlayer.tsx b/src/components/ui/APlayer.tsx new file mode 100644 index 00000000..eb4b82a3 --- /dev/null +++ b/src/components/ui/APlayer.tsx @@ -0,0 +1,25 @@ +import { AudioInfo } from "aplayer-react" +import { APlayer as AplayerReact } from "aplayer-react" +import { useEffect, useState } from "react" + +export const APlayer: React.FC< + { + children: string[] + src: string + } & React.HTMLAttributes +> = ({ src, children }) => { + const [audio, setAudio] = useState() + useEffect(() => { + if (!Array.isArray(children) || children.length < 3) { + throw new Error("Aplayer params error, exactly 3 parameters required") + } + const [artist, name, cover] = children as Array + setAudio({ artist, name, cover, url: src }) + }, []) + + if (!audio) { + return null + } else { + return + } +} diff --git a/src/markdown/index.ts b/src/markdown/index.ts index 82a8ef84..8d5352d9 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -9,6 +9,7 @@ import rehypeRaw from "rehype-raw" import { refractor } from "refractor" import rehypePrismGenerator from "rehype-prism-plus/generator" import { rehypeImage } from "./rehype-image" +import { rehypeAudio } from "./rehype-audio" import { rehypeTable } from "./rehype-table" import { remarkCallout } from "./remark-callout" import { rehypeExternalLink } from "./rehyper-external-link" @@ -35,6 +36,7 @@ import { remarkMermaid } from "./remark-mermaid" import { Mermaid } from "~/components/ui/Mermaid" import rehypeRewrite from "rehype-rewrite" import { toText } from "hast-util-to-text" +import { APlayer } from "~/components/ui/APlayer" export type MarkdownEnv = { excerpt: string @@ -109,6 +111,7 @@ export const renderPageContent = ( .use(rehypeStringify) .use(rehypeRaw) .use(rehypeImage, { env }) + .use(rehypeAudio) .use(rehypeSanitize, sanitizeScheme) .use(rehypePrism, { ignoreMissing: true, @@ -181,6 +184,7 @@ export const renderPageContent = ( anchor: Element, mention: Mention, mermaid: Mermaid, + audio: APlayer, } as any, }) .use(() => (tree) => { diff --git a/src/markdown/rehype-audio.ts b/src/markdown/rehype-audio.ts new file mode 100644 index 00000000..41241917 --- /dev/null +++ b/src/markdown/rehype-audio.ts @@ -0,0 +1,58 @@ +import { Root } from "rehype-raw" +import { Plugin } from "unified" +import { visit } from "unist-util-visit" +import { getUserContentsUrl } from "~/lib/user-contents" +import { toGateway } from "~/lib/ipfs-parser" + +export const rehypeAudio: Plugin, Root> = () => { + return (tree) => { + visit(tree, { tagName: "audio" }, (node, i, parent) => { + if (!node.properties) { + return + } + + const src = node.properties.src + + if (!src || typeof src !== "string") { + return + } + + let url = src + const ipfsUrl = toGateway(url) + node.properties.src = ipfsUrl + url = ipfsUrl + + node.properties.src = getUserContentsUrl(url) + + if (parent) { + const coverRaw = node.properties.cover + const cover = + typeof coverRaw === "string" ? toGateway(coverRaw) : undefined + + parent.children[i!] = { + type: "element", + tagName: "audio", + properties: { + src: url, + }, + // children array format: + // artist - name - cover + children: [ + { + type: "text", + value: `${node.properties.artist}`, + }, + { + type: "text", + value: `${node.properties.name}`, + }, + { + type: "text", + value: cover ?? "", + }, + ], + } + } + }) + } +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index c764e0d5..ac7a3772 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,5 +1,6 @@ import "~/css/main.css" import "~/generated/uno.css" +import "aplayer/dist/APlayer.min.css" import { Toaster } from "react-hot-toast" import { createClient, WagmiConfig } from "wagmi" diff --git a/src/pages/dashboard/[subdomain]/editor.tsx b/src/pages/dashboard/[subdomain]/editor.tsx index 0e3f1342..3687c946 100644 --- a/src/pages/dashboard/[subdomain]/editor.tsx +++ b/src/pages/dashboard/[subdomain]/editor.tsx @@ -44,6 +44,7 @@ import { serverSidePropsHandler } from "~/lib/server-side-props" import { getDefaultSlug } from "~/lib/default-slug" import { useMobileLayout } from "~/hooks/useMobileLayout" import { OptionsButton } from "~/components/dashboard/OptionsButton" +import NodeID3 from "node-id3" export const getServerSideProps: GetServerSideProps = serverSidePropsHandler( async (ctx) => { @@ -391,19 +392,52 @@ export default function SubdomainEditor() { async (file: File) => { const toastId = toast.loading("Uploading...") try { - if (!file.type.startsWith("image/")) { - throw new Error("You can only upload images") + if ( + !file.type.startsWith("image/") && + !file.type.startsWith("audio/") + ) { + throw new Error("You can only upload images or audios") } const { key } = await uploadFile(file) toast.success("Uploaded!", { id: toastId, }) - view?.dispatch( - view.state.replaceSelection( - `\n![${file.name.replace(/\.\w+$/, "")}](${key})\n`, - ), - ) + if (file.type.startsWith("image/")) { + view?.dispatch( + view.state.replaceSelection( + `\n![${file.name.replace(/\.\w+$/, "")}](${key})\n`, + ), + ) + } else if (file.type.startsWith("audio/")) { + const fileArrayBuffer = await file.arrayBuffer() + const fileBuffer = Buffer.from(fileArrayBuffer) + const tags = NodeID3.read(fileBuffer) + const name = tags.title ?? file.name + const artist = tags.artist + const cover = await (async () => { + const image = tags.image + if (!image || typeof image === "string") return image + + const toastId = toast.loading("Uploading cover...") + const { key } = await uploadFile( + new Blob([image.imageBuffer], { type: image.type.name }), + ) + toast.success("Uploaded cover!", { + id: toastId, + }) + return key + })() + view?.dispatch( + view.state.replaceSelection( + `\n