Skip to content

Commit 32828d3

Browse files
committed
feat: apply friend modal
Signed-off-by: Innei <[email protected]>
1 parent 4a1ac0f commit 32828d3

File tree

7 files changed

+245
-48
lines changed

7 files changed

+245
-48
lines changed

src/app/friends/page.tsx

+100-14
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Form, FormInput } from '~/components/ui/form'
1616
import { Loading } from '~/components/ui/loading'
1717
import { BottomToUpTransitionView } from '~/components/ui/transition/BottomToUpTransitionView'
1818
import { shuffle } from '~/lib/_'
19+
import { toast } from '~/lib/toast'
1920
import { useAggregationSelector } from '~/providers/root/aggregation-data-provider'
2021
import { useModalStack } from '~/providers/root/modal-stack-provider'
2122
import { apiClient } from '~/utils/request'
@@ -285,16 +286,79 @@ const ApplyLinkInfo: FC = () => {
285286
const FormModal = () => {
286287
const { dismissTop } = useModalStack()
287288
const inputs = useRef([
288-
{ name: 'author', placeholder: '昵称 *', required: true },
289-
{ name: 'name', placeholder: '站点标题 *', required: true },
290-
{ name: 'url', placeholder: '网站 * https://', required: true },
291-
{ name: 'avatar', placeholder: '头像链接 * https://', required: true },
292-
{ name: 'email', placeholder: '留下你的邮箱哦 *', required: true },
289+
{
290+
name: 'author',
291+
placeholder: '昵称 *',
292+
rules: [
293+
{
294+
validator: (value: string) => !!value,
295+
message: '昵称不能为空',
296+
},
297+
{
298+
validator: (value: string) => value.length <= 20,
299+
message: '昵称不能超过20个字符',
300+
},
301+
],
302+
},
303+
{
304+
name: 'name',
305+
placeholder: '站点标题 *',
306+
rules: [
307+
{
308+
validator: (value: string) => !!value,
309+
message: '站点标题不能为空',
310+
},
311+
{
312+
validator: (value: string) => value.length <= 20,
313+
message: '站点标题不能超过20个字符',
314+
},
315+
],
316+
},
317+
{
318+
name: 'url',
319+
placeholder: '网站 * https://',
320+
rules: [
321+
{
322+
validator: isHttpsUrl,
323+
message: '请输入正确的网站链接 https://',
324+
},
325+
],
326+
},
327+
{
328+
name: 'avatar',
329+
placeholder: '头像链接 * https://',
330+
rules: [
331+
{
332+
validator: isHttpsUrl,
333+
message: '请输入正确的头像链接 https://',
334+
},
335+
],
336+
},
337+
{
338+
name: 'email',
339+
placeholder: '留下你的邮箱哦 *',
340+
341+
rules: [
342+
{
343+
validator: isEmail,
344+
message: '请输入正确的邮箱',
345+
},
346+
],
347+
},
293348
{
294349
name: 'description',
295350
placeholder: '一句话描述一下自己吧 *',
296-
maxLength: 50,
297-
required: true,
351+
352+
rules: [
353+
{
354+
validator: (value: string) => !!value,
355+
message: '一句话描述一下自己吧',
356+
},
357+
{
358+
validator: (value: string) => value.length <= 50,
359+
message: '一句话描述不要超过50个字啦',
360+
},
361+
],
298362
},
299363
]).current
300364
const [state, setState] = useState({
@@ -314,13 +378,17 @@ const FormModal = () => {
314378
setValue(e.target.name as keyof typeof state, e.target.value)
315379
}, [])
316380

317-
const handleSubmit = useCallback((e: any) => {
318-
e.preventDefault()
381+
const handleSubmit = useCallback(
382+
(e: any) => {
383+
e.preventDefault()
319384

320-
apiClient.link.applyLink({ ...state }).then(() => {
321-
dismissTop()
322-
})
323-
}, [])
385+
apiClient.link.applyLink({ ...state }).then(() => {
386+
dismissTop()
387+
toast.success('好耶!')
388+
})
389+
},
390+
[state],
391+
)
324392
return (
325393
<Form className="w-[300px] space-y-4 text-center" onSubmit={handleSubmit}>
326394
{inputs.map((input) => (
@@ -333,9 +401,27 @@ const FormModal = () => {
333401
/>
334402
))}
335403

336-
<StyledButton variant="primary" onClick={handleSubmit}>
404+
<StyledButton variant="primary" type="submit">
337405
好耶!
338406
</StyledButton>
339407
</Form>
340408
)
341409
}
410+
411+
const isHttpsUrl = (value: string) => {
412+
return (
413+
/^https?:\/\/.*/.test(value) &&
414+
(() => {
415+
try {
416+
new URL(value)
417+
return true
418+
} catch {
419+
return false
420+
}
421+
})()
422+
)
423+
}
424+
425+
const isEmail = (value: string) => {
426+
return /^.+@.+\..+$/.test(value)
427+
}

src/components/ui/form/Form.tsx

+59-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useRef } from 'react'
1+
import { useCallback, useMemo, useRef } from 'react'
2+
import { produce } from 'immer'
23
import { atom } from 'jotai'
34
import type {
45
DetailedHTMLProps,
@@ -9,20 +10,27 @@ import type { Field } from './types'
910

1011
import { jotaiStore } from '~/lib/store'
1112

12-
import { FormContext } from './FormContext'
13+
import { FormConfigContext, FormContext, useForm } from './FormContext'
1314

1415
export const Form = (
1516
props: PropsWithChildren<
16-
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>
17+
DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
18+
showErrorMessage?: boolean
19+
}
1720
>,
1821
) => {
22+
const { showErrorMessage = true, ...formProps } = props
1923
const fieldsAtom = useRef(atom({})).current
2024
return (
2125
<FormContext.Provider
2226
value={
2327
useRef({
28+
showErrorMessage,
2429
fields: fieldsAtom,
25-
30+
getField: (name: string) => {
31+
// @ts-expect-error
32+
return jotaiStore.get(fieldsAtom)[name]
33+
},
2634
addField: (name: string, field: Field) => {
2735
jotaiStore.set(fieldsAtom, (p) => {
2836
return {
@@ -43,7 +51,11 @@ export const Form = (
4351
}).current
4452
}
4553
>
46-
<FormInternal {...props} />
54+
<FormConfigContext.Provider
55+
value={useMemo(() => ({ showErrorMessage }), [showErrorMessage])}
56+
>
57+
<FormInternal {...formProps} />
58+
</FormConfigContext.Provider>
4759
</FormContext.Provider>
4860
)
4961
}
@@ -54,13 +66,49 @@ const FormInternal = (
5466
>,
5567
) => {
5668
const { onSubmit, ...rest } = props
69+
const fieldsAtom = useForm().fields
70+
const handleSubmit = useCallback(
71+
async (e: React.FormEvent<HTMLFormElement>) => {
72+
e.preventDefault()
73+
74+
const fields = jotaiStore.get(fieldsAtom)
75+
for await (const [key, field] of Object.entries(fields)) {
76+
const $ref = field.$ref
77+
if (!$ref) continue
78+
const value = $ref.value
79+
const rules = field.rules
80+
for (let i = 0; i < rules.length; i++) {
81+
const rule = rules[i]
82+
try {
83+
const isOk = await rule.validator(value)
84+
if (!isOk) {
85+
console.error(
86+
`Form validation failed, at field \`${key}\`` +
87+
`, got value \`${value}\``,
88+
)
89+
$ref.focus()
90+
if (rule.message) {
91+
jotaiStore.set(fieldsAtom, (prev) => {
92+
return produce(prev, (draft) => {
93+
;(draft[key] as Field).rules[i].status = 'error'
94+
})
95+
})
96+
}
97+
return
98+
}
99+
} catch (e) {
100+
console.error('validate function throw error', e)
101+
return
102+
}
103+
}
104+
}
105+
106+
onSubmit?.(e)
107+
},
108+
[onSubmit],
109+
)
57110
return (
58-
<form
59-
onSubmit={(e) => {
60-
onSubmit?.(e)
61-
}}
62-
{...rest}
63-
>
111+
<form onSubmit={handleSubmit} {...rest}>
64112
{props.children}
65113
</form>
66114
)
+9-4
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import { createContext, useContext } from 'react'
2-
import type { Atom } from 'jotai'
2+
import { atom } from 'jotai'
33
import type { Field } from './types'
44

5+
const initialFields = atom({} as { [key: string]: Field })
56
export const FormContext = createContext<{
6-
fields: Atom<{
7-
[key: string]: Field
8-
}>
7+
fields: typeof initialFields
98

109
addField: (name: string, field: Field) => void
1110
removeField: (name: string) => void
11+
getField: (name: string) => Field | undefined
12+
}>(null!)
13+
14+
export const FormConfigContext = createContext<{
15+
showErrorMessage?: boolean
1216
}>(null!)
1317
export const useForm = () => {
1418
return useContext(FormContext)
1519
}
20+
export const useFormConfig = () => useContext(FormConfigContext)

src/components/ui/form/FormInput.tsx

+64-13
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,95 @@
1-
import { memo, useEffect } from 'react'
1+
import { memo, useCallback, useEffect, useRef } from 'react'
2+
import { produce } from 'immer'
3+
import { useAtomValue } from 'jotai'
4+
import { selectAtom } from 'jotai/utils'
25
import type { DetailedHTMLProps, FC, InputHTMLAttributes } from 'react'
36
import type { FormFieldBaseProps } from './types'
47

8+
import { AutoResizeHeight } from '~/components/widgets/shared/AutoResizeHeight'
9+
import { jotaiStore } from '~/lib/store'
510
import { clsxm } from '~/utils/helper'
611

7-
import { useForm } from './FormContext'
12+
import { useForm, useFormConfig } from './FormContext'
813

914
export const FormInput: FC<
1015
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> &
1116
FormFieldBaseProps<string>
12-
> = memo(({ className, rules, ...rest }) => {
17+
> = memo(({ className, rules, onKeyDown, ...rest }) => {
1318
const FormCtx = useForm()
1419
if (!FormCtx) throw new Error('FormInput must be used inside <FormContext />')
15-
const { addField, removeField } = FormCtx
20+
const { showErrorMessage } = useFormConfig()
21+
const { addField, removeField, fields } = FormCtx
22+
const inputRef = useRef<HTMLInputElement>(null)
1623

24+
const errorMessage = useAtomValue(
25+
selectAtom(
26+
fields,
27+
useCallback(
28+
(atomValue) => {
29+
if (!rest.name) return
30+
return atomValue[rest.name]?.rules.find(
31+
(rule) => rule.status === 'error',
32+
)?.message
33+
},
34+
[rest.name],
35+
),
36+
),
37+
)
1738
useEffect(() => {
1839
const name = rest.name
1940
if (!rules) return
2041
if (!name) return
2142

2243
addField(name, {
2344
rules,
45+
$ref: inputRef.current,
2446
})
2547

2648
return () => {
2749
removeField(name)
2850
}
2951
}, [rest.name, rules])
3052

53+
const handleKeyDown = useCallback(
54+
(e: React.KeyboardEvent<HTMLInputElement>) => {
55+
if (onKeyDown) onKeyDown(e)
56+
// const currentField =
57+
jotaiStore.set(fields, (p) => {
58+
return produce(p, (draft) => {
59+
if (!rest.name) return
60+
draft[rest.name].rules.forEach((rule) => {
61+
if (rule.status === 'error') rule.status = 'success'
62+
})
63+
})
64+
})
65+
},
66+
[fields, onKeyDown, rest.name],
67+
)
68+
3169
return (
32-
<input
33-
className={clsxm(
34-
'relative h-12 w-full rounded-lg bg-gray-200/50 px-3 dark:bg-zinc-800/50',
35-
'ring-accent/80 duration-200 focus:ring-2',
36-
'appearance-none',
37-
className,
70+
<>
71+
<input
72+
ref={inputRef}
73+
className={clsxm(
74+
'relative h-12 w-full rounded-lg bg-gray-200/50 px-3 dark:bg-zinc-800/50',
75+
'ring-accent/80 duration-200 focus:ring-2',
76+
'appearance-none',
77+
!!errorMessage && 'ring-2 ring-red-400 dark:ring-orange-700',
78+
className,
79+
)}
80+
type="text"
81+
onKeyDown={handleKeyDown}
82+
{...rest}
83+
/>
84+
85+
{showErrorMessage && (
86+
<AutoResizeHeight duration={0.2}>
87+
<p className="text-left text-sm text-red-400 dark:text-orange-700">
88+
{errorMessage}
89+
</p>
90+
</AutoResizeHeight>
3891
)}
39-
type="text"
40-
{...rest}
41-
/>
92+
</>
4293
)
4394
})
4495

0 commit comments

Comments
 (0)