feat: add discover feed form from rsshub (#89)

* feat: regexp path parser

Signed-off-by: Innei <i@innei.in>

* feat: markdown

Signed-off-by: Innei <i@innei.in>

* fix: shirk

Signed-off-by: Innei <i@innei.in>

* feat: discover form

Signed-off-by: Innei <i@innei.in>

* fix: modal overlay zindex

Signed-off-by: Innei <i@innei.in>

* feat: fill path

Signed-off-by: Innei <i@innei.in>

* feat: modal

Signed-off-by: Innei <i@innei.in>

* fix: card style

Signed-off-by: Innei <i@innei.in>

* fix: parser

Signed-off-by: Innei <i@innei.in>

* feat: add maintainers

Signed-off-by: Innei <i@innei.in>

* fix: select zindex

Signed-off-by: Innei <i@innei.in>

* fix: dismiss all

Signed-off-by: Innei <i@innei.in>

* feat: optimize a11y

Signed-off-by: Innei <i@innei.in>

* fix: dark mode style

Signed-off-by: Innei <i@innei.in>

---------

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-06-26 20:25:38 +08:00 committed by GitHub
parent 43e7d12a54
commit 57b643fced
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 2483 additions and 168 deletions

View File

@ -36,4 +36,5 @@ jobs:
run: |
pnpm run typecheck
npm run lint
pnpm run test
- name: Run test
run: pnpm test

View File

@ -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",

View File

@ -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: {}

View File

@ -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,

View File

@ -0,0 +1,11 @@
import { createAtomHooks } from "@renderer/lib/jotai"
import { atom } from "jotai"
export const [
,
useSidebarActiveView,
useSidebarActiveViewValue,
useSetSidebarActiveView,
getSidebarActiveView,
setSidebarActiveView,
] = createAtomHooks(atom(0))

View File

@ -36,6 +36,7 @@ export const CopyButton: Component<{
}, [value])
return (
<MotionButtonBase
type="button"
onClick={handleCopy}
className={cn(
"center flex text-xs",

View File

@ -68,7 +68,7 @@ const FormItem = React.forwardRef<
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<FormItemContext.Provider value={React.useMemo(() => ({ id }), [id])}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)

View File

@ -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) => (
<div
className={cn(styles.markdown, props.className, "prose dark:prose-invert prose-th:text-left")}
dangerouslySetInnerHTML={useMemo(
() => ({
__html: md.render(props.children as string),
}),
[props.children],
)}
/>
)

View File

@ -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;
}
}
}

View File

@ -0,0 +1 @@
export * from "./Markdown"

View File

@ -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 `<details class="${klass} custom-block"${attrs}><summary>${title}</summary>\n` }
return `<div class="${klass} custom-block"${attrs}><p class="custom-block-title">${title}</p>\n`
} else { return klass === "details" ? `</details>\n` : `</div>\n` }
},
},
]
}

View File

@ -0,0 +1,5 @@
import { atom } from "jotai"
import type { ModalProps } from "./types"
export const modalStackAtom = atom([] as (ModalProps & { id: string })[])

View File

@ -17,3 +17,5 @@ export const modalMontionConfig = {
exit: initialStyle,
transition: microReboundPreset,
}
export const MODAL_STACK_Z_INDEX = 100

View File

@ -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<string, ModalProps>
import { createContext } from "react"
export type CurrentModalContentProps = ModalContentPropsInternal & {
ref: RefObject<HTMLElement | null>
@ -14,10 +9,7 @@ export const CurrentModalContext = createContext<CurrentModalContentProps>(
null as any,
)
export const useCurrentModal = () => useContext(CurrentModalContext)
export type ModalContentComponent<T> = FC<ModalContentPropsInternal & T>
export type ModalContentPropsInternal = {
dismiss: () => void
}
export const modalStackAtom = atom([] as (ModalProps & { id: string })[])

View File

@ -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<ModalProps, "content"> {
@ -37,18 +39,19 @@ const DeclarativeModalImpl: FC<DeclarativeModalProps> = ({
return (
<AnimatePresence>
{open && (
<ModalInternal isTop onClose={onOpenChange} index={index} item={item}>
{children}
</ModalInternal>
<>
<ModalInternal isTop onClose={onOpenChange} index={index} item={item}>
{children}
</ModalInternal>
<ModalOverlay zIndex={MODAL_STACK_Z_INDEX - 1 + index} />
</>
)}
</AnimatePresence>
)
}
const FooterAction: Component = ({ children, className }) => (
<div
className={cn("mt-4 flex items-center justify-end gap-2", className)}
>
<div className={cn("mt-4 flex items-center justify-end gap-2", className)}>
{children}
</div>
)

View File

@ -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<string, ModalProps>
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)

View File

@ -1,5 +1,6 @@
export * from "./context"
export * from "./helper"
export * from "./hooks"
export * from "./modal"
export * from "./provider"
export * from "./types"

View File

@ -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
}) => (
<Dialog.Overlay asChild>
<m.div
onClick={onClick}
className="fixed inset-0 z-[11] bg-zinc-50/80 dark:bg-neutral-900/80"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex }}
/>
</Dialog.Overlay>
)
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<{
<Wrapper>
<Dialog.Root open onOpenChange={onClose}>
<Dialog.Portal>
{overlay && <DialogOverlay zIndex={19} />}
<Dialog.DialogTitle className="sr-only">{title}</Dialog.DialogTitle>
<Dialog.Content asChild>
<div
@ -190,6 +170,7 @@ export const ModalInternal: Component<{
modalContainerClassName,
)}
onClick={clickOutsideToDismiss ? dismiss : undefined}
style={zIndexStyle}
>
<div
className={cn("contents", modalClassName)}
@ -204,15 +185,15 @@ export const ModalInternal: Component<{
</Wrapper>
)
}
return (
<Wrapper>
<Dialog.Root open onOpenChange={onClose}>
<Dialog.Portal>
{overlay && <DialogOverlay zIndex={19} />}
<Dialog.Content asChild>
<div
ref={edgeElementRef}
style={zIndexStyle}
className={cn(
"center fixed inset-0 z-20 flex",
modalContainerClassName,
@ -220,14 +201,14 @@ export const ModalInternal: Component<{
onClick={clickOutsideToDismiss ? dismiss : noticeModal}
>
<m.div
style={modalStyle}
style={zIndexStyle}
{...modalMontionConfig}
animate={animateController}
className={cn(
"relative flex flex-col overflow-hidden rounded-lg",
opaque ?
"bg-zinc-50 dark:bg-neutral-900" :
"bg-zinc-50/80 backdrop-blur-sm dark:bg-neutral-900/80",
"bg-theme-modal-background-opaque" :
"bg-theme-modal-background backdrop-blur-sm",
"shadow-modal p-2",
max ?
"h-[90vh] w-[90vw]" :
@ -256,7 +237,7 @@ export const ModalInternal: Component<{
<span>{title}</span>
</Dialog.Title>
<Dialog.DialogClose className="center p-2" onClick={close}>
<Dialog.DialogClose className="center p-2" tabIndex={1} onClick={close}>
<i className="i-mgc-close-cute-re" />
</Dialog.DialogClose>
</div>
@ -265,6 +246,7 @@ export const ModalInternal: Component<{
<div className="min-h-0 shrink grow overflow-auto px-4 py-2">
{finalChildren}
</div>
</m.div>
</div>
</Dialog.Content>

View File

@ -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 (
<RootPortal>
<m.div
id="modal-overlay"
onClick={onClick}
className="fixed inset-0 z-[11] bg-zinc-50/80 dark:bg-neutral-900/80"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex }}
/>
</RootPortal>
)
}

View File

@ -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<PropsWithChildren> = ({ children }) => (
<>
@ -16,7 +18,8 @@ export const ModalStackProvider: FC<PropsWithChildren> = ({ children }) => (
const ModalStack = () => {
const stack = useAtomValue(modalStackAtom)
useDismissAllWhenRouterChange()
// Vite HMR issue
// useDismissAllWhenRouterChange()
return (
<AnimatePresence mode="popLayout">
@ -28,6 +31,7 @@ const ModalStack = () => {
isTop={index === stack.length - 1}
/>
))}
{stack.length > 0 && <ModalOverlay zIndex={MODAL_STACK_Z_INDEX + stack.length - 1} />}
</AnimatePresence>
)
}

View File

@ -76,7 +76,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"shadow-perfect relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"shadow-perfect relative z-[1000] max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,

View File

@ -23,6 +23,32 @@ declare const routes: hono_hono_base.HonoBase<hono_types.BlankEnv, {
status: 200;
};
};
"/wallets/transactions/claim_daily": {
$post: {
input: {};
output: {
code: 0;
data: {
transactionHash: string;
};
};
outputFormat: "json";
status: 200;
};
};
"/wallets/transactions/claim_daily_ttl": {
$get: {
input: {};
output: {
code: 0;
data: {
ttl: number;
};
};
outputFormat: "json";
status: 200;
};
};
"/wallets": {
$get: {
input: {

View File

@ -1,5 +1,6 @@
import { useReadonlyRouteSelector } from "@renderer/atoms"
import { getReadonlyRoute, useReadonlyRouteSelector } from "@renderer/atoms"
import { FeedViewType } from "@renderer/lib/enum"
import type { Params } from "react-router-dom"
import { useParams, useSearchParams } from "react-router-dom"
// '0', '1', '2', '3', '4', '5',
const FeedViewTypeValues = (() => {
@ -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<any>,
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 = <T>(
selector: (params: {
@ -61,25 +76,11 @@ export const useRouteParamsSelector = <T>(
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)
}

View File

@ -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",
}
`)
})
})

View File

@ -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<CompileOptions, "validate">,
): 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<string, PathParams>
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<string, string>)
}

View File

@ -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 (
<div className="mb-2 text-sm">
<p>
The maintainers of this feed are as follows. If you have any questions
or suggestions, you can contact them.
</p>
<ul className="flex flex-wrap items-center gap-4">
{maintainers.map((maintainer) => (
<a
href={`https://github.com/${maintainer}`}
key={maintainer}
target="_blank"
rel="noreferrer noopener"
className="inline-flex cursor-pointer items-center text-theme-foreground/50 duration-200 hover:text-theme-accent"
>
@
{maintainer}
<i className="i-mgc-external-link-cute-re ml-0.5" />
</a>
))}
</ul>
</div>
)
}
export const FeedDescription = ({ description }: { description?: string }) => {
if (!description) {
return <p className="mb-4">This feed does not have a description.</p>
}
return (
<>
<p>
The description of this feed is as follows, and you can fill out the
parameter form with the relevant information.
</p>
<Markdown className="my-4 w-full max-w-full cursor-text select-text">
{description}
</Markdown>
</>
)
}
export const DiscoverFeedForm = ({
route,
routePrefix,
}: {
route: RSSHubRoute
routePrefix
}) => {
const keys = parseRegexpPathParams(route.path)
const formPlaceholder = useMemo<Record<string, string>>(() => {
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<z.infer<typeof dynamicFormSchema>>({
resolver: zodResolver(dynamicFormSchema),
defaultValues: {},
mode: "all",
}) as UseFormReturn<any>
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: () => (
<FeedForm
asWidget
url={url}
defaultView={defaultView}
onSuccess={dismissAll}
/>
),
})
} 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<HTMLFormElement>(null)
useLayoutEffect(() => {
const $form = formElRef.current
if (!$form) return
$form.querySelectorAll("input")[0]?.focus()
}, [formElRef])
return (
<Form {...form}>
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
ref={formElRef}
>
{keys.array.map((keyItem) => {
const parameters = normalizeRSSHubParameters(
route.parameters[keyItem.name],
)
return (
<FormItem key={keyItem.name} className="flex flex-col space-y-2">
<FormLabel>
{keyItem.name}
{!keyItem.optional && (
<sup className="ml-1 align-sub text-red-500">*</sup>
)}
</FormLabel>
<Input
{...form.register(keyItem.name)}
placeholder={parameters?.default ?? formPlaceholder[keyItem.name]}
defaultValue={parameters?.default || void 0}
/>
{!!parameters && (
<p className="text-xs text-theme-foreground/50">
{parameters.description}
</p>
)}
</FormItem>
)
})}
<PreviewUrl
watch={form.watch}
path={route.path}
routePrefix={`rsshub://${routePrefix}`}
/>
<FeedMaintainers maintainers={route.maintainers} />
<div className="sticky bottom-0 -mt-4 flex w-full translate-y-3 justify-end bg-theme-modal-background-opaque py-3">
<StyledButton disabled={!form.formState.isValid} type="submit">
Preview
</StyledButton>
</div>
</form>
</Form>
)
}
const PreviewUrl: FC<{
watch: UseFormReturn<any>["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 (
<div className="group relative min-w-0 py-2">
<pre className="w-full whitespace-pre-line break-words text-xs text-theme-foreground/40">
{renderedPath}
</pre>
<CopyButton
value={renderedPath}
className="absolute right-0 top-0 opacity-0 duration-200 group-hover:opacity-100"
/>
</div>
)
}

File diff suppressed because one or more lines are too long

View File

@ -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<HTMLButtonElement>(null)
useEffect(() => {
if (feed.isSuccess) nextFrame(() => buttonRef.current?.focus())
}, [feed.isSuccess])
return (
<div
className={cn(
@ -248,6 +254,7 @@ export const FeedForm: Component<{
<div className="flex flex-1 items-end justify-end">
<StyledButton
ref={buttonRef}
type="submit"
isLoading={followMutation.isPending}
>

View File

@ -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}
</>
<div className="w-full min-w-[550px] max-w-[700px]">
<FeedDescription description={route.description} />
<DiscoverFeedForm route={route} routePrefix={routePrefix} />
</div>
)

View File

@ -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<RecommendationCardProps> = memo(({ data, routePrefix }) => {
const { present } = useModalStack()
return (
<Card className="shadow-none">
<CardHeader className="p-5 pb-3">
<CardTitle className="flex items-center text-base">
<SiteIcon url={`https://${data.url}`} />
{data.name}
</CardTitle>
</CardHeader>
<CardContent className="p-5 pt-0">
<CardDescription>
<ul className="space-y-1">
{Object.keys(data.routes).map((route) => (
<li
key={route}
className="duration-200 hover:font-medium hover:text-theme-foreground-hover"
onClick={() => {
present({
content: () => (
<RecommendationContent routePrefix={routePrefix} route={data.routes[route]} />
),
icon: (
<SiteIcon
className="size-4"
url={`https://${data.url}`}
/>
),
title: `${data.name} - ${data.routes[route].name}`,
})
}}
>
{data.routes[route].name}
</li>
))}
</ul>
</CardDescription>
</CardContent>
</Card>
)
},
)

View File

@ -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 <LoadingCircle className="mt-16" size="medium" />
}
const { data } = rsshubPopular
if (!data) {
return null
}
return (
<div className="mt-8">
<div className="text-center text-lg font-medium">Popular</div>
{rsshubPopular.data && (
<div className="mt-4 grid grid-cols-3 gap-4">
{Object.keys(rsshubPopular.data).map((key) => (
<Card key={key}>
<CardHeader className="p-5 pb-3">
<CardTitle className="flex items-center text-base">
<SiteIcon
url={`https://${rsshubPopular.data[key].url}`}
/>
{rsshubPopular.data[key].name}
</CardTitle>
</CardHeader>
<CardContent className="p-5 pt-0">
<CardDescription>
<ul className="space-y-1">
{Object.keys(rsshubPopular.data[key].routes).map((route) => (
<li
key={route}
className="transition-colors hover:font-medium hover:text-zinc-800"
onClick={() => {
present({
content: () => <RecommendationContent route={rsshubPopular.data[key].routes[route]} />,
icon: (
<SiteIcon
className="size-4"
url={`https://${rsshubPopular.data[key].url}`}
/>
),
title: `${rsshubPopular.data[key].name} - ${rsshubPopular.data[key].routes[route].name}`,
clickOutsideToDismiss: true,
})
}}
>
{rsshubPopular.data[key].routes[route].name}
</li>
))}
</ul>
</CardDescription>
</CardContent>
</Card>
))}
</div>
)}
<div className="text-center text-lg font-bold">Popular</div>
<div className="mt-4 grid grid-cols-3 gap-4 px-3">
{Object.keys(data).map((key) => (
<RecommendationCard key={key} data={data[key]} routePrefix={key} />
))}
</div>
</div>
)
}

View File

@ -0,0 +1,43 @@
export type RSSHubRouteType = Record<string, RSSHubRouteDeclaration>
export interface RSSHubRouteDeclaration {
routes: Routes
name: string
url: string
}
type Routes = Record<string, RSSHubRoute>
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<string, RSSHubParameter>
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
// }

View File

@ -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

View File

@ -27,7 +27,7 @@ export function ListItem({
if (!entry) return <ReactVirtuosoItemPlaceholder />
return (
<div className={cn("relative flex py-3 pl-3 pr-2", !asRead && "before:absolute before:-left-0.5 before:top-[18px] before:block before:size-2 before:rounded-full before:bg-blue-500")}>
<div className={cn("relative flex py-3 pl-3 pr-2", !asRead && "before:absolute before:-left-0.5 before:top-[18px] before:block before:size-2 before:rounded-full before:bg-theme-accent")}>
<FeedIcon feed={entry.feeds} entry={entry.entries} />
<div className="-mt-0.5 line-clamp-4 flex-1 text-sm leading-tight">
<div className={cn("flex gap-1 text-[10px] font-bold", asRead ? "text-zinc-400" : "text-zinc-500")}>

View File

@ -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<HTMLDivElement>(null)
const [active, setActive_] = useState(0)
const [active, setActive_] = useSidebarActiveView()
const spring = useSpring(0, {
stiffness: 700,
damping: 40,

View File

@ -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"
>
<i className="i-mgc-add-cute-re text-3xl " />
<i className="i-mgc-add-cute-re text-3xl" />
Add some feeds
</Link>
</div>

View File

@ -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%;
}

View File

@ -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), <alpha-value>)",
vibrancyBg: "var(--fo-vibrancy-background)",
"vibrancyFg": "hsl(var(--fo-vibrancy-foreground), <alpha-value>)",
"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), <alpha-value>)",
foreground: "hsl(var(--fo-tooltip-foreground), <alpha-value>)",
},
inactive: "hsl(var(--fo-inactive), <alpha-value>)",
disabled: "hsl(var(--fo-disabled), <alpha-value>)",
"inactive": "hsl(var(--fo-inactive), <alpha-value>)",
"disabled": "hsl(var(--fo-disabled), <alpha-value>)",
foreground: "hsl(var(--fo-text-primary), <alpha-value>)",
background: "var(--fo-background)",
"foreground": "hsl(var(--fo-text-primary), <alpha-value>)",
"background": "var(--fo-background)",
"foreground-hover": "hsl(var(--fo-text-primary-hover), <alpha-value>)",
"modal": {
"background": "var(--fo-modal-background)",
"background-opaque": "var(--fo-modal-background-opaque)",
},
},
},
borderRadius: {