From 57b643fcedf54fcc3214c77d1b15e518fb677fd8 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 26 Jun 2024 20:25:38 +0800 Subject: [PATCH] feat: add discover feed form from rsshub (#89) * feat: regexp path parser Signed-off-by: Innei * feat: markdown Signed-off-by: Innei * fix: shirk Signed-off-by: Innei * feat: discover form Signed-off-by: Innei * fix: modal overlay zindex Signed-off-by: Innei * feat: fill path Signed-off-by: Innei * feat: modal Signed-off-by: Innei * fix: card style Signed-off-by: Innei * fix: parser Signed-off-by: Innei * feat: add maintainers Signed-off-by: Innei * fix: select zindex Signed-off-by: Innei * fix: dismiss all Signed-off-by: Innei * feat: optimize a11y Signed-off-by: Innei * fix: dark mode style Signed-off-by: Innei --------- Signed-off-by: Innei --- .github/workflows/lint.yml | 3 +- package.json | 6 + pnpm-lock.yaml | 100 ++ src/main/menu.ts | 4 +- src/renderer/src/atoms/sidebar.ts | 11 + .../ui/code-highlighter/copy-button.tsx | 1 + src/renderer/src/components/ui/form.tsx | 2 +- .../src/components/ui/markdown/Markdown.tsx | 28 + .../components/ui/markdown/index.module.css | 264 +++++ .../src/components/ui/markdown/index.ts | 1 + .../ui/markdown/plugins/container.ts | 31 + .../src/components/ui/modal/stacked/atom.ts | 5 + .../components/ui/modal/stacked/constants.ts | 2 + .../components/ui/modal/stacked/context.tsx | 10 +- .../ui/modal/stacked/declarative-modal.tsx | 17 +- .../src/components/ui/modal/stacked/hooks.tsx | 8 +- .../src/components/ui/modal/stacked/index.ts | 1 + .../src/components/ui/modal/stacked/modal.tsx | 58 +- .../components/ui/modal/stacked/overlay.tsx | 28 + .../components/ui/modal/stacked/provider.tsx | 10 +- src/renderer/src/components/ui/select.tsx | 2 +- src/renderer/src/hono.ts | 26 + src/renderer/src/hooks/biz/useRouteParams.ts | 53 +- src/renderer/src/lib/path-parser.test.ts | 347 ++++++ src/renderer/src/lib/path-parser.ts | 122 ++ .../modules/discover/content-components.tsx | 236 ++++ .../src/modules/discover/example-data.json | 1017 +++++++++++++++++ .../src/modules/discover/feed-form.tsx | 9 +- .../discover/recommendation-content.tsx | 16 +- .../modules/discover/recommendations-card.tsx | 61 + .../src/modules/discover/recommendations.tsx | 82 +- src/renderer/src/modules/discover/types.ts | 43 + src/renderer/src/modules/discover/utils.ts | 3 + .../entry-column/list-item-template.tsx | 2 +- .../src/modules/feed-column/index.tsx | 5 +- src/renderer/src/modules/feed-column/list.tsx | 2 +- src/renderer/src/styles/colors.css | 10 + tailwind.config.ts | 25 +- 38 files changed, 2483 insertions(+), 168 deletions(-) create mode 100644 src/renderer/src/atoms/sidebar.ts create mode 100644 src/renderer/src/components/ui/markdown/Markdown.tsx create mode 100644 src/renderer/src/components/ui/markdown/index.module.css create mode 100644 src/renderer/src/components/ui/markdown/index.ts create mode 100644 src/renderer/src/components/ui/markdown/plugins/container.ts create mode 100644 src/renderer/src/components/ui/modal/stacked/atom.ts create mode 100644 src/renderer/src/components/ui/modal/stacked/overlay.tsx create mode 100644 src/renderer/src/lib/path-parser.test.ts create mode 100644 src/renderer/src/lib/path-parser.ts create mode 100644 src/renderer/src/modules/discover/content-components.tsx create mode 100644 src/renderer/src/modules/discover/example-data.json create mode 100644 src/renderer/src/modules/discover/recommendations-card.tsx create mode 100644 src/renderer/src/modules/discover/types.ts create mode 100644 src/renderer/src/modules/discover/utils.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e0b0ebfda..314c64f60 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,4 +36,5 @@ jobs: run: | pnpm run typecheck npm run lint - pnpm run test + - name: Run test + run: pnpm test diff --git a/package.json b/package.json index e9890b4b0..68368d240 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,11 @@ "lodash-es": "4.17.21", "lowdb": "7.0.1", "lucide-react": "0.396.0", + "markdown-it": "14.1.0", + "markdown-it-container": "4.0.0", "ofetch": "1.3.4", + "path-to-regexp": "7.0.0", + "react-error-boundary": "4.0.13", "react-helmet-async": "2.0.5", "react-hook-form": "7.52.0", "react-hotkeys-hook": "4.5.0", @@ -108,6 +112,8 @@ "@tailwindcss/container-queries": "0.1.1", "@tailwindcss/typography": "0.5.13", "@types/lodash-es": "4.17.12", + "@types/markdown-it": "14.1.1", + "@types/markdown-it-container": "2.0.10", "@types/node": "^20.14.7", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cdd3939a..69b4aba89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,9 +169,21 @@ importers: lucide-react: specifier: 0.396.0 version: 0.396.0(react@18.3.1) + markdown-it: + specifier: 14.1.0 + version: 14.1.0 + markdown-it-container: + specifier: 4.0.0 + version: 4.0.0 ofetch: specifier: 1.3.4 version: 1.3.4 + path-to-regexp: + specifier: 7.0.0 + version: 7.0.0 + react-error-boundary: + specifier: 4.0.13 + version: 4.0.13(react@18.3.1) react-helmet-async: specifier: 2.0.5 version: 2.0.5(react@18.3.1) @@ -260,6 +272,12 @@ importers: '@types/lodash-es': specifier: 4.17.12 version: 4.17.12 + '@types/markdown-it': + specifier: 14.1.1 + version: 14.1.1 + '@types/markdown-it-container': + specifier: 2.0.10 + version: 2.0.10 '@types/node': specifier: ^20.14.7 version: 20.14.7 @@ -2179,15 +2197,27 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} '@types/lodash@4.17.4': resolution: {integrity: sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==} + '@types/markdown-it-container@2.0.10': + resolution: {integrity: sha512-zv+YxrlSYRq51e9kzm3orv4OvF4U79Ll1OyplNXr00o4ZC/8PukJk/jEWH7CnsMtrSWZlyv0czhz42jm9J4uLw==} + + '@types/markdown-it@14.1.1': + resolution: {integrity: sha512-4NpsnpYl2Gt1ljyBGrKMxFYAYvpqbnnkgP/i/g+NLpjEUa3obn1XJCur9YbEXKDAkaXqsR1LbDnGEJ0MmKFxfg==} + '@types/mdast@4.0.3': resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==} + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} @@ -4145,6 +4175,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + lint-staged@15.2.7: resolution: {integrity: sha512-+FdVbbCZ+yoh7E/RosSdqKJyUM2OEjTciH0TFNkawKgvFp1zbGlEC39RADg+xKBG1R4mhoH2j85myBQZ5wR+lw==} engines: {node: '>=18.12.0'} @@ -4259,6 +4292,13 @@ packages: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + markdown-it-container@4.0.0: + resolution: {integrity: sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==} + + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + matcher@3.0.0: resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} engines: {node: '>=10'} @@ -4293,6 +4333,9 @@ packages: mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -4695,6 +4738,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@7.0.0: + resolution: {integrity: sha512-58Y94bQqF3zBIASFNiufRPH1NfgZth1qwZ35radL87sg8pgbVqr6uikAhqZtFD+w65MGH6SWnY/ly3GbrM4fbg==} + engines: {node: '>=16'} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -4834,6 +4881,10 @@ packages: pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4850,6 +4901,11 @@ packages: peerDependencies: react: ^18.3.1 + react-error-boundary@4.0.13: + resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} + peerDependencies: + react: '>=16.13.1' + react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} @@ -5503,6 +5559,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.5.3: resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} @@ -7797,16 +7856,29 @@ snapshots: dependencies: '@types/node': 20.14.7 + '@types/linkify-it@5.0.0': {} + '@types/lodash-es@4.17.12': dependencies: '@types/lodash': 4.17.4 '@types/lodash@4.17.4': {} + '@types/markdown-it-container@2.0.10': + dependencies: + '@types/markdown-it': 14.1.1 + + '@types/markdown-it@14.1.1': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@4.0.3': dependencies: '@types/unist': 3.0.2 + '@types/mdurl@2.0.0': {} + '@types/ms@0.7.34': {} '@types/node@20.14.7': @@ -10254,6 +10326,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + lint-staged@15.2.7: dependencies: chalk: 5.3.0 @@ -10390,6 +10466,17 @@ snapshots: - bluebird - supports-color + markdown-it-container@4.0.0: {} + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 @@ -10488,6 +10575,8 @@ snapshots: mdn-data@2.0.30: {} + mdurl@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -10964,6 +11053,8 @@ snapshots: lru-cache: 10.2.2 minipass: 7.1.2 + path-to-regexp@7.0.0: {} + path-type@4.0.0: {} pathe@1.1.2: {} @@ -11077,6 +11168,8 @@ snapshots: end-of-stream: 1.4.4 once: 1.4.0 + punycode.js@2.3.1: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -11089,6 +11182,11 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-error-boundary@4.0.13(react@18.3.1): + dependencies: + '@babel/runtime': 7.24.4 + react: 18.3.1 + react-fast-compare@3.2.2: {} react-helmet-async@2.0.5(react@18.3.1): @@ -11786,6 +11884,8 @@ snapshots: typescript@5.5.2: {} + uc.micro@2.1.0: {} + ufo@1.5.3: {} undici-types@5.26.5: {} diff --git a/src/main/menu.ts b/src/main/menu.ts index d4d660853..6f3ca1de6 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -43,7 +43,7 @@ export const registerAppMenu = () => { label: "follow https://rsshub.app/twitter/user/DIYgod", click: () => { createWindow({ - extraPath: `/add?url=${encodeURIComponent( + extraPath: `#add?url=${encodeURIComponent( "https://rsshub.app/twitter/user/DIYgod", )}`, width: 800, @@ -55,7 +55,7 @@ export const registerAppMenu = () => { label: "follow https://diygod.me/feed", click: () => { createWindow({ - extraPath: `/add?url=${encodeURIComponent( + extraPath: `#add?url=${encodeURIComponent( "https://diygod.me/feed", )}`, width: 800, diff --git a/src/renderer/src/atoms/sidebar.ts b/src/renderer/src/atoms/sidebar.ts new file mode 100644 index 000000000..f7b69f4bc --- /dev/null +++ b/src/renderer/src/atoms/sidebar.ts @@ -0,0 +1,11 @@ +import { createAtomHooks } from "@renderer/lib/jotai" +import { atom } from "jotai" + +export const [ + , + useSidebarActiveView, + useSidebarActiveViewValue, + useSetSidebarActiveView, + getSidebarActiveView, + setSidebarActiveView, +] = createAtomHooks(atom(0)) diff --git a/src/renderer/src/components/ui/code-highlighter/copy-button.tsx b/src/renderer/src/components/ui/code-highlighter/copy-button.tsx index 6cb474470..e6b62ee8c 100644 --- a/src/renderer/src/components/ui/code-highlighter/copy-button.tsx +++ b/src/renderer/src/components/ui/code-highlighter/copy-button.tsx @@ -36,6 +36,7 @@ export const CopyButton: Component<{ }, [value]) return ( + ({ id }), [id])}>
) diff --git a/src/renderer/src/components/ui/markdown/Markdown.tsx b/src/renderer/src/components/ui/markdown/Markdown.tsx new file mode 100644 index 000000000..9b46b2cc3 --- /dev/null +++ b/src/renderer/src/components/ui/markdown/Markdown.tsx @@ -0,0 +1,28 @@ +/* eslint-disable @eslint-react/dom/no-dangerously-set-innerhtml */ +import { cn } from "@renderer/lib/utils" +import markdownit from "markdown-it" +import { useMemo } from "react" + +import styles from "./index.module.css" +import { createContainer } from "./plugins/container" + +const md = markdownit() + .use(...createContainer("tip", "TIP", () => md)) + .use(...createContainer("info", "INFO", () => md)) + .use(...createContainer("warning", "WARNING", () => md)) + .use(...createContainer("danger", "DANGER", () => md)) + .use(...createContainer("details", "Details", () => md)) + +export const Markdown: Component<{ + children: string +}> = (props) => ( +
({ + __html: md.render(props.children as string), + }), + [props.children], + )} + /> +) diff --git a/src/renderer/src/components/ui/markdown/index.module.css b/src/renderer/src/components/ui/markdown/index.module.css new file mode 100644 index 000000000..40b57e913 --- /dev/null +++ b/src/renderer/src/components/ui/markdown/index.module.css @@ -0,0 +1,264 @@ +.markdown { + --vp-custom-block-font-size: 16px; + --vp-custom-block-code-font-size: 14px; + --vp-custom-block-info-border: #0077cc; + --vp-custom-block-info-text: #0077cc; + --vp-custom-block-info-bg: #f0f9ff; + --vp-custom-block-info-code-bg: #f0f9ff; + --vp-custom-block-note-border: #f6c244; + --vp-custom-block-note-text: #f6c244; + --vp-custom-block-note-bg: #fff8e6; + --vp-custom-block-note-code-bg: #fff8e6; + --vp-custom-block-tip-border: #0077cc; + --vp-custom-block-tip-text: #0077cc; + --vp-custom-block-tip-bg: #f0f9ff; + --vp-custom-block-tip-code-bg: #f0f9ff; + --vp-custom-block-important-border: #ff4d4f; + --vp-custom-block-important-text: #ff4d4f; + --vp-custom-block-important-bg: #fff1f0; + --vp-custom-block-important-code-bg: #fff1f0; + --vp-custom-block-warning-border: #ffaa00; + --vp-custom-block-warning-text: #ffaa00; + --vp-custom-block-warning-bg: #fffbe6; + --vp-custom-block-warning-code-bg: #fffbe6; + --vp-custom-block-danger-border: #ff4d4f; + --vp-custom-block-danger-text: #ff4d4f; + --vp-custom-block-danger-bg: #fff1f0; + --vp-custom-block-danger-code-bg: #fff1f0; + --vp-custom-block-caution-border: #ffaa00; + --vp-custom-block-caution-text: #ffaa00; + --vp-custom-block-caution-bg: #fffbe6; + --vp-custom-block-caution-code-bg: #fffbe6; + --vp-custom-block-details-border: #0077cc; + --vp-custom-block-details-text: #0077cc; + --vp-custom-block-details-bg: #f0f9ff; + --vp-custom-block-details-code-bg: #f0f9ff; + + --vp-c-text-2: #333; + --vp-c-brand-1: #0077cc; + --vp-c-brand-2: #005faa; + --vp-c-tip-1: #0077cc; + --vp-c-tip-2: #005faa; + --vp-c-important-1: #ff4d4f; + --vp-c-important-2: #d32f2f; + --vp-c-warning-1: #ffaa00; + --vp-c-warning-2: #cc8400; + --vp-c-danger-1: #ff4d4f; + --vp-c-danger-2: #d32f2f; + --vp-c-caution-1: #ffaa00; + --vp-c-caution-2: #cc8400; + + :global { + .custom-block { + @apply text-sm; + + border: 1px solid transparent; + border-radius: 8px; + padding: 16px 16px 8px; + line-height: 24px; + + color: var(--vp-c-text-2); + } + + .custom-block.info { + border-color: var(--vp-custom-block-info-border); + color: var(--vp-custom-block-info-text); + background-color: var(--vp-custom-block-info-bg); + } + + .custom-block.info a, + .custom-block.info code { + color: var(--vp-c-brand-1); + } + + .custom-block.info a:hover, + .custom-block.info a:hover > code { + color: var(--vp-c-brand-2); + } + + .custom-block.info code { + background-color: var(--vp-custom-block-info-code-bg); + } + + .custom-block.note { + border-color: var(--vp-custom-block-note-border); + color: var(--vp-custom-block-note-text); + background-color: var(--vp-custom-block-note-bg); + } + + .custom-block.note a, + .custom-block.note code { + color: var(--vp-c-brand-1); + } + + .custom-block.note a:hover, + .custom-block.note a:hover > code { + color: var(--vp-c-brand-2); + } + + .custom-block.note code { + background-color: var(--vp-custom-block-note-code-bg); + } + + .custom-block.tip { + border-color: var(--vp-custom-block-tip-border); + color: var(--vp-custom-block-tip-text); + background-color: var(--vp-custom-block-tip-bg); + } + + .custom-block.tip a, + .custom-block.tip code { + color: var(--vp-c-tip-1); + } + + .custom-block.tip a:hover, + .custom-block.tip a:hover > code { + color: var(--vp-c-tip-2); + } + + .custom-block.tip code { + background-color: var(--vp-custom-block-tip-code-bg); + } + + .custom-block.important { + border-color: var(--vp-custom-block-important-border); + color: var(--vp-custom-block-important-text); + background-color: var(--vp-custom-block-important-bg); + } + + .custom-block.important a, + .custom-block.important code { + color: var(--vp-c-important-1); + } + + .custom-block.important a:hover, + .custom-block.important a:hover > code { + color: var(--vp-c-important-2); + } + + .custom-block.important code { + background-color: var(--vp-custom-block-important-code-bg); + } + + .custom-block.warning { + border-color: var(--vp-custom-block-warning-border); + color: var(--vp-custom-block-warning-text); + background-color: var(--vp-custom-block-warning-bg); + } + + .custom-block.warning a, + .custom-block.warning code { + color: var(--vp-c-warning-1); + } + + .custom-block.warning a:hover, + .custom-block.warning a:hover > code { + color: var(--vp-c-warning-2); + } + + .custom-block.warning code { + background-color: var(--vp-custom-block-warning-code-bg); + } + + .custom-block.danger { + border-color: var(--vp-custom-block-danger-border); + color: var(--vp-custom-block-danger-text); + background-color: var(--vp-custom-block-danger-bg); + } + + .custom-block.danger a, + .custom-block.danger code { + color: var(--vp-c-danger-1); + } + + .custom-block.danger a:hover, + .custom-block.danger a:hover > code { + color: var(--vp-c-danger-2); + } + + .custom-block.danger code { + background-color: var(--vp-custom-block-danger-code-bg); + } + + .custom-block.caution { + border-color: var(--vp-custom-block-caution-border); + color: var(--vp-custom-block-caution-text); + background-color: var(--vp-custom-block-caution-bg); + } + + .custom-block.caution a, + .custom-block.caution code { + color: var(--vp-c-caution-1); + } + + .custom-block.caution a:hover, + .custom-block.caution a:hover > code { + color: var(--vp-c-caution-2); + } + + .custom-block.caution code { + background-color: var(--vp-custom-block-caution-code-bg); + } + + .custom-block.details { + border-color: var(--vp-custom-block-details-border); + color: var(--vp-custom-block-details-text); + background-color: var(--vp-custom-block-details-bg); + } + + .custom-block.details a { + color: var(--vp-c-brand-1); + } + + .custom-block.details a:hover, + .custom-block.details a:hover > code { + color: var(--vp-c-brand-2); + } + + .custom-block.details code { + background-color: var(--vp-custom-block-details-code-bg); + } + + .custom-block-title { + font-weight: 600; + @apply m-0 text-base; + } + + .custom-block p + p { + margin: 8px 0; + } + + .custom-block.details summary { + margin: 0 0 8px; + font-weight: 700; + cursor: pointer; + user-select: none; + } + + .custom-block.details summary + p { + margin: 8px 0; + } + + .custom-block a { + color: inherit; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 2px; + transition: opacity 0.25s; + } + + .custom-block a:hover { + opacity: 0.75; + } + + .custom-block code { + font-size: var(--vp-custom-block-code-font-size); + } + + .custom-block.custom-block th, + .custom-block.custom-block blockquote > p { + font-size: var(--vp-custom-block-font-size); + color: inherit; + } + } +} diff --git a/src/renderer/src/components/ui/markdown/index.ts b/src/renderer/src/components/ui/markdown/index.ts new file mode 100644 index 000000000..b94d1d4f4 --- /dev/null +++ b/src/renderer/src/components/ui/markdown/index.ts @@ -0,0 +1 @@ +export * from "./Markdown" diff --git a/src/renderer/src/components/ui/markdown/plugins/container.ts b/src/renderer/src/components/ui/markdown/plugins/container.ts new file mode 100644 index 000000000..a84cead43 --- /dev/null +++ b/src/renderer/src/components/ui/markdown/plugins/container.ts @@ -0,0 +1,31 @@ +import type MarkdownIt from "markdown-it" +import type { RenderRule } from "markdown-it/lib/renderer.mjs" +import container from "markdown-it-container" +/** @copy vuejs/vitepress/src/node/markdown/plugins/containers.ts */ +type ContainerArgs = [typeof container, string, { render: RenderRule }] + +export function createContainer( + klass: string, + defaultTitle: string, + getMd: () => MarkdownIt, +): ContainerArgs { + return [ + container, + klass, + { + render(tokens, idx, _options, env: { references?: any }) { + const md = getMd() + const token = tokens[idx] + const info = token.info.trim().slice(klass.length).trim() + const attrs = md.renderer.renderAttrs(token) + if (token.nesting === 1) { + const title = md.renderInline(info || defaultTitle, { + references: env.references, + }) + if (klass === "details") { return `
${title}\n` } + return `

${title}

\n` + } else { return klass === "details" ? `
\n` : `
\n` } + }, + }, + ] +} diff --git a/src/renderer/src/components/ui/modal/stacked/atom.ts b/src/renderer/src/components/ui/modal/stacked/atom.ts new file mode 100644 index 000000000..63887799e --- /dev/null +++ b/src/renderer/src/components/ui/modal/stacked/atom.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai" + +import type { ModalProps } from "./types" + +export const modalStackAtom = atom([] as (ModalProps & { id: string })[]) diff --git a/src/renderer/src/components/ui/modal/stacked/constants.ts b/src/renderer/src/components/ui/modal/stacked/constants.ts index 77eda61f8..631a9b304 100644 --- a/src/renderer/src/components/ui/modal/stacked/constants.ts +++ b/src/renderer/src/components/ui/modal/stacked/constants.ts @@ -17,3 +17,5 @@ export const modalMontionConfig = { exit: initialStyle, transition: microReboundPreset, } + +export const MODAL_STACK_Z_INDEX = 100 diff --git a/src/renderer/src/components/ui/modal/stacked/context.tsx b/src/renderer/src/components/ui/modal/stacked/context.tsx index 19fa84e83..15ed315e4 100644 --- a/src/renderer/src/components/ui/modal/stacked/context.tsx +++ b/src/renderer/src/components/ui/modal/stacked/context.tsx @@ -1,10 +1,5 @@ -import { atom } from "jotai" import type { FC, RefObject } from "react" -import { createContext, useContext } from "react" - -import type { ModalProps } from "./types" - -export const modalIdToPropsMap = {} as Record +import { createContext } from "react" export type CurrentModalContentProps = ModalContentPropsInternal & { ref: RefObject @@ -14,10 +9,7 @@ export const CurrentModalContext = createContext( null as any, ) -export const useCurrentModal = () => useContext(CurrentModalContext) - export type ModalContentComponent = FC export type ModalContentPropsInternal = { dismiss: () => void } -export const modalStackAtom = atom([] as (ModalProps & { id: string })[]) diff --git a/src/renderer/src/components/ui/modal/stacked/declarative-modal.tsx b/src/renderer/src/components/ui/modal/stacked/declarative-modal.tsx index 2d9da9d7b..2ff00e17e 100644 --- a/src/renderer/src/components/ui/modal/stacked/declarative-modal.tsx +++ b/src/renderer/src/components/ui/modal/stacked/declarative-modal.tsx @@ -4,8 +4,10 @@ import { AnimatePresence } from "framer-motion" import type { FC, ReactNode } from "react" import { useId, useMemo } from "react" -import { modalStackAtom } from "./context" +import { modalStackAtom } from "./atom" +import { MODAL_STACK_Z_INDEX } from "./constants" import { ModalInternal } from "./modal" +import { ModalOverlay } from "./overlay" import type { ModalProps } from "./types" export interface DeclarativeModalProps extends Omit { @@ -37,18 +39,19 @@ const DeclarativeModalImpl: FC = ({ return ( {open && ( - - {children} - + <> + + {children} + + + )} ) } const FooterAction: Component = ({ children, className }) => ( -
+
{children}
) diff --git a/src/renderer/src/components/ui/modal/stacked/hooks.tsx b/src/renderer/src/components/ui/modal/stacked/hooks.tsx index eb4c7b155..8af9a1290 100644 --- a/src/renderer/src/components/ui/modal/stacked/hooks.tsx +++ b/src/renderer/src/components/ui/modal/stacked/hooks.tsx @@ -1,11 +1,13 @@ import { jotaiStore } from "@renderer/lib/jotai" import { useUIStore } from "@renderer/store" -import { useCallback, useEffect, useId, useRef } from "react" +import { useCallback, useContext, useEffect, useId, useRef } from "react" import { useLocation } from "react-router-dom" -import { modalIdToPropsMap, modalStackAtom } from "./context" +import { modalStackAtom } from "./atom" +import { CurrentModalContext } from "./context" import type { ModalProps, ModalStackOptions } from "./types" +export const modalIdToPropsMap = {} as Record export const useModalStack = (options?: ModalStackOptions) => { const id = useId() const currentCount = useRef(0) @@ -77,3 +79,5 @@ export const useDismissAllWhenRouterChange = () => { actions.dismissAll() }, [pathname]) } + +export const useCurrentModal = () => useContext(CurrentModalContext) diff --git a/src/renderer/src/components/ui/modal/stacked/index.ts b/src/renderer/src/components/ui/modal/stacked/index.ts index 6a1d3f5b9..d1b95dde1 100644 --- a/src/renderer/src/components/ui/modal/stacked/index.ts +++ b/src/renderer/src/components/ui/modal/stacked/index.ts @@ -1,5 +1,6 @@ export * from "./context" export * from "./helper" +export * from "./hooks" export * from "./modal" export * from "./provider" export * from "./types" diff --git a/src/renderer/src/components/ui/modal/stacked/modal.tsx b/src/renderer/src/components/ui/modal/stacked/modal.tsx index 1e37022a7..f19317c5f 100644 --- a/src/renderer/src/components/ui/modal/stacked/modal.tsx +++ b/src/renderer/src/components/ui/modal/stacked/modal.tsx @@ -15,36 +15,17 @@ import { useRef, } from "react" import { useEventCallback } from "usehooks-ts" -import { useShallow } from "zustand/react/shallow" import { Divider } from "../../divider" -import { modalMontionConfig } from "./constants" +import { modalStackAtom } from "./atom" +import { MODAL_STACK_Z_INDEX, modalMontionConfig } from "./constants" import type { CurrentModalContentProps, ModalContentPropsInternal, } from "./context" -import { CurrentModalContext, modalStackAtom } from "./context" +import { CurrentModalContext } from "./context" import type { ModalProps } from "./types" -const DialogOverlay = ({ - onClick, - zIndex, -}: { - onClick?: () => void - zIndex?: number -}) => ( - - - -) - export const ModalInternal: Component<{ item: ModalProps & { id: string } index: number @@ -73,12 +54,13 @@ export const ModalInternal: Component<{ [close], ) - const { opaque, overlay: defaultOverlay } = useUIStore( - useShallow((state) => ({ - overlay: state.modalOverlay, - opaque: state.modalOpaque, - })), - ) + // const { opaque, overlay: defaultOverlay } = useUIStore( + // useShallow((state) => ({ + // overlay: state.modalOverlay, + // opaque: state.modalOpaque, + // })), + // ) + const opaque = useUIStore((state) => state.modalOpaque) const { CustomModalComponent, @@ -90,10 +72,10 @@ export const ModalInternal: Component<{ wrapper: Wrapper = Fragment, max, icon, - overlay = defaultOverlay, + draggable = false, } = item - const modalStyle = useMemo(() => ({ zIndex: 99 + index }), [index]) + const zIndexStyle = useMemo(() => ({ zIndex: MODAL_STACK_Z_INDEX + index }), [index]) const dismiss = useCallback( (e: SyntheticEvent) => { e.stopPropagation() @@ -180,8 +162,6 @@ export const ModalInternal: Component<{ - {overlay && } - {title}
) } + return ( - {overlay && } -
{title} - +
@@ -265,6 +246,7 @@ export const ModalInternal: Component<{
{finalChildren}
+
diff --git a/src/renderer/src/components/ui/modal/stacked/overlay.tsx b/src/renderer/src/components/ui/modal/stacked/overlay.tsx new file mode 100644 index 000000000..6da6582b9 --- /dev/null +++ b/src/renderer/src/components/ui/modal/stacked/overlay.tsx @@ -0,0 +1,28 @@ +import { useUIStore } from "@renderer/store" +import { m } from "framer-motion" + +import { RootPortal } from "../../portal" + +export const ModalOverlay = ({ + onClick, + zIndex, +}: { + onClick?: () => void + zIndex?: number +}) => { + const modalSettingOverlay = useUIStore((state) => state.modalOverlay) + if (!modalSettingOverlay) return null + return ( + + + + ) +} diff --git a/src/renderer/src/components/ui/modal/stacked/provider.tsx b/src/renderer/src/components/ui/modal/stacked/provider.tsx index f97508e30..c9e581742 100644 --- a/src/renderer/src/components/ui/modal/stacked/provider.tsx +++ b/src/renderer/src/components/ui/modal/stacked/provider.tsx @@ -2,9 +2,11 @@ import { AnimatePresence } from "framer-motion" import { useAtomValue } from "jotai" import type { FC, PropsWithChildren } from "react" -import { modalStackAtom } from "./context" -import { useDismissAllWhenRouterChange } from "./hooks" +import { modalStackAtom } from "./atom" +import { MODAL_STACK_Z_INDEX } from "./constants" +// import { useDismissAllWhenRouterChange } from "./hooks" import { ModalInternal } from "./modal" +import { ModalOverlay } from "./overlay" export const ModalStackProvider: FC = ({ children }) => ( <> @@ -16,7 +18,8 @@ export const ModalStackProvider: FC = ({ children }) => ( const ModalStack = () => { const stack = useAtomValue(modalStackAtom) - useDismissAllWhenRouterChange() + // Vite HMR issue + // useDismissAllWhenRouterChange() return ( @@ -28,6 +31,7 @@ const ModalStack = () => { isTop={index === stack.length - 1} /> ))} + {stack.length > 0 && } ) } diff --git a/src/renderer/src/components/ui/select.tsx b/src/renderer/src/components/ui/select.tsx index a724da61a..45d54088f 100644 --- a/src/renderer/src/components/ui/select.tsx +++ b/src/renderer/src/components/ui/select.tsx @@ -76,7 +76,7 @@ const SelectContent = React.forwardRef< { @@ -27,10 +28,17 @@ export const useRouteFeedId = () => { return feedId } -export const useRouteParms = () => { - const params = useParams() - const [search] = useSearchParams() - const view = useRouteView() +const parseRouteParams = ( + params: Params, + search: URLSearchParams, +) => { + const _view = search.get("view") + + const view = ( + (_view && FeedViewTypeValues.includes(_view) ? + +_view : + FeedViewType.Articles) || FeedViewType.Articles + ) let feedId: string | number = params.feedId! @@ -47,6 +55,13 @@ export const useRouteParms = () => { category: search.get("category") || undefined, } } + +export const useRouteParms = () => { + const params = useParams() + const [search] = useSearchParams() + + return parseRouteParams(params, search) +} const noop = [] as any[] export const useRouteParamsSelector = ( selector: (params: { @@ -61,25 +76,11 @@ export const useRouteParamsSelector = ( useReadonlyRouteSelector((route) => { const { searchParams, params } = route - let feedId: string | number = params.feedId! - - // If feedId is a number, it's a FeedViewType - if (feedId && FeedViewTypeValues.includes(feedId as string)) { - feedId = Number.parseInt(feedId as string) - } - - const view = searchParams.get("view") - - const finalView = - (view && FeedViewTypeValues.includes(view) ? - +view : - FeedViewType.Articles) || FeedViewType.Articles - - return selector({ - entryId: params.entryId || undefined, - feedId: params.feedId || undefined, - level: searchParams.get("level") || undefined, - category: searchParams.get("category") || undefined, - view: finalView, - }) + return selector(parseRouteParams(params, searchParams)) }, deps) + +export const getRouteParams = () => { + const route = getReadonlyRoute() + const { searchParams, params } = route + return parseRouteParams(params, searchParams) +} diff --git a/src/renderer/src/lib/path-parser.test.ts b/src/renderer/src/lib/path-parser.test.ts new file mode 100644 index 000000000..714946fc3 --- /dev/null +++ b/src/renderer/src/lib/path-parser.test.ts @@ -0,0 +1,347 @@ +import { compile, pathToRegexp } from "path-to-regexp" +import { describe, expect, test } from "vitest" + +import { MissingOptionalParamError, MissingRequiredParamError, parseFullPathParams, parseRegexpPathParams, regexpPathToPath, transformUriPath } from "./path-parser" + +describe("test `transformUriPath()`", () => { + test("normal path", () => { + expect(transformUriPath("/issues/all")).toMatchInlineSnapshot( + `"/issues/all"`, + ) + }) + + test("has tailing optional params", () => { + const path = transformUriPath("/issues/:id?") + expect(path).toMatchInlineSnapshot(`"/issues{/:id}?"`) + expect(compile(path)({})).toMatchInlineSnapshot(`"/issues"`) + const regexp = pathToRegexp(path) + expect(regexp.keys).toMatchInlineSnapshot(` + [ + { + "modifier": "?", + "name": "id", + "pattern": undefined, + "prefix": "/", + "separator": "/", + "suffix": "", + }, + ] + `) + }) + + test("has tailing optional params and with value", () => { + const path = transformUriPath("/issues/:id?") + + expect( + compile(path)({ + id: "1111111", + }), + ).toMatchInlineSnapshot(`"/issues/1111111"`) + const regexp = pathToRegexp(path) + expect(regexp.keys).toMatchInlineSnapshot(` + [ + { + "modifier": "?", + "name": "id", + "pattern": undefined, + "prefix": "/", + "separator": "/", + "suffix": "", + }, + ] + `) + }) + + test("has many optional params", () => { + const path = transformUriPath("/issue/:user/:repo/:state?/:labels?") + expect(path).toMatchInlineSnapshot( + `"/issue{/:user}{/:repo}{/:state}?{/:labels}?"`, + ) + expect( + compile(path)({ + user: "user", + repo: "repo", + }), + ).toMatchInlineSnapshot(`"/issue/user/repo"`) + expect(pathToRegexp(path).keys).toMatchInlineSnapshot(` + [ + { + "modifier": "", + "name": "user", + "pattern": undefined, + "prefix": "/", + "separator": "/", + "suffix": "", + }, + { + "modifier": "", + "name": "repo", + "pattern": undefined, + "prefix": "/", + "separator": "/", + "suffix": "", + }, + { + "modifier": "?", + "name": "state", + "pattern": undefined, + "prefix": "/", + "separator": "/", + "suffix": "", + }, + { + "modifier": "?", + "name": "labels", + "pattern": undefined, + "prefix": "/", + "separator": "/", + "suffix": "", + }, + ] + `) + }) + + test("catch all path", () => { + const path = transformUriPath("/:path{.+}?") + expect(path).toMatchInlineSnapshot(`"{/:path}*"`) + expect( + compile(path)({ + path: ["a", "b", "c"], + }), + ).toMatchInlineSnapshot(`"/a/b/c"`) + }) + + test("catch all path with leading route", () => { + const path = transformUriPath("/issues/:path{.+}?") + expect(path).toMatchInlineSnapshot(`"/issues{/:path}*"`) + expect( + compile(path)({ + path: ["a", "b", "c"], + }), + ).toMatchInlineSnapshot(`"/issues/a/b/c"`) + }) + + test("catch all path but value is query search string", () => { + const path = transformUriPath("/issues/:path{.+}?") + expect(path).toMatchInlineSnapshot(`"/issues{/:path}*"`) + expect( + compile(path)({ + path: ["a=1&b=2"], + }), + ).toMatchInlineSnapshot(`"/issues/a%3D1%26b%3D2"`) + }) + + test("catch all route via `*`", () => { + expect(transformUriPath("/issues/*")).toMatchInlineSnapshot(`"/issues{/:__catchAll__}"`) + expect(transformUriPath("*")).toMatchInlineSnapshot(`"{/:__catchAll__}"`) + }) +}) + +describe("test `regexpPathToPath()`", () => { + test("normal path", () => { + expect(regexpPathToPath("/issues/all", {})).toMatchInlineSnapshot( + `"/issues/all"`, + ) + }) + test("path with optional params", () => { + expect(regexpPathToPath("/issues/:id?", {})).toMatchInlineSnapshot( + `"/issues"`, + ) + expect(regexpPathToPath("/issues/:id?", { id: "1" })).toMatchInlineSnapshot( + `"/issues/1"`, + ) + }) + + test("path with many optional params", () => { + expect( + regexpPathToPath("/issue/:user/:repo/:state?/:labels?", { + user: "rssnext", + repo: "follow", + }), + ).toMatchInlineSnapshot(`"/issue/rssnext/follow"`) + }) + + test("path with many optional params, but when using the optional parameter(s) after the optional parameter(s), the previous optional parameter(s) is/are not filled in.", () => { + expect( + () => regexpPathToPath("/issue/:user/:repo/:state?/:labels?", { + user: "rssnext", + repo: "follow", + labels: "rss", + }), + ).toThrowError(MissingOptionalParamError) + }) + test("path with many optional params, but when using the optional parameter(s) after the optional parameter(s), the previous optional parameter(s) is/are not filled in.", () => { + expect( + () => regexpPathToPath("/ranking/:rid?/:day?/:arc_type?/:disableEmbed?", { + day: "1", + }), + ).toThrowError(MissingOptionalParamError) + }) + + test("missing required param", () => { + expect(() => regexpPathToPath("/issue/:user/:repo/:state?/:labels?", {})).toThrow(MissingRequiredParamError) + }) + + test("catch all path", () => { + expect(regexpPathToPath("*", { + catchAll: "a/b/c", + })).toMatchInlineSnapshot(`"/a%2Fb%2Fc"`) + }) + + test("path with many optional params and all inputted", () => { + expect(regexpPathToPath("/issue/:user/:repo/:state?/:labels?", { + user: "rssnext", + repo: "follow", + state: "open", + labels: "rss", + })).toMatchInlineSnapshot(`"/issue/rssnext/follow/open/rss"`) + }) +}) + +describe("test `parseRegexpPathParams()`", () => { + test("normal path", () => { + expect(parseRegexpPathParams("/issues/all")).toMatchInlineSnapshot(` + { + "array": [], + "length": 0, + "map": {}, + } + `) + }) + + test("path with optional params", () => { + expect(parseRegexpPathParams("/issues/:id?")).toMatchInlineSnapshot(` + { + "array": [ + { + "isCatchAll": false, + "name": "id", + "optional": true, + }, + ], + "length": 1, + "map": { + "id": { + "isCatchAll": false, + "name": "id", + "optional": true, + }, + }, + } + `) + }) + + test("path with many optional params", () => { + expect(parseRegexpPathParams("/issue/:user/:repo/:state?/:labels?")).toMatchInlineSnapshot(` + { + "array": [ + { + "isCatchAll": false, + "name": "user", + "optional": false, + }, + { + "isCatchAll": false, + "name": "repo", + "optional": false, + }, + { + "isCatchAll": false, + "name": "state", + "optional": true, + }, + { + "isCatchAll": false, + "name": "labels", + "optional": true, + }, + ], + "length": 4, + "map": { + "labels": { + "isCatchAll": false, + "name": "labels", + "optional": true, + }, + "repo": { + "isCatchAll": false, + "name": "repo", + "optional": false, + }, + "state": { + "isCatchAll": false, + "name": "state", + "optional": true, + }, + "user": { + "isCatchAll": false, + "name": "user", + "optional": false, + }, + }, + } + `) + }) + + test("catch all path", () => { + expect(parseRegexpPathParams("/:path{.+}?")).toMatchInlineSnapshot(` + { + "array": [ + { + "isCatchAll": true, + "name": "path", + "optional": true, + }, + ], + "length": 1, + "map": { + "path": { + "isCatchAll": true, + "name": "path", + "optional": true, + }, + }, + } + `) + }) + + test("catch all path via `*`", () => { + expect(parseRegexpPathParams("*")).toMatchInlineSnapshot(` + { + "array": [ + { + "isCatchAll": true, + "name": "__catchAll__", + "optional": false, + }, + ], + "length": 1, + "map": { + "__catchAll__": { + "isCatchAll": true, + "name": "__catchAll__", + "optional": false, + }, + }, + } + `) + }) +}) + +describe("test `parseFullPathParams()`", () => { + test("case 1", () => { + expect(parseFullPathParams("/user/123344", "/user/:id")).toMatchInlineSnapshot(` + { + "id": "123344", + } + `) + }) + test("case 2", () => { + expect(parseFullPathParams("/build/wangqiru/ttrss", "/build/:user/:name")).toMatchInlineSnapshot(` + { + "name": "ttrss", + "user": "wangqiru", + } + `) + }) +}) diff --git a/src/renderer/src/lib/path-parser.ts b/src/renderer/src/lib/path-parser.ts new file mode 100644 index 000000000..4b3ff1d26 --- /dev/null +++ b/src/renderer/src/lib/path-parser.ts @@ -0,0 +1,122 @@ +import type { CompileOptions } from "path-to-regexp" +import { compile, pathToRegexp } from "path-to-regexp" + +const CATCH_ALL_GROUP_KEY = "__catchAll__" +export function transformUriPath(uri: string): string { + let result = "" + const parts = uri.split("/") + + for (const part of parts) { + if (!part) continue + if (part.startsWith(":")) { + if (part.includes("{") && part.includes("}")) { + result += `{/:${part.slice(1, part.indexOf("{"))}}*` + } else if (part.endsWith("?")) { + result += `{/:${part.slice(1, -1)}}?` + } else { + result += `{/:${part.slice(1)}}` + } + } else if (part === "*") { + result += `{/:${CATCH_ALL_GROUP_KEY}}` + } else { + result += `/${part}` + } + } + return result +} + +export class MissingOptionalParamError extends Error { + constructor(public param: string) { + super(`You used the optional parameter ${param}, but there are other optional parameters before the optional parameter ${param} that you did not fill in.`) + } +} + +export class MissingRequiredParamError extends Error { + constructor(param: string) { + super(`Missing required param ${param}`) + } +} +export const regexpPathToPath = ( + regexpPath: string, + params: { + catchAll?: string + [key: string]: string | string[] | undefined + }, + options?: Omit, +): string => { + const transformedPath = transformUriPath(regexpPath) + + const paramsKeys = pathToRegexp(transformedPath).keys + const inputtedKeys = new Set(Object.keys(params)) + let prevKeyIsOptional = false + let prevKeyIsInputted = false + for (const key of paramsKeys) { + if (key.name === CATCH_ALL_GROUP_KEY) { + params[CATCH_ALL_GROUP_KEY] = params.catchAll + break + } + const isOptional = key.modifier === "?" + const userInputted = inputtedKeys.has(key.name) + if (!isOptional && !userInputted) { + throw new MissingRequiredParamError(key.name) + } + + if (prevKeyIsOptional && !isOptional) { + throw new Error("Required param after optional param") + } + + if (prevKeyIsOptional && !prevKeyIsInputted && userInputted) { + throw new MissingOptionalParamError(key.name) + } + + prevKeyIsOptional = isOptional + prevKeyIsInputted = userInputted + } + return compile(transformedPath, { + validate: false, + + ...options, + })(params) +} + +export type PathParams = { + name: string + optional: boolean + isCatchAll: boolean + +} +export const parseRegexpPathParams = (regexpPath: string) => { + const transformedPath = transformUriPath(regexpPath) + const array: PathParams[] = [] + + for (const item of pathToRegexp(transformedPath).keys) { + array.push({ + name: item.name, + optional: item.modifier === "?" || item.modifier === "*", + isCatchAll: item.name === CATCH_ALL_GROUP_KEY || item.modifier === "*", + }) + } + + const map = array.reduce((acc, { name, optional, isCatchAll }) => { + acc[name] = { name, optional, isCatchAll } + return acc + }, {}) as Record + return { + array, + map, + length: array.length, + } +} +export const parseFullPathParams = (path: string, regexpPath: string) => { + // path: /user/123344 + // regexpPath: /user/:id + // return {id: '123344'} + const transformedPath = transformUriPath(regexpPath) + const { keys } = pathToRegexp(transformedPath) + const result = pathToRegexp(transformedPath).exec(path) + if (!result) return {} + return keys.reduce((acc, key, index) => { + acc[key.name] = result[index + 1] + return acc + }, {} as Record) +} diff --git a/src/renderer/src/modules/discover/content-components.tsx b/src/renderer/src/modules/discover/content-components.tsx new file mode 100644 index 000000000..486bfcd0e --- /dev/null +++ b/src/renderer/src/modules/discover/content-components.tsx @@ -0,0 +1,236 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { getSidebarActiveView } from "@renderer/atoms/sidebar" +import { StyledButton } from "@renderer/components/ui/button" +import { CopyButton } from "@renderer/components/ui/code-highlighter" +import { Form, FormItem, FormLabel } from "@renderer/components/ui/form" +import { Input } from "@renderer/components/ui/input" +import { Markdown } from "@renderer/components/ui/markdown" +import { useModalStack } from "@renderer/components/ui/modal" +import type { FeedViewType } from "@renderer/lib/enum" +import { + MissingOptionalParamError, + parseFullPathParams, + parseRegexpPathParams, + regexpPathToPath, +} from "@renderer/lib/path-parser" +import type { FC } from "react" +import { useCallback, useLayoutEffect, useMemo, useRef } from "react" +import type { UseFormReturn } from "react-hook-form" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { FeedForm } from "./feed-form" +import type { RSSHubRoute } from "./types" +import { normalizeRSSHubParameters } from "./utils" + +const FeedMaintainers = ({ maintainers }: { maintainers?: string[] }) => { + if (!maintainers || maintainers.length === 0) { + return null + } + + return ( +
+

+ The maintainers of this feed are as follows. If you have any questions + or suggestions, you can contact them. +

+ +
+ ) +} + +export const FeedDescription = ({ description }: { description?: string }) => { + if (!description) { + return

This feed does not have a description.

+ } + + return ( + <> +

+ The description of this feed is as follows, and you can fill out the + parameter form with the relevant information. +

+ + {description} + + + ) +} + +export const DiscoverFeedForm = ({ + route, + routePrefix, +}: { + route: RSSHubRoute + routePrefix +}) => { + const keys = parseRegexpPathParams(route.path) + + const formPlaceholder = useMemo>(() => { + if (!route.example) return {} + return parseFullPathParams( + route.example.replace(`/${routePrefix}`, ""), + route.path, + ) + }, [route.example, route.path, routePrefix]) + const dynamicFormSchema = useMemo( + () => + z.object({ + ...Object.fromEntries( + keys.array.map((keyItem) => [ + keyItem.name, + keyItem.optional ? z.string().optional() : z.string().min(1), + ]), + ), + }), + [keys], + ) + const form = useForm>({ + resolver: zodResolver(dynamicFormSchema), + defaultValues: {}, + mode: "all", + }) as UseFormReturn + + const { present, dismissAll } = useModalStack() + + const onSubmit = useCallback( + (data) => { + try { + // Delete empty string values + const nextData = { ...data } + for (const key in nextData) { + if (nextData[key] === "") { + delete nextData[key] + } + } + + const fillRegexpPath = regexpPathToPath(route.path, nextData) + const url = `rsshub://${routePrefix}${fillRegexpPath}` + const defaultView = getSidebarActiveView() as FeedViewType + + present({ + title: "Add follow", + content: () => ( + + ), + }) + } catch (err: unknown) { + if (err instanceof MissingOptionalParamError) { + toast.error(err.message) + const idx = keys.array.findIndex((item) => item.name === err.param) + + form.setFocus(keys.array[idx === 0 ? 0 : idx - 1].name) + } + } + }, + [dismissAll, form, keys.array, present, route.path, routePrefix], + ) + + const formElRef = useRef(null) + + useLayoutEffect(() => { + const $form = formElRef.current + if (!$form) return + $form.querySelectorAll("input")[0]?.focus() + }, [formElRef]) + return ( +
+ + {keys.array.map((keyItem) => { + const parameters = normalizeRSSHubParameters( + route.parameters[keyItem.name], + ) + + return ( + + + {keyItem.name} + {!keyItem.optional && ( + * + )} + + + {!!parameters && ( +

+ {parameters.description} +

+ )} +
+ ) + })} + + + + + +
+ + Preview + +
+ + + ) +} + +const PreviewUrl: FC<{ + watch: UseFormReturn["watch"] + path: string + routePrefix: string +}> = ({ watch, path, routePrefix }) => { + const data = watch() + + const fullPath = useMemo(() => { + try { + return regexpPathToPath(path, data) + } catch (err: unknown) { + console.info((err as Error).message) + return path + } + }, [path, data]) + + const renderedPath = `${routePrefix}${fullPath}` + return ( +
+
+        {renderedPath}
+      
+ +
+ ) +} diff --git a/src/renderer/src/modules/discover/example-data.json b/src/renderer/src/modules/discover/example-data.json new file mode 100644 index 000000000..301c95681 --- /dev/null +++ b/src/renderer/src/modules/discover/example-data.json @@ -0,0 +1,1017 @@ +{ + "data": { + "apnews": { + "routes": { + "/rss/:rss?": { + "path": "/rss/:rss?", + "categories": [ + "traditional-media", + "popular" + ], + "example": "/apnews/rss/business", + "parameters": { + "rss": "Route name from the first segment of the corresponding site, or `index` for the front page(default)." + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "apnews.com/:rss" + ], + "target": "/rss/:rss" + } + ], + "name": "RSS", + "maintainers": [ + "zoenglinghou", + "mjysci", + "TonyRL" + ], + "location": "rss.ts" + } + }, + "name": "AP News", + "url": "apnews.com" + }, + "bilibili": { + "routes": { + "/user/dynamic/:uid/:routeParams?": { + "path": "/user/dynamic/:uid/:routeParams?", + "categories": [ + "social-media", + "popular" + ], + "example": "/bilibili/user/dynamic/2267573", + "parameters": { + "uid": "用户 id, 可在 UP 主主页中找到", + "routeParams": "额外参数;请参阅以下说明和表格" + }, + "features": { + "requireConfig": [ + { + "name": "BILIBILI_COOKIE_*", + "optional": true, + "description": "如果没有此配置,那么必须开启 puppeteer 支持;BILIBILI_COOKIE_{uid}: 用于用户关注动态系列路由,对应 uid 的 b 站用户登录后的 Cookie 值,`{uid}` 替换为 uid,如 `BILIBILI_COOKIE_2267573`,获取方式:\n1. 打开 [https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8](https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/dynamic_new?uid=0&type=8)\n2. 打开控制台,切换到 Network 面板,刷新\n3. 点击 dynamic_new 请求,找到 Cookie\n4. 视频和专栏,UP 主粉丝及关注只要求 `SESSDATA` 字段,动态需复制整段 Cookie" + } + ], + "requirePuppeteer": false, + "antiCrawler": true, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "space.bilibili.com/:uid" + ], + "target": "/user/dynamic/:uid" + } + ], + "name": "UP 主动态", + "maintainers": [ + "DIYgod", + "zytomorrow", + "CaoMeiYouRen", + "JimenezLi" + ], + "description": "| 键 | 含义 | 接受的值 | 默认值 |\n | ------------ | --------------------------------- | -------------- | ------ |\n | showEmoji | 显示或隐藏表情图片 | 0/1/true/false | false |\n | disableEmbed | 关闭内嵌视频 | 0/1/true/false | false |\n | useAvid | 视频链接使用 AV 号 (默认为 BV 号) | 0/1/true/false | false |\n | directLink | 使用内容直链 | 0/1/true/false | false |\n\n 用例:`/bilibili/user/dynamic/2267573/showEmoji=1&disableEmbed=1&useAvid=1`\n\n :::tip 动态的专栏显示全文\n 动态的专栏显示全文请使用通用参数里的 `mode=fulltext`\n\n 举例:bilibili 专栏全文输出 /bilibili/user/dynamic/2267573/?mode=fulltext\n :::", + "location": "dynamic.ts" + }, + "/ranking/:rid?/:day?/:arc_type?/:disableEmbed?": { + "path": "/ranking/:rid?/:day?/:arc_type?/:disableEmbed?", + "name": "排行榜", + "maintainers": [ + "DIYgod" + ], + "categories": [ + "social-media", + "popular" + ], + "example": "/bilibili/ranking/0/3/1", + "parameters": { + "rid": "排行榜分区 id, 默认 0", + "day": "时间跨度,可为 1 3 7 30", + "arc_type": "投稿时间,可为 0(全部投稿) 1(近期投稿) , 默认 1", + "disableEmbed": "默认为开启内嵌视频,任意值为关闭" + }, + "description": "| 全站 | 动画 | 国创相关 | 音乐 | 舞蹈 | 游戏 | 科技 | 数码 | 生活 | 鬼畜 | 时尚 | 娱乐 | 影视 |\n| ---- | ---- | -------- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |\n| 0 | 1 | 168 | 3 | 129 | 4 | 36 | 188 | 160 | 119 | 155 | 5 | 181 |", + "location": "ranking.ts" + }, + "/user/video/:uid/:disableEmbed?": { + "path": "/user/video/:uid/:disableEmbed?", + "categories": [ + "social-media", + "popular" + ], + "example": "/bilibili/user/video/2267573", + "parameters": { + "uid": "用户 id, 可在 UP 主主页中找到", + "disableEmbed": "默认为开启内嵌视频,任意值为关闭" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": true, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "space.bilibili.com/:uid" + ], + "target": "/user/video/:uid" + } + ], + "name": "UP 主投稿", + "maintainers": [ + "DIYgod" + ], + "description": ":::tip 动态的专栏显示全文\n 可以使用 [UP 主动态](#bilibili-up-zhu-dong-tai) 路由作为代替绕过反爬限制\n :::", + "location": "video.ts" + } + }, + "name": "Bilibili", + "url": "www.bilibili.com" + }, + "dockerhub": { + "routes": { + "/build/:owner/:image/:tag?": { + "path": "/build/:owner/:image/:tag?", + "categories": [ + "program-update", + "popular" + ], + "example": "/dockerhub/build/wangqiru/ttrss", + "parameters": { + "owner": "Image owner", + "image": "Image name", + "tag": "Image tag, default to latest" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "name": "Image New Build", + "maintainers": [ + "HenryQW" + ], + "description": ":::warning\n The owner of the official image fills in the library, for example: [https://rsshub.app/dockerhub/build/library/mysql](https://rsshub.app/dockerhub/build/library/mysql)\n :::", + "location": "build.ts" + } + }, + "name": "Docker Hub", + "url": "hub.docker.com" + }, + "douban": { + "routes": { + "/group/:groupid/:type?": { + "path": "/group/:groupid/:type?", + "categories": [ + "social-media", + "popular" + ], + "example": "/douban/group/648102", + "parameters": { + "groupid": "豆瓣小组的 id", + "type": "缺省 最新,essence 最热,elite 精华" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "www.douban.com/group/:groupid" + ], + "target": "/group/:groupid" + } + ], + "name": "豆瓣小组", + "maintainers": [ + "DIYgod" + ], + "location": "other/group.ts" + } + }, + "name": "豆瓣", + "url": "www.douban.com" + }, + "github": { + "routes": { + "/issue/:user/:repo/:state?/:labels?": { + "path": "/issue/:user/:repo/:state?/:labels?", + "categories": [ + "programming", + "popular" + ], + "example": "/github/issue/vuejs/core/all/wontfix", + "parameters": { + "user": "GitHub username", + "repo": "GitHub repo name", + "state": "the state of the issues. Can be either `open`, `closed`, or `all`. Default: `open`.", + "labels": "a list of comma separated label names" + }, + "radar": [ + { + "source": [ + "github.com/:user/:repo/issues", + "github.com/:user/:repo/issues/:id", + "github.com/:user/:repo" + ], + "target": "/issue/:user/:repo" + } + ], + "name": "Repo Issues", + "maintainers": [ + "HenryQW", + "AndreyMZ" + ], + "location": "issue.ts" + } + }, + "name": "GitHub", + "url": "github.com", + "description": ":::tip\nGitHub provides some official RSS feeds:\n\n- Repo releases: `https://github.com/:owner/:repo/releases.atom`\n- Repo commits: `https://github.com/:owner/:repo/commits.atom`\n- User activities: `https://github.com/:user.atom`\n- Private feed: `https://github.com/:user.private.atom?token=:secret` (You can find **Subscribe to your news feed** in [dashboard](https://github.com) page after login)\n- Wiki history: `https://github.com/:owner/:repo/wiki.atom`\n:::" + }, + "instagram": { + "routes": { + "/:category/:key": { + "path": "/:category/:key", + "categories": [ + "social-media", + "popular" + ], + "example": "/instagram/user/stefaniejoosten", + "parameters": { + "category": "Feed category, see table above", + "key": "Username / Hashtag name" + }, + "features": { + "requireConfig": [ + { + "name": "IG_PROXY", + "optional": true, + "description": "" + } + ], + "requirePuppeteer": false, + "antiCrawler": true, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "name": "User Profile / Hashtag - Private API", + "maintainers": [ + "oppilate", + "DIYgod" + ], + "description": ":::warning\nDue to [Instagram Private API](https://github.com/dilame/instagram-private-api) restrictions, you have to setup your credentials on the server. 2FA is not supported. See [deployment guide](https://docs.rsshub.app/deploy/) for more.\n:::", + "location": "private-api/index.ts" + } + }, + "name": "Instagram", + "url": "www.instagram.com", + "description": ":::tip\nIt's highly recommended to deploy with Redis cache enabled.\n:::" + }, + "javbus": { + "routes": { + "/:path{.+}?": { + "path": "/:path{.+}?", + "radar": [ + { + "source": [ + "www.javbus.com/:path*" + ], + "target": "/:path" + } + ], + "name": "Works", + "maintainers": [ + "MegrezZhu", + "CoderTonyChan", + "nczitzk", + "Felix2yu" + ], + "categories": [ + "multimedia", + "popular" + ], + "url": "www.javbus.com", + "location": "index.ts" + } + }, + "name": "JavBus", + "url": "www.javbus.com", + "description": ":::warning\nRequests from non-Asia areas will be redirected to login page.\n:::\n\n:::tip Language\nYou can change the language of each route to the languages listed below.\n\n| English | 日本语 | 한국의 | 中文 |\n| ------- | ------ | ------ | ---------------- |\n| en | ja | ko | (leave it empty) |\n:::\n\n:::tip\nJavBus has multiple backup domains, these routes use default domain `https://javbus.com`. If the domain is unreachable, you can add `?domain=` to the end of the route to specify the domain to visit. Let say you want to use the backup domain `https://javsee.icu`, you can add `?domain=javsee.icu` to the end of the route, then the route will be [`/javbus/en?domain=javsee.icu`](https://rsshub.app/javbus?domain=javsee.icu)\n\n**Note**: **Western** has different domain than the main site, the backup domains are also different. The default domain is `https://javbus.org` and you can add `?western_domain=` to the end of the route to specify the domain to visit. Let say you want to use the backup domain `https://javsee.one`, you can add `?western_domain=javsee.one` to the end of the route, then the route will be [`/javbus/western/en?western_domain=javsee.one`](https://rsshub.app/javbus/western?western_domain=javsee.one)\n:::" + }, + "jike": { + "routes": { + "/topic/:id/:showUid?": { + "path": "/topic/:id/:showUid?", + "categories": [ + "social-media", + "popular" + ], + "example": "/jike/topic/556688fae4b00c57d9dd46ee", + "parameters": { + "id": "圈子 id, 可在即刻 web 端圈子页或 APP 分享出来的圈子页 URL 中找到", + "showUid": "是否在内容中显示用户信息,设置为 1 则开启" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "web.okjike.com/topic/:id" + ], + "target": "/topic/:id" + } + ], + "name": "圈子", + "maintainers": [ + "DIYgod", + "prnake" + ], + "location": "topic.ts" + }, + "/user/:id": { + "path": "/user/:id", + "categories": [ + "social-media", + "popular" + ], + "example": "/jike/user/3EE02BC9-C5B3-4209-8750-4ED1EE0F67BB", + "parameters": { + "id": "用户 id, 可在即刻分享出来的单条动态页点击用户头像进入个人主页,然后在个人主页的 URL 中找到,或者在单条动态页使用 RSSHub Radar 插件" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "web.okjike.com/u/:uid" + ], + "target": "/user/:uid" + } + ], + "name": "用户动态", + "maintainers": [ + "DIYgod", + "prnake" + ], + "location": "user.ts" + } + }, + "name": "即刻", + "url": "m.okjike.com" + }, + "lofter": { + "routes": { + "/user/:name?": { + "path": "/user/:name?", + "categories": [ + "social-media", + "popular" + ], + "example": "/lofter/user/i", + "parameters": { + "name": "Lofter user name, can be found in the URL" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "name": "User", + "maintainers": [ + "hondajojo", + "nczitzk", + "LucunJi" + ], + "location": "user.ts" + } + }, + "name": "Lofter", + "url": "www.lofter.com" + }, + "pixiv": { + "routes": { + "/ranking/:mode/:date?": { + "path": "/ranking/:mode/:date?", + "categories": [ + "social-media", + "popular" + ], + "example": "/pixiv/ranking/week", + "parameters": { + "mode": "rank type", + "date": "format: `2018-4-25`" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "name": "Rankings", + "maintainers": [ + "EYHN" + ], + "description": "| daily rank | weekly rank | monthly rank | male rank | female rank | AI-generated work Rankings | original rank | rookie user rank |\n | ---------- | ----------- | ------------ | --------- | ----------- | -------------------------- | -------------- | ---------------- |\n | day | week | month | day_male | day_female | day_ai | week_original | week_rookie |\n\n | R-18 daily rank | R-18 AI-generated work | R-18 male rank | R-18 female rank | R-18 weekly rank | R-18G rank |\n | --------------- | ---------------------- | -------------- | ---------------- | ---------------- | ---------- |\n | day_r18 | day_r18_ai | day_male_r18 | day_female_r18 | week_r18 | week_r18g |", + "location": "ranking.ts" + }, + "/search/:keyword/:order?/:mode?": { + "path": "/search/:keyword/:order?/:mode?", + "categories": [ + "social-media", + "popular" + ], + "example": "/pixiv/search/Nezuko/popular/2", + "parameters": { + "keyword": "keyword", + "order": "rank mode, empty or other for time order, popular for popular order", + "mode": "filte R18 content" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "name": "Keyword", + "maintainers": [ + "DIYgod" + ], + "description": "| only not R18 | only R18 | no filter |\n | ------------ | -------- | -------------- |\n | safe | r18 | empty or other |", + "location": "search.ts" + }, + "/user/:id": { + "path": "/user/:id", + "categories": [ + "social-media", + "popular" + ], + "example": "/pixiv/user/15288095", + "parameters": { + "id": "user id, available in user's homepage URL" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "www.pixiv.net/users/:id" + ] + } + ], + "name": "User Activity", + "maintainers": [ + "DIYgod" + ], + "location": "user.ts" + } + }, + "name": "pixiv", + "url": "www.pixiv.net" + }, + "rsshub": { + "routes": { + "/routes/:lang?": { + "path": "/routes/:lang?", + "categories": [ + "program-update", + "popular" + ], + "example": "/rsshub/routes/en", + "parameters": { + "lang": "Language, `zh` means Chinese docs, other values or null means English docs, `en` by default" + }, + "radar": [ + { + "source": [ + "docs.rsshub.app/*" + ], + "target": "/routes" + } + ], + "name": "New routes", + "maintainers": [ + "DIYgod" + ], + "url": "docs.rsshub.app/*", + "location": "routes.ts" + } + }, + "name": "RSSHub", + "url": "docs.rsshub.app" + }, + "sehuatang": { + "routes": { + "/user/:uid": { + "path": "/user/:uid", + "categories": [ + "multimedia", + "popular" + ], + "example": "/sehuatang/user/411096", + "parameters": { + "uid": "用户 uid, 可在用户主页 URL 中找到" + }, + "features": { + "requireConfig": [ + { + "name": "SEHUATANG_COOKIE", + "description": "" + } + ], + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "name": "作者文章", + "maintainers": [ + "JamYiz" + ], + "location": "user.ts" + } + }, + "name": "色花堂", + "url": "sehuatang.net" + }, + "telegram": { + "routes": { + "/channel/:username/:routeParams?": { + "path": "/channel/:username/:routeParams?", + "categories": [ + "social-media", + "popular" + ], + "example": "/telegram/channel/awesomeDIYgod/searchQuery=twitter", + "parameters": { + "username": "channel username", + "routeParams": "extra parameters, see the table below" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "t.me/s/:username" + ], + "target": "/channel/:username" + } + ], + "name": "Channel", + "maintainers": [ + "DIYgod", + "Rongronggg9" + ], + "description": "\n | Key | Description | Accepts | Defaults to |\n | ---------------------- | --------------------------------------------------------------------- | -------------------------------------------------- | ------------ |\n | showLinkPreview | Show the link preview from Telegram | 0/1/true/false | true |\n | showViaBot | For messages sent via bot, show the bot | 0/1/true/false | true |\n | showReplyTo | For reply messages, show the target of the reply | 0/1/true/false | true |\n | showFwdFrom | For forwarded messages, show the forwarding source | 0/1/true/false | true |\n | showFwdFromAuthor | For forwarded messages, show the author of the forwarding source | 0/1/true/false | true |\n | showInlineButtons | Show inline buttons | 0/1/true/false | false |\n | showMediaTagInTitle | Show media tags in the title | 0/1/true/false | true |\n | showMediaTagAsEmoji | Show media tags as emoji | 0/1/true/false | true |\n | showHashtagAsHyperlink | Show hashtags as hyperlinks (`https://t.me/s/channel?q=%23hashtag`) | 0/1/true/false | true |\n | includeFwd | Include forwarded messages | 0/1/true/false | true |\n | includeReply | Include reply messages | 0/1/true/false | true |\n | includeServiceMsg | Include service messages (e.g. message pinned, channel photo updated) | 0/1/true/false | true |\n | includeUnsupportedMsg | Include messages unsupported by t.me | 0/1/true/false | false |\n | searchQuery | search query | keywords; replace `#hashtag` with `%23hashtag` | (no keyword) |\n\n Specify different option values than default values can meet different needs, URL\n\n ```\n https://rsshub.app/telegram/channel/NewlearnerChannel/showLinkPreview=0&showViaBot=0&showReplyTo=0&showFwdFrom=0&showFwdFromAuthor=0&showInlineButtons=0&showMediaTagInTitle=1&showMediaTagAsEmoji=1&includeFwd=0&includeReply=1&includeServiceMsg=0&includeUnsupportedMsg=0\n ```\n\n generates an RSS without any link previews and annoying metadata, with emoji media tags in the title, without forwarded messages (but with reply messages), and without messages you don't care about (service messages and unsupported messages), for people who prefer pure subscriptions.\n\n :::tip\n For backward compatibility reasons, invalid `routeParams` will be treated as `searchQuery` .\n\n Due to Telegram restrictions, some channels involving pornography, copyright, and politics cannot be subscribed. You can confirm by visiting `https://t.me/s/:username`.\n :::", + "location": "channel.ts" + } + }, + "name": "Telegram", + "url": "t.me" + }, + "twitter": { + "routes": { + "/keyword/:keyword/:routeParams?": { + "path": "/keyword/:keyword/:routeParams?", + "categories": [ + "social-media", + "popular" + ], + "example": "/twitter/keyword/RSSHub", + "parameters": { + "keyword": "keyword", + "routeParams": "extra parameters, see the table above" + }, + "features": { + "requireConfig": [ + { + "name": "TWITTER_USERNAME", + "description": "Please see above for details." + }, + { + "name": "TWITTER_PASSWORD", + "description": "Please see above for details." + }, + { + "name": "TWITTER_COOKIE", + "description": "Please see above for details." + } + ], + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "name": "Keyword", + "maintainers": [ + "DIYgod", + "yindaheng98", + "Rongronggg9" + ], + "radar": [ + { + "source": [ + "x.com/search" + ] + } + ], + "location": "keyword.ts" + }, + "/media/:id/:routeParams?": { + "path": "/media/:id/:routeParams?", + "categories": [ + "social-media", + "popular" + ], + "example": "/twitter/media/DIYgod", + "parameters": { + "id": "username; in particular, if starts with `+`, it will be recognized as a [unique ID](https://github.com/DIYgod/RSSHub/issues/12221), e.g. `+44196397`", + "routeParams": "extra parameters, see the table above." + }, + "features": { + "requireConfig": [ + { + "name": "TWITTER_USERNAME", + "description": "Please see above for details." + }, + { + "name": "TWITTER_PASSWORD", + "description": "Please see above for details." + }, + { + "name": "TWITTER_COOKIE", + "description": "Please see above for details." + } + ], + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "name": "User media", + "maintainers": [ + "DIYgod", + "yindaheng98", + "Rongronggg9" + ], + "radar": [ + { + "source": [ + "x.com/:id/media" + ], + "target": "/media/:id" + } + ], + "location": "media.ts" + }, + "/user/:id/:routeParams?": { + "path": "/user/:id/:routeParams?", + "categories": [ + "social-media", + "popular" + ], + "example": "/twitter/user/DIYgod", + "parameters": { + "id": "username; in particular, if starts with `+`, it will be recognized as a [unique ID](https://github.com/DIYgod/RSSHub/issues/12221), e.g. `+44196397`", + "routeParams": "extra parameters, see the table above; particularly when `routeParams=exclude_replies`, replies are excluded; `routeParams=exclude_rts` excludes retweets,`routeParams=exclude_rts_replies` exclude replies and retweets; for default include all." + }, + "features": { + "requireConfig": [ + { + "name": "TWITTER_USERNAME", + "description": "Please see above for details." + }, + { + "name": "TWITTER_PASSWORD", + "description": "Please see above for details." + }, + { + "name": "TWITTER_AUTHENTICATION_SECRET", + "description": "TOTP 2FA secret, please see above for details.", + "optional": true + }, + { + "name": "TWITTER_COOKIE", + "description": "Please see above for details." + } + ], + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "name": "User timeline", + "maintainers": [ + "DIYgod", + "yindaheng98", + "Rongronggg9" + ], + "radar": [ + { + "source": [ + "x.com/:id" + ], + "target": "/user/:id" + } + ], + "location": "user.ts" + } + }, + "name": "X (Twitter)", + "url": "x.com", + "description": "Specify options (in the format of query string) in parameter `routeParams` to control some extra features for Tweets\n\n| Key | Description | Accepts | Defaults to |\n| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------- | ----------------------------------------- |\n| `readable` | Enable readable layout | `0`/`1`/`true`/`false` | `false` |\n| `authorNameBold` | Display author name in bold | `0`/`1`/`true`/`false` | `false` |\n| `showAuthorInTitle` | Show author name in title | `0`/`1`/`true`/`false` | `false` (`true` in `/twitter/followings`) |\n| `showAuthorInDesc` | Show author name in description (RSS body) | `0`/`1`/`true`/`false` | `false` (`true` in `/twitter/followings`) |\n| `showQuotedAuthorAvatarInDesc` | Show avatar of quoted Tweet's author in description (RSS body) (Not recommended if your RSS reader extracts images from description) | `0`/`1`/`true`/`false` | `false` |\n| `showAuthorAvatarInDesc` | Show avatar of author in description (RSS body) (Not recommended if your RSS reader extracts images from description) | `0`/`1`/`true`/`false` | `false` |\n| `showEmojiForRetweetAndReply` | Use \"🔁\" instead of \"RT\", \"↩️\" & \"💬\" instead of \"Re\" | `0`/`1`/`true`/`false` | `false` |\n| `showSymbolForRetweetAndReply` | Use \" RT \" instead of \"\", \" Re \" instead of \"\" | `0`/`1`/`true`/`false` | `true` |\n| `showRetweetTextInTitle` | Show quote comments in title (if `false`, only the retweeted tweet will be shown in the title) | `0`/`1`/`true`/`false` | `true` |\n| `addLinkForPics` | Add clickable links for Tweet pictures | `0`/`1`/`true`/`false` | `false` |\n| `showTimestampInDescription` | Show timestamp in description | `0`/`1`/`true`/`false` | `false` |\n| `showQuotedInTitle` | Show quoted tweet in title | `0`/`1`/`true`/`false` | `false` |\n| `widthOfPics` | Width of Tweet pictures | Unspecified/Integer | Unspecified |\n| `heightOfPics` | Height of Tweet pictures | Unspecified/Integer | Unspecified |\n| `sizeOfAuthorAvatar` | Size of author's avatar | Integer | `48` |\n| `sizeOfQuotedAuthorAvatar` | Size of quoted tweet's author's avatar | Integer | `24` |\n| `excludeReplies` | Exclude replies, only available in `/twitter/user` | `0`/`1`/`true`/`false` | `false` |\n| `includeRts` | Include retweets, only available in `/twitter/user` | `0`/`1`/`true`/`false` | `true` |\n| `forceWebApi` | Force using Web API even if Developer API is configured, only available in `/twitter/user` and `/twitter/keyword` | `0`/`1`/`true`/`false` | `false` |\n| `count` | `count` parameter passed to Twitter API, only available in `/twitter/user` | Unspecified/Integer | Unspecified |\n\nSpecify different option values than default values to improve readability. The URL\n\n```\nhttps://rsshub.app/twitter/user/durov/readable=1&authorNameBold=1&showAuthorInTitle=1&showAuthorInDesc=1&showQuotedAuthorAvatarInDesc=1&showAuthorAvatarInDesc=1&showEmojiForRetweetAndReply=1&showRetweetTextInTitle=0&addLinkForPics=1&showTimestampInDescription=1&showQuotedInTitle=1&heightOfPics=150\n```\n\ngenerates\n\n\"Readable\n\nCurrently supports two authentication methods:\n\n- Using `TWITTER_COOKIE` (recommended): Configure the cookies of logged-in Twitter Web, at least including the fields auth_token and ct0. RSSHub will use this information to directly access Twitter's web API to obtain data.\n\n- Using `TWITTER_USERNAME` `TWITTER_PASSWORD` and `TWITTER_AUTHENTICATION_SECRET`: Configure the Twitter username and password. RSSHub will use this information to log in to Twitter and obtain data using the mobile API. Please note that if you have not logged in with the current IP address before, it is easy to trigger Twitter's risk control mechanism.\n" + }, + "weibo": { + "routes": { + "/keyword/:keyword/:routeParams?": { + "path": "/keyword/:keyword/:routeParams?", + "categories": [ + "social-media", + "popular" + ], + "example": "/weibo/keyword/DIYgod", + "parameters": { + "keyword": "你想订阅的微博关键词", + "routeParams": "额外参数;请参阅上面的说明和表格" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "name": "关键词", + "maintainers": [ + "DIYgod", + "Rongronggg9" + ], + "location": "keyword.ts" + }, + "/user/:uid/:routeParams?": { + "path": "/user/:uid/:routeParams?", + "categories": [ + "social-media", + "popular" + ], + "example": "/weibo/user/1195230310", + "parameters": { + "uid": "用户 id, 博主主页打开控制台执行 `$CONFIG.oid` 获取", + "routeParams": "额外参数;请参阅上面的说明和表格;特别地,当 `routeParams=1` 时开启微博视频显示" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": true, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "m.weibo.cn/u/:uid", + "m.weibo.cn/profile/:uid", + "www.weibo.com/u/:uid" + ], + "target": "/user/:uid" + } + ], + "name": "博主", + "maintainers": [ + "DIYgod", + "iplusx", + "Rongronggg9" + ], + "description": ":::warning\n 部分博主仅登录可见,未提供 Cookie 的情况下不支持订阅,可以通过打开 `https://m.weibo.cn/u/:uid` 验证。如需要订阅该部分博主,可配置 Cookie 后订阅。\n\n 未提供 Cookie 的情况下偶尔会触发反爬限制,提供 Cookie 可缓解该情况。\n\n 微博用户 Cookie 的配置可参照部署文档\n :::", + "location": "user.ts" + } + }, + "name": "微博绿洲", + "url": "weibo.com", + "description": ":::warning\n微博会针对请求的来源地区返回不同的结果。一个已知的例子为:部分视频因未知原因仅限中国大陆境内访问 (CDN 域名为 `locallimit.us.sinaimg.cn` 而非 `f.video.weibocdn.com`)。若一条微博含有这种视频且 RSSHub 实例部署在境外,抓取到的微博可能不含视频。将 RSSHub 部署在境内有助于抓取这种视频,但阅读器也必须处于境内网络环境以加载视频。\n:::\n\n对于微博内容,在 `routeParams` 参数中以 query string 格式指定选项,可以控制输出的样式\n\n| 键 | 含义 | 接受的值 | 默认值 |\n| -------------------------- | ------------------------------------------------------------------ | -------------- | ----------------------------------- |\n| readable | 是否开启细节排版可读性优化 | 0/1/true/false | false |\n| authorNameBold | 是否加粗作者名字 | 0/1/true/false | false |\n| showAuthorInTitle | 是否在标题处显示作者 | 0/1/true/false | false(`/weibo/keyword/`中为 true) |\n| showAuthorInDesc | 是否在正文处显示作者 | 0/1/true/false | false(`/weibo/keyword/`中为 true) |\n| showAuthorAvatarInDesc | 是否在正文处显示作者头像(若阅读器会提取正文图片,不建议开启) | 0/1/true/false | false |\n| showEmojiForRetweet | 显示“🔁”取代“转发”两个字 | 0/1/true/false | false |\n| showRetweetTextInTitle | 在标题出显示转发评论(置为 false 则在标题只显示被转发微博) | 0/1/true/false | true |\n| addLinkForPics | 为图片添加可点击的链接 | 0/1/true/false | false |\n| showTimestampInDescription | 在正文处显示被转发微博的时间戳 | 0/1/true/false | false |\n| widthOfPics | 微博配图宽(生效取决于阅读器) | 不指定 / 数字 | 不指定 |\n| heightOfPics | 微博配图高(生效取决于阅读器) | 不指定 / 数字 | 不指定 |\n| sizeOfAuthorAvatar | 作者头像大小 | 数字 | 48 |\n| displayVideo | 是否直接显示微博视频和 Live Photo,只在博主或个人时间线 RSS 中有效 | 0/1/true/false | true |\n| displayArticle | 是否直接显示微博文章,只在博主或个人时间线 RSS 中有效 | 0/1/true/false | false |\n| displayComments | 是否直接显示热门评论,只在博主或个人时间线 RSS 中有效 | 0/1/true/false | false |\n| showEmojiInDescription | 是否展示正文中的微博表情,关闭则替换为 `[表情名]` | 0/1/true/false | true |\n| showLinkIconInDescription | 是否展示正文中的链接图标 | 0/1/true/false | true |\n| preferMobileLink | 是否使用移动版链接(默认使用 PC 版) | 0/1/true/false | false |\n\n指定更多与默认值不同的参数选项可以改善 RSS 的可读性,如\n\n[https://rsshub.app/weibo/user/1642909335/readable=1&authorNameBold=1&showAuthorInTitle=1&showAuthorInDesc=1&showAuthorAvatarInDesc=1&showEmojiForRetweet=1&showRetweetTextInTitle=0&addLinkForPics=1&showTimestampInDescription=1&showTimestampInDescription=1&heightOfPics=150](https://rsshub.app/weibo/user/1642909335/readable=1&authorNameBold=1&showAuthorInTitle=1&showAuthorInDesc=1&showAuthorAvatarInDesc=1&showEmojiForRetweet=1&showRetweetTextInTitle=0&addLinkForPics=1&showTimestampInDescription=1&showTimestampInDescription=1&heightOfPics=150)\n\n的效果为\n\n\"微博小秘书的可读微博" + }, + "xiaoyuzhou": { + "routes": { + "/podcast/:id": { + "path": "/podcast/:id", + "categories": [ + "multimedia", + "popular" + ], + "example": "/xiaoyuzhou/podcast/6021f949a789fca4eff4492c", + "parameters": { + "id": "播客 id,可以在小宇宙播客的 URL 中找到" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "xiaoyuzhoufm.com/podcast/:id" + ] + } + ], + "name": "播客", + "maintainers": [ + "hondajojo", + "jtsang4" + ], + "url": "xiaoyuzhoufm.com/", + "location": "podcast.ts" + } + }, + "name": "小宇宙", + "url": "xiaoyuzhoufm.com" + }, + "ximalaya": { + "routes": { + "/:type/:id/:all/:shownote?": { + "path": [ + "/:type/:id/:all/:shownote?" + ], + "categories": [ + "multimedia", + "popular" + ], + "example": "/ximalaya/album/299146", + "parameters": { + "type": "专辑类型,通常可以使用 `album`,可在对应专辑页面的 URL 中找到", + "id": "专辑 id, 可在对应专辑页面的 URL 中找到", + "all": "是否需要获取全部节目,填入 `1`、`true`、`all` 视为获取所有节目,填入其他则不获取。" + }, + "features": { + "requireConfig": [ + { + "name": "XIMALAYA_TOKEN", + "description": "" + } + ], + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": true, + "supportScihub": false + }, + "name": "专辑", + "maintainers": [ + "lengthmin", + "jjeejj", + "prnake" + ], + "description": "目前喜马拉雅的 API 只能一集一集的获取各节目上的 ShowNote,会极大的占用系统资源,所以默认为不获取节目的 ShowNote。\n\n :::warning\n 专辑类型即 url 中的分类拼音,使用通用分类 `album` 通常是可行的,专辑 id 是跟在**分类拼音**后的那个 id, 不要输成某集的 id 了\n\n **付费内容需要配置好已购买账户的 token 才能收听,详情见部署页面的配置模块**\n :::", + "location": "album.ts" + } + }, + "name": "喜马拉雅", + "url": "ximalaya.com" + }, + "youtube": { + "routes": { + "/user/:username/:embed?": { + "path": "/user/:username/:embed?", + "categories": [ + "social-media", + "popular" + ], + "example": "/youtube/user/@JFlaMusic", + "parameters": { + "username": "YouTuber username with @", + "embed": "Default to embed the video, set to any value to disable embedding" + }, + "features": { + "requireConfig": [ + { + "name": "YOUTUBE_KEY", + "description": " YouTube API Key, support multiple keys, split them with `,`, [API Key application](https://console.developers.google.com/)" + } + ], + "requirePuppeteer": false, + "antiCrawler": false, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "www.youtube.com/user/:username" + ], + "target": "/user/:username" + } + ], + "name": "Channel with username", + "maintainers": [ + "DIYgod" + ], + "location": "user.ts" + } + }, + "name": "YouTube", + "url": "youtube.com" + }, + "zhihu": { + "routes": { + "/people/activities/:id": { + "path": "/people/activities/:id", + "categories": [ + "social-media", + "popular" + ], + "example": "/zhihu/people/activities/diygod", + "parameters": { + "id": "作者 id,可在用户主页 URL 中找到" + }, + "features": { + "requireConfig": false, + "requirePuppeteer": false, + "antiCrawler": true, + "supportBT": false, + "supportPodcast": false, + "supportScihub": false + }, + "radar": [ + { + "source": [ + "www.zhihu.com/people/:id" + ] + } + ], + "name": "用户动态", + "maintainers": [ + "DIYgod" + ], + "location": "activities.ts" + } + }, + "name": "知乎", + "url": "www.zhihu.com" + } + } +} diff --git a/src/renderer/src/modules/discover/feed-form.tsx b/src/renderer/src/modules/discover/feed-form.tsx index 5ee25406c..d24f98226 100644 --- a/src/renderer/src/modules/discover/feed-form.tsx +++ b/src/renderer/src/modules/discover/feed-form.tsx @@ -24,12 +24,13 @@ import { ViewSelectContent } from "@renderer/components/view-select-content" import { useBizQuery } from "@renderer/hooks" import { apiClient } from "@renderer/lib/api-fetch" import { tipcClient } from "@renderer/lib/client" +import { nextFrame } from "@renderer/lib/dom" import { FeedViewType } from "@renderer/lib/enum" import { cn } from "@renderer/lib/utils" import { Queries } from "@renderer/queries" import { useFeed } from "@renderer/queries/feed" import { useMutation } from "@tanstack/react-query" -import { useEffect } from "react" +import { useEffect, useRef } from "react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { z } from "zod" @@ -134,6 +135,11 @@ export const FeedForm: Component<{ Queries.subscription.categories(Number.parseInt(form.watch("view"))), ) + const buttonRef = useRef(null) + useEffect(() => { + if (feed.isSuccess) nextFrame(() => buttonRef.current?.focus()) + }, [feed.isSuccess]) + return (
diff --git a/src/renderer/src/modules/discover/recommendation-content.tsx b/src/renderer/src/modules/discover/recommendation-content.tsx index e8de8cb68..815769e29 100644 --- a/src/renderer/src/modules/discover/recommendation-content.tsx +++ b/src/renderer/src/modules/discover/recommendation-content.tsx @@ -1,12 +1,16 @@ -import type { RecommendationItem } from "@renderer/models" +import { DiscoverFeedForm, FeedDescription } from "./content-components" +import type { RSSHubRoute } from "./types" export const RecommendationContent = ({ route, + routePrefix, }: { - route: RecommendationItem["routes"][string] + route: RSSHubRoute + routePrefix: string }) => ( - <> - rsshub:/ - {route.path} - +
+ + + +
) diff --git a/src/renderer/src/modules/discover/recommendations-card.tsx b/src/renderer/src/modules/discover/recommendations-card.tsx new file mode 100644 index 000000000..127aba9fb --- /dev/null +++ b/src/renderer/src/modules/discover/recommendations-card.tsx @@ -0,0 +1,61 @@ +import { SiteIcon } from "@renderer/components/site-icon" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@renderer/components/ui/card" +import { useModalStack } from "@renderer/components/ui/modal/stacked/hooks" +import type { FC } from "react" +import { memo } from "react" + +import { RecommendationContent } from "./recommendation-content" +import type { RSSHubRouteDeclaration } from "./types" + +interface RecommendationCardProps { + data: RSSHubRouteDeclaration + routePrefix: string +} +export const RecommendationCard: FC = memo(({ data, routePrefix }) => { + const { present } = useModalStack() + return ( + + + + + {data.name} + + + + +
    + {Object.keys(data.routes).map((route) => ( +
  • { + present({ + content: () => ( + + ), + icon: ( + + ), + title: `${data.name} - ${data.routes[route].name}`, + }) + }} + > + {data.routes[route].name} +
  • + ))} +
+
+
+
+ ) +}, +) diff --git a/src/renderer/src/modules/discover/recommendations.tsx b/src/renderer/src/modules/discover/recommendations.tsx index de22e58e8..543db2264 100644 --- a/src/renderer/src/modules/discover/recommendations.tsx +++ b/src/renderer/src/modules/discover/recommendations.tsx @@ -1,69 +1,35 @@ -import { SiteIcon } from "@renderer/components/site-icon" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@renderer/components/ui/card" -import { useModalStack } from "@renderer/components/ui/modal/stacked/hooks" +import { LoadingCircle } from "@renderer/components/ui/loading" import { useBizQuery } from "@renderer/hooks" import { Queries } from "@renderer/queries" -import { RecommendationContent } from "./recommendation-content" +import { RecommendationCard } from "./recommendations-card" export function Recommendations() { - const rsshubPopular = useBizQuery(Queries.discover.rsshubCategory({ - category: "popular", - })) - const { present } = useModalStack() + const rsshubPopular = useBizQuery( + Queries.discover.rsshubCategory({ + category: "popular", + }), + ) + + if (rsshubPopular.isLoading) { + return + } + + const { data } = rsshubPopular + + if (!data) { + return null + } return (
-
Popular
- {rsshubPopular.data && ( -
- {Object.keys(rsshubPopular.data).map((key) => ( - - - - - {rsshubPopular.data[key].name} - - - - -
    - {Object.keys(rsshubPopular.data[key].routes).map((route) => ( -
  • { - present({ - content: () => , - icon: ( - - ), - title: `${rsshubPopular.data[key].name} - ${rsshubPopular.data[key].routes[route].name}`, - clickOutsideToDismiss: true, - }) - }} - > - {rsshubPopular.data[key].routes[route].name} -
  • - ))} -
-
-
-
- ))} -
- )} +
Popular
+ +
+ {Object.keys(data).map((key) => ( + + ))} +
) } diff --git a/src/renderer/src/modules/discover/types.ts b/src/renderer/src/modules/discover/types.ts new file mode 100644 index 000000000..bda351b26 --- /dev/null +++ b/src/renderer/src/modules/discover/types.ts @@ -0,0 +1,43 @@ +export type RSSHubRouteType = Record +export interface RSSHubRouteDeclaration { + routes: Routes + name: string + url: string +} +type Routes = Record +export type RSSHubParameterObject = { + description: string + default: string | null + options?: { + label: string + value: string + }[] +} + +export type RSSHubParameter = string | RSSHubParameterObject +export type RSSHubRoute = { + path: string + categories: string[] + example: string + parameters: Record + name: string + maintainers: string[] + location: string + description: string + + // features: Features + // radar: RadarItem[] +} + +// interface Features { +// requireConfig: boolean +// requirePuppeteer: boolean +// antiCrawler: boolean +// supportBT: boolean +// supportPodcast: boolean +// supportScihub: boolean +// } +// interface RadarItem { +// source: string[] +// target: string +// } diff --git a/src/renderer/src/modules/discover/utils.ts b/src/renderer/src/modules/discover/utils.ts new file mode 100644 index 000000000..cb999384a --- /dev/null +++ b/src/renderer/src/modules/discover/utils.ts @@ -0,0 +1,3 @@ +import type { RSSHubParameter, RSSHubParameterObject } from "./types" + +export const normalizeRSSHubParameters = (parameters: RSSHubParameter): RSSHubParameterObject | null => parameters ? typeof parameters === "string" ? { description: parameters, default: null } : parameters : null diff --git a/src/renderer/src/modules/entry-column/list-item-template.tsx b/src/renderer/src/modules/entry-column/list-item-template.tsx index 7ec112bb0..ec7972c5c 100644 --- a/src/renderer/src/modules/entry-column/list-item-template.tsx +++ b/src/renderer/src/modules/entry-column/list-item-template.tsx @@ -27,7 +27,7 @@ export function ListItem({ if (!entry) return return ( -
+
diff --git a/src/renderer/src/modules/feed-column/index.tsx b/src/renderer/src/modules/feed-column/index.tsx index 89a215fe8..82c712678 100644 --- a/src/renderer/src/modules/feed-column/index.tsx +++ b/src/renderer/src/modules/feed-column/index.tsx @@ -1,4 +1,5 @@ import { getReadonlyRoute } from "@renderer/atoms" +import { useSidebarActiveView } from "@renderer/atoms/sidebar" import { Logo } from "@renderer/components/icons/logo" import { ActionButton } from "@renderer/components/ui/button" import { ProfileButton } from "@renderer/components/user-button" @@ -11,7 +12,7 @@ import { clamp, cn } from "@renderer/lib/utils" import { useWheel } from "@use-gesture/react" import { m, useSpring } from "framer-motion" import { Lethargy } from "lethargy" -import { useCallback, useRef, useState } from "react" +import { useCallback, useRef } from "react" import { isHotkeyPressed, useHotkeys } from "react-hotkeys-hook" import { Link } from "react-router-dom" @@ -35,7 +36,7 @@ const useBackHome = (active: number) => { export function FeedColumn() { const carouselRef = useRef(null) - const [active, setActive_] = useState(0) + const [active, setActive_] = useSidebarActiveView() const spring = useSpring(0, { stiffness: 700, damping: 40, diff --git a/src/renderer/src/modules/feed-column/list.tsx b/src/renderer/src/modules/feed-column/list.tsx index 6677fb417..b5f5f737b 100644 --- a/src/renderer/src/modules/feed-column/list.tsx +++ b/src/renderer/src/modules/feed-column/list.tsx @@ -200,7 +200,7 @@ export function FeedList({ to="/discover" className="-mt-36 flex h-full flex-1 flex-col items-center justify-center gap-2" > - + Add some feeds
diff --git a/src/renderer/src/styles/colors.css b/src/renderer/src/styles/colors.css index 3b28f5c51..46cceefe0 100644 --- a/src/renderer/src/styles/colors.css +++ b/src/renderer/src/styles/colors.css @@ -14,6 +14,11 @@ --fo-item-active: theme(colors.native.active/0.4); --fo-item-hover: theme(colors.native.active/0.2); + + --fo-modal-background: theme(colors.zinc.50/0.8); + --fo-modal-background-opaque: theme(colors.zinc.50); + + --fo-text-primary-hover: 0, 0%, 14.9%; } [data-theme="dark"] { @@ -31,4 +36,9 @@ --fo-text-primary: 0, 0%, 80%; --fo-background: theme(colors.background); + + --fo-modal-background: theme(colors.neutral.900/0.8); + --fo-modal-background-opaque: theme(colors.neutral.900); + + --fo-text-primary-hover: 240, 4.9%, 83.9%; } diff --git a/tailwind.config.ts b/tailwind.config.ts index fb4abfb1f..fe3accffb 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -70,7 +70,7 @@ export default resolveConfig({ theme: { // https://uicolors.app/create - accent: { + "accent": { DEFAULT: "#ff5c00", 50: "#fff7ec", 100: "#ffeed3", @@ -85,24 +85,31 @@ export default resolveConfig({ 950: "#461304", }, - vibrancyFg: "hsl(var(--fo-vibrancy-foreground), )", - vibrancyBg: "var(--fo-vibrancy-background)", + "vibrancyFg": "hsl(var(--fo-vibrancy-foreground), )", + "vibrancyBg": "var(--fo-vibrancy-background)", - item: { + "item": { active: "var(--fo-item-active)", hover: "var(--fo-item-hover)", }, - tooltip: { + "tooltip": { background: "hsl(var(--fo-tooltip-background), )", foreground: "hsl(var(--fo-tooltip-foreground), )", }, - inactive: "hsl(var(--fo-inactive), )", - disabled: "hsl(var(--fo-disabled), )", + "inactive": "hsl(var(--fo-inactive), )", + "disabled": "hsl(var(--fo-disabled), )", - foreground: "hsl(var(--fo-text-primary), )", - background: "var(--fo-background)", + "foreground": "hsl(var(--fo-text-primary), )", + "background": "var(--fo-background)", + + "foreground-hover": "hsl(var(--fo-text-primary-hover), )", + + "modal": { + "background": "var(--fo-modal-background)", + "background-opaque": "var(--fo-modal-background-opaque)", + }, }, }, borderRadius: {