Skip to content

Commit b3eec57

Browse files
committed
feat: modal stack
Signed-off-by: Innei <[email protected]>
1 parent 8a8a6ba commit b3eec57

File tree

12 files changed

+417
-16
lines changed

12 files changed

+417
-16
lines changed

src/app/notes/[id]/page.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Loading } from '~/components/ui/loading'
1919
import { Markdown } from '~/components/ui/markdown'
2020
import { NoteFooterNavigationBarForMobile } from '~/components/widgets/note/NoteFooterNavigation'
2121
import { NoteTopic } from '~/components/widgets/note/NoteTopic'
22+
import { SubscribeBell } from '~/components/widgets/subscribe/SubscribeBell'
2223
import { TocAside, TocAutoScroll } from '~/components/widgets/toc'
2324
import { XLogInfoForNote, XLogSummaryForNote } from '~/components/widgets/xlog'
2425
import { useNoteByNidQuery, useNoteData } from '~/hooks/data/use-note'
@@ -120,6 +121,7 @@ const NotePage = memo(({ note }: { note: NoteModel }) => {
120121
</NoteHideIfSecret>
121122
</article>
122123

124+
<SubscribeBell defaultType="note_c" />
123125
<NoteTopic topic={note.topic} />
124126
<XLogInfoForNote />
125127
<NoteFooterNavigationBarForMobile id={note.id} />

src/components/layout/header/internal/HeaderDrawerButton.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const HeaderDrawerButton = () => {
5959
>
6060
<Dialog.DialogClose asChild>
6161
<MotionButtonBase
62-
className="z-9 absolute right-0 top-0 p-8"
62+
className="absolute right-0 top-0 z-[9] p-8"
6363
onClick={() => {
6464
setOpen(false)
6565
}}

src/components/ui/dlalog/DialogOverlay.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const DialogOverlay = () => {
55
return (
66
<Dialog.Overlay asChild>
77
<motion.div
8-
className="fixed inset-0 z-[11] bg-slate-50/80 backdrop-blur-sm dark:bg-slate-900/80"
8+
className="fixed inset-0 z-[11] bg-slate-50/80 backdrop-blur-sm dark:bg-neutral-900/80"
99
initial={{ opacity: 0 }}
1010
animate={{ opacity: 1 }}
1111
exit={{ opacity: 0 }}

src/components/ui/input/Input.tsx

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { DetailedHTMLProps, FC, InputHTMLAttributes } from 'react'
2+
3+
import { clsxm } from '~/utils/helper'
4+
5+
export const Input: FC<
6+
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
7+
> = ({ className, ...props }) => {
8+
return (
9+
<input
10+
className={clsxm(
11+
'min-w-0 flex-auto appearance-none rounded-lg border ring-accent/20 duration-200 sm:text-sm',
12+
'bg-base-100 px-3 py-[calc(theme(spacing.2)-1px)] placeholder:text-zinc-400 focus:outline-none focus:ring-2',
13+
'border-zinc-900/10 dark:border-zinc-700',
14+
'focus:border-accent-focus dark:bg-zinc-700/[0.15] dark:text-zinc-200 dark:placeholder:text-zinc-500',
15+
className,
16+
)}
17+
{...props}
18+
/>
19+
)
20+
}

src/components/widgets/note/NoteMetaBar.tsx

+8-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
/* eslint-disable @typescript-eslint/no-non-null-assertion */
33
'use client'
44

5+
import { cloneElement } from 'react'
56
import type { ReactNode } from 'react'
67

78
import { CreativeCommonsIcon } from '~/components/icons/cc'
@@ -10,6 +11,8 @@ import { useNoteData } from '~/hooks/data/use-note'
1011
import { mood2icon, weather2icon } from '~/lib/meta-icon'
1112

1213
const dividerVertical = <DividerVertical className="!mx-2 scale-y-50" />
14+
const dividerVerticalWithKey = () =>
15+
cloneElement(dividerVertical, { key: `divider-${Math.random()}` })
1316
export const NoteMetaBar = () => {
1417
const note = useNoteData()
1518
if (!note) return null
@@ -18,7 +21,7 @@ export const NoteMetaBar = () => {
1821

1922
if (note.weather) {
2023
children.push(
21-
dividerVertical,
24+
dividerVerticalWithKey(),
2225
<span className="inline-flex items-center space-x-1" key="weather">
2326
{weather2icon(note.weather)}
2427
<span className="font-medium">{note.weather}</span>
@@ -28,7 +31,7 @@ export const NoteMetaBar = () => {
2831

2932
if (note.mood) {
3033
children.push(
31-
dividerVertical,
34+
dividerVerticalWithKey(),
3235
<span className="inline-flex items-center space-x-1" key="mood">
3336
{mood2icon(note.mood)}
3437
<span className="font-medium">{note.mood}</span>
@@ -38,7 +41,7 @@ export const NoteMetaBar = () => {
3841

3942
if (note.count.read > 0) {
4043
children.push(
41-
dividerVertical,
44+
dividerVerticalWithKey(),
4245
<span className="inline-flex items-center space-x-1" key="readcount">
4346
<i className="icon-[mingcute--book-6-line]" />
4447
<span className="font-medium">{note.count.read}</span>
@@ -48,7 +51,7 @@ export const NoteMetaBar = () => {
4851

4952
if (note.count.like > 0) {
5053
children.push(
51-
dividerVertical,
54+
dividerVerticalWithKey(),
5255
<span className="inline-flex items-center space-x-1" key="linkcount">
5356
<i className="icon-[mingcute--heart-line]" />
5457
<span className="font-medium">{note.count.like}</span>
@@ -57,7 +60,7 @@ export const NoteMetaBar = () => {
5760
}
5861

5962
children.push(
60-
dividerVertical,
63+
dividerVerticalWithKey(),
6164
<span className="inline-flex items-center" key="cc">
6265
<a
6366
href="https://creativecommons.org/licenses/by-nc-nd/4.0/"

src/components/widgets/note/NotePasswordForm.tsx

+2-8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState } from 'react'
2-
import clsx from 'clsx'
32

43
import { StyledButton } from '~/components/ui/button'
4+
import { Input } from '~/components/ui/input/Input'
55

66
export const NotePasswordForm = () => {
77
const [password, setPassword] = useState('')
@@ -13,18 +13,12 @@ export const NotePasswordForm = () => {
1313
<div className="flex h-[calc(100vh-15rem)] flex-col space-y-4 center">
1414
需要密码才能查看!
1515
<form className="mt-8 flex flex-col space-y-4 center">
16-
<input
16+
<Input
1717
value={password}
1818
onChange={(e) => setPassword(e.target.value)}
1919
type="password"
2020
placeholder="输入密码以查看"
2121
aria-label="输入密码以查看"
22-
className={clsx(
23-
'min-w-0 flex-auto appearance-none rounded-lg border ring-accent/20 sm:text-sm',
24-
'bg-base-100 px-3 py-[calc(theme(spacing.2)-1px)] placeholder:text-zinc-400 focus:outline-none focus:ring-2',
25-
'border-zinc-900/10 dark:border-zinc-700',
26-
'focus:border-accent-focus dark:bg-zinc-700/[0.15] dark:text-zinc-200 dark:placeholder:text-zinc-500',
27-
)}
2822
/>
2923
<StyledButton
3024
disabled={!password}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { SubscribeTypeToBitMap } from '@mx-space/api-client'
2+
import type { FC } from 'react'
3+
4+
import { useIsEnableSubscribe, usePresentSubscribeModal } from './hooks'
5+
6+
type SubscribeType = keyof typeof SubscribeTypeToBitMap
7+
interface SubscribeBellProps {
8+
defaultType: SubscribeType[] | SubscribeType
9+
}
10+
export const SubscribeBell: FC<SubscribeBellProps> = (props) => {
11+
const { defaultType } = props
12+
const canSubscribe = useIsEnableSubscribe()
13+
const { present } = usePresentSubscribeModal(
14+
([] as SubscribeType[]).concat(defaultType),
15+
)
16+
17+
if (!canSubscribe) {
18+
return null
19+
}
20+
21+
return (
22+
<div className="mb-6 flex justify-center">
23+
<button
24+
className="flex flex-col items-center justify-center p-4"
25+
onClick={present}
26+
>
27+
<p className="text-gray-1 leading-8 opacity-80">
28+
站点已开启邮件订阅,点亮小铃铛,订阅最新文章哦~
29+
</p>
30+
31+
<i className="icon-[material-symbols--notifications-active-outline] mt-4 scale-150 transform text-3xl text-accent opacity-50 transition-opacity hover:opacity-100" />
32+
</button>
33+
</div>
34+
)
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { useEffect, useReducer } from 'react'
2+
import type { SubscribeTypeToBitMap } from '@mx-space/api-client'
3+
import type { FC } from 'react'
4+
5+
import { Input } from '~/components/ui/input/Input'
6+
import { useStateToRef } from '~/hooks/common/use-state-ref'
7+
import { toast } from '~/lib/toast'
8+
import { useAggregationData } from '~/providers/root/aggregation-data-provider'
9+
import { apiClient } from '~/utils/request'
10+
11+
import { useSubscribeStatusQuery } from './hooks'
12+
13+
interface SubscribeModalProps {
14+
onConfirm: () => void
15+
defaultTypes?: (keyof typeof SubscribeTypeToBitMap)[]
16+
}
17+
18+
const subscibeTextMap: Record<string, string> = {
19+
post_c: '文章',
20+
note_c: '手记',
21+
say_c: '说说',
22+
recently_c: '速记',
23+
}
24+
25+
const initialState = {
26+
email: '',
27+
types: {
28+
post_c: false,
29+
note_c: false,
30+
say_c: false,
31+
recently_c: false,
32+
},
33+
}
34+
35+
type Action =
36+
| { type: 'set'; data: Partial<typeof initialState> }
37+
| { type: 'reset' }
38+
39+
const useFormData = () => {
40+
const [state, dispatch] = useReducer(
41+
(state: typeof initialState, payload: Action) => {
42+
switch (payload.type) {
43+
case 'set':
44+
return { ...state, ...payload.data }
45+
case 'reset':
46+
return initialState
47+
}
48+
},
49+
{ ...initialState },
50+
)
51+
return [state, dispatch] as const
52+
}
53+
54+
export const SubscribeModal: FC<SubscribeModalProps> = ({
55+
onConfirm,
56+
defaultTypes,
57+
}) => {
58+
const [state, dispatch] = useFormData()
59+
60+
const stateRef = useStateToRef(state)
61+
62+
useEffect(() => {
63+
if (!defaultTypes || !defaultTypes.length) {
64+
return
65+
}
66+
67+
dispatch({
68+
type: 'set',
69+
data: {
70+
types: defaultTypes.reduce(
71+
(acc, type) => {
72+
// @ts-ignore
73+
acc[type] = true
74+
return acc
75+
},
76+
{ ...stateRef.current.types },
77+
),
78+
},
79+
})
80+
}, [])
81+
82+
const query = useSubscribeStatusQuery()
83+
84+
const handleSubList = async () => {
85+
const { email, types } = state
86+
await apiClient.subscribe.subscribe(
87+
email,
88+
// @ts-ignore
89+
Object.keys(types).filter((name) => state.types[name]) as any[],
90+
)
91+
92+
toast('订阅成功,谢谢你!', 'success')
93+
dispatch({ type: 'reset' })
94+
onConfirm()
95+
}
96+
const aggregation = useAggregationData()
97+
if (!aggregation) return null
98+
const {
99+
seo: { title },
100+
} = aggregation
101+
return (
102+
<form action="#" onSubmit={handleSubList} className="flex flex-col gap-5">
103+
<p className="text-gray-1 text-sm">
104+
欢迎订阅「{title}
105+
」,我会定期推送最新的内容到你的邮箱。
106+
</p>
107+
<Input
108+
type="text"
109+
placeholder="留下你的邮箱哦 *"
110+
required
111+
value={state.email}
112+
onChange={(e) => {
113+
dispatch({ type: 'set', data: { email: e.target.value } })
114+
}}
115+
/>
116+
<div className="flex gap-10">
117+
{Object.keys(state.types)
118+
.filter((type) => query.data?.allowTypes.includes(type as any))
119+
.map((name) => (
120+
<fieldset
121+
className="children:cursor-pointer inline-flex items-center text-lg"
122+
key={name}
123+
>
124+
<input
125+
className="checkbox-accent checkbox mr-2"
126+
type="checkbox"
127+
onChange={(e) => {
128+
dispatch({
129+
type: 'set',
130+
data: {
131+
types: {
132+
...state.types,
133+
[name]: e.target.checked,
134+
},
135+
},
136+
})
137+
}}
138+
// @ts-ignore
139+
checked={state.types[name]}
140+
id={name}
141+
/>
142+
<label htmlFor={name} className="text-shizuku">
143+
{subscibeTextMap[name]}
144+
</label>
145+
</fieldset>
146+
))}
147+
</div>
148+
149+
<p className="text-gray-1 -mt-2 text-sm">
150+
或者你也可以通过{' '}
151+
<a href="/feed" className="text-green" target="_blank">
152+
/feed
153+
</a>{' '}
154+
订阅「{title}」的 RSS 流。
155+
</p>
156+
<button className="btn-accent btn-md btn" disabled={!state.email}>
157+
订阅
158+
</button>
159+
</form>
160+
)
161+
}
+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import type { SubscribeTypeToBitMap } from '@mx-space/api-client'
3+
4+
import { useModalStack } from '~/providers/root/modal-stack-provider'
5+
import { apiClient } from '~/utils/request'
6+
7+
import { SubscribeModal } from './SubscribeModal'
8+
9+
const SWR_CHECK_SUBSCRIBE_KEY = ['subscribe-status']
10+
11+
export const useSubscribeStatusQuery = () => {
12+
return useQuery(SWR_CHECK_SUBSCRIBE_KEY, apiClient.subscribe.check, {
13+
cacheTime: 60_000 * 10,
14+
})
15+
}
16+
17+
export const useIsEnableSubscribe = () =>
18+
useQuery({
19+
queryKey: SWR_CHECK_SUBSCRIBE_KEY,
20+
select: (data: { enable: boolean }) => data?.enable,
21+
})
22+
23+
export const usePresentSubscribeModal = (
24+
defaultTypes?: (keyof typeof SubscribeTypeToBitMap)[],
25+
) => {
26+
const { present } = useModalStack()
27+
28+
return {
29+
present: () => {
30+
const dispose = present({
31+
title: '邮件订阅',
32+
33+
content: () => (
34+
<SubscribeModal onConfirm={dispose} defaultTypes={defaultTypes} />
35+
),
36+
})
37+
},
38+
}
39+
}

src/providers/root/index.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ProviderComposer } from '../../components/common/ProviderComposer'
88
import { AggregationProvider } from './aggregation-data-provider'
99
import { DebugProvider } from './debug-provider'
1010
import { JotaiStoreProvider } from './jotai-provider'
11+
import { ModalStackProvider } from './modal-stack-provider'
1112
import { PageScrollInfoProvider } from './page-scroll-info-provider'
1213
import { SocketProvider } from './socket-provider'
1314
import { ViewportProvider } from './viewport-provider'
@@ -21,6 +22,7 @@ const contexts: JSX.Element[] = [
2122
<SocketProvider key="socketProvider" />,
2223
<PageScrollInfoProvider key="PageScrollInfoProvider" />,
2324
<DebugProvider key="debugProvider" />,
25+
<ModalStackProvider key='modalStackProvider'/>,
2426
]
2527
export function Providers({ children }: PropsWithChildren) {
2628
return <ProviderComposer contexts={contexts}>{children}</ProviderComposer>

0 commit comments

Comments
 (0)