281 lines
8.4 KiB
TypeScript
281 lines
8.4 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
|
|
import {
|
|
mdiMenuUp,
|
|
mdiMenuDown,
|
|
mdiAccount,
|
|
mdiRename,
|
|
mdiAt,
|
|
mdiLinkVariant,
|
|
mdiPound,
|
|
mdiAccountPlus,
|
|
} from '@mdi/js'
|
|
import Icon from '@mdi/react'
|
|
import type { EditSocialById, UpdateSocialInput } from 'types/graphql'
|
|
|
|
import type { RWGqlError } from '@redwoodjs/forms'
|
|
import { Form, FieldError, Label, TextField, Submit } from '@redwoodjs/forms'
|
|
|
|
import { baseUrls, getLogoComponent, sortOrder } from 'src/lib/handle'
|
|
|
|
type FormSocial = NonNullable<EditSocialById['social']>
|
|
|
|
interface SocialFormProps {
|
|
social?: EditSocialById['social']
|
|
onSave: (data: UpdateSocialInput, id?: FormSocial['id']) => void
|
|
error: RWGqlError
|
|
loading: boolean
|
|
}
|
|
|
|
const types: FormSocial['type'][] = [
|
|
'x',
|
|
'threads',
|
|
'instagram',
|
|
'facebook',
|
|
'tiktok',
|
|
'youtube',
|
|
'steam',
|
|
'discord',
|
|
'twitch',
|
|
'linkedin',
|
|
'matrix',
|
|
'github',
|
|
'gitea',
|
|
'forgejo',
|
|
'gitlab',
|
|
'bitbucket',
|
|
'leetcode',
|
|
'email',
|
|
'phone',
|
|
'custom',
|
|
]
|
|
|
|
const urlHandles: FormSocial['type'][] = ['custom', 'gitea', 'forgejo']
|
|
|
|
const SocialForm = (props: SocialFormProps) => {
|
|
const emailRegex =
|
|
/^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/
|
|
|
|
const phoneRegex = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/
|
|
const urlRegex =
|
|
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i
|
|
const matrixRegex =
|
|
/^([#@][a-zA-Z0-9_\-\.]+):([a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,})$/
|
|
|
|
const [type, setType] = useState<FormSocial['type']>(
|
|
props.social?.type ?? 'x'
|
|
)
|
|
|
|
const typesDropdownRef = useRef<HTMLDivElement>(null)
|
|
const [typesDropdownOpen, setTypesDropdownOpen] = useState(false)
|
|
|
|
const [username, setUsername] = useState(props.social?.username ?? '')
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
typesDropdownRef.current &&
|
|
!typesDropdownRef.current.contains(event.target as Node)
|
|
)
|
|
setTypesDropdownOpen(false)
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [])
|
|
|
|
const onSubmit = (data: FormSocial) => {
|
|
data.type = type
|
|
data.name.trim()
|
|
data.username.trim()
|
|
props.onSave(data, props?.social?.id)
|
|
}
|
|
|
|
const nameRef = useRef<HTMLInputElement>(null)
|
|
useEffect(() => nameRef.current?.focus(), [])
|
|
|
|
return (
|
|
<Form<FormSocial>
|
|
onSubmit={onSubmit}
|
|
error={props.error}
|
|
className="h-128 max-w-80 space-y-2"
|
|
>
|
|
<Label name="name" className="form-control w-full">
|
|
<Label
|
|
name="name"
|
|
className="input input-bordered flex items-center gap-2"
|
|
errorClassName="input input-bordered flex items-center gap-2 input-error"
|
|
>
|
|
<Label
|
|
name="name"
|
|
className="size-4 opacity-70"
|
|
errorClassName="size-4 text-error"
|
|
>
|
|
<Icon path={mdiRename} />
|
|
</Label>
|
|
<TextField
|
|
name="name"
|
|
ref={nameRef}
|
|
placeholder="Name"
|
|
defaultValue={props.social?.name}
|
|
className="w-full"
|
|
validation={{
|
|
required: {
|
|
value: true,
|
|
message: 'Required',
|
|
},
|
|
}}
|
|
/>
|
|
</Label>
|
|
<div className="label">
|
|
<FieldError
|
|
name="name"
|
|
className="text-xs font-semibold text-error"
|
|
/>
|
|
</div>
|
|
</Label>
|
|
|
|
<Label name="username" className="form-control w-full">
|
|
<Label
|
|
name="username"
|
|
className="input input-bordered flex items-center gap-2"
|
|
errorClassName="input input-bordered flex items-center gap-2 input-error"
|
|
>
|
|
<Label
|
|
name="username"
|
|
className="size-5 opacity-70"
|
|
errorClassName="size-5 text-error"
|
|
>
|
|
<Icon
|
|
path={
|
|
type == 'email'
|
|
? mdiAt
|
|
: type == 'phone'
|
|
? mdiPound
|
|
: urlHandles.includes(type)
|
|
? mdiLinkVariant
|
|
: type == 'discord'
|
|
? mdiAccountPlus
|
|
: mdiAccount
|
|
}
|
|
/>
|
|
</Label>
|
|
<TextField
|
|
name="username"
|
|
placeholder={
|
|
urlHandles.includes(type)
|
|
? 'URL'
|
|
: type == 'phone'
|
|
? 'Phone'
|
|
: type == 'email'
|
|
? 'Email'
|
|
: type == 'discord'
|
|
? 'Invite Code'
|
|
: 'Username'
|
|
}
|
|
className="w-full"
|
|
defaultValue={username}
|
|
autoCorrect="off"
|
|
autoComplete="off"
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
validation={{
|
|
required: {
|
|
value: true,
|
|
message: 'Required',
|
|
},
|
|
pattern: {
|
|
value:
|
|
type == 'email'
|
|
? emailRegex
|
|
: type == 'phone'
|
|
? phoneRegex
|
|
: urlHandles.includes(type)
|
|
? urlRegex
|
|
: type == 'matrix' && matrixRegex,
|
|
message: `Invalid ${
|
|
urlHandles.includes(type)
|
|
? 'URL'
|
|
: type == 'phone'
|
|
? 'phone number'
|
|
: type == 'email'
|
|
? 'Email'
|
|
: type == 'matrix' && 'Matrix identifier'
|
|
}`,
|
|
},
|
|
}}
|
|
/>
|
|
</Label>
|
|
<div className="label">
|
|
<FieldError
|
|
name="username"
|
|
className="text-xs font-semibold text-error"
|
|
/>
|
|
{type == 'phone' && (
|
|
<span className="label-text-alt">Format: +1 555-555-5555</span>
|
|
)}
|
|
{!urlHandles.includes(type) &&
|
|
type !== 'phone' &&
|
|
type !== 'email' && (
|
|
<span className="label-text-alt">{`${baseUrls[type]}${username}`}</span>
|
|
)}
|
|
</div>
|
|
</Label>
|
|
|
|
<div className="dropdown mb-4" ref={typesDropdownRef}>
|
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
|
|
<div
|
|
tabIndex={0}
|
|
role="button"
|
|
className="btn"
|
|
onClick={() => setTypesDropdownOpen(!typesDropdownOpen)}
|
|
>
|
|
<div className="flex">
|
|
{getLogoComponent(type)}
|
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
|
<label className="swap swap-flip size-6">
|
|
<input type="checkbox" checked={typesDropdownOpen} disabled />
|
|
<Icon path={mdiMenuUp} className="swap-on -mr-2 ml-2 size-6" />
|
|
<Icon path={mdiMenuDown} className="swap-off -mr-2 ml-2 size-6" />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
{typesDropdownOpen && (
|
|
<ul
|
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
|
tabIndex={0}
|
|
className="menu dropdown-content z-10 mt-2 grid w-72 grid-cols-5 grid-rows-2 gap-2 rounded-box bg-base-100 shadow-xl"
|
|
>
|
|
{types
|
|
.sort((a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b))
|
|
.map((type, i) => (
|
|
<li key={i}>
|
|
<button
|
|
className="btn btn-square btn-ghost"
|
|
onClick={() => {
|
|
setType(type)
|
|
setTypesDropdownOpen(false)
|
|
}}
|
|
>
|
|
{getLogoComponent(type)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<nav className="my-2 flex justify-center space-x-2">
|
|
<Submit
|
|
disabled={props.loading}
|
|
className="btn btn-primary btn-sm uppercase"
|
|
>
|
|
Save
|
|
</Submit>
|
|
</nav>
|
|
</Form>
|
|
)
|
|
}
|
|
|
|
export default SocialForm
|