360 lines
14 KiB
TypeScript
360 lines
14 KiB
TypeScript
![]() |
import { createFileUploader } from '@solid-primitives/upload'
|
||
|
import { clsx } from 'clsx'
|
||
|
import deepEqual from 'fast-deep-equal'
|
||
|
import { createEffect, createSignal, For, lazy, Match, onCleanup, onMount, Show, Switch } from 'solid-js'
|
||
|
import { createStore } from 'solid-js/store'
|
||
|
|
||
|
import { useConfirm } from '../../context/confirm'
|
||
|
import { useLocalize } from '../../context/localize'
|
||
|
import { useProfileForm } from '../../context/profile'
|
||
|
import { useSession } from '../../context/session'
|
||
|
import { useSnackbar } from '../../context/snackbar'
|
||
|
import { clone } from '../../utils/clone'
|
||
|
import { getImageUrl } from '../../utils/getImageUrl'
|
||
|
import { handleImageUpload } from '../../utils/handleImageUpload'
|
||
|
import { profileSocialLinks } from '../../utils/profileSocialLinks'
|
||
|
import { validateUrl } from '../../utils/validateUrl'
|
||
|
import { Button } from '../_shared/Button'
|
||
|
import { Icon } from '../_shared/Icon'
|
||
|
import { Loading } from '../_shared/Loading'
|
||
|
import { Popover } from '../_shared/Popover'
|
||
|
import { SocialNetworkInput } from '../_shared/SocialNetworkInput'
|
||
|
import { ProfileSettingsNavigation } from '../Nav/ProfileSettingsNavigation'
|
||
|
|
||
|
import styles from '../../pages/profile/Settings.module.scss'
|
||
|
|
||
|
const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor'))
|
||
|
const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTextarea/GrowingTextarea'))
|
||
|
|
||
|
export const ProfileSettings = () => {
|
||
|
const { t } = useLocalize()
|
||
|
const [prevForm, setPrevForm] = createStore({})
|
||
|
const [isFormInitialized, setIsFormInitialized] = createSignal(false)
|
||
|
const [social, setSocial] = createSignal([])
|
||
|
const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false)
|
||
|
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
|
||
|
const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false)
|
||
|
const [uploadError, setUploadError] = createSignal(false)
|
||
|
const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false)
|
||
|
const [hostname, setHostname] = createSignal<string | null>(null)
|
||
|
const [slugError, setSlugError] = createSignal<string>()
|
||
|
const [nameError, setNameError] = createSignal<string>()
|
||
|
|
||
|
const {
|
||
|
form,
|
||
|
actions: { submit, updateFormField, setForm },
|
||
|
} = useProfileForm()
|
||
|
|
||
|
const {
|
||
|
actions: { showSnackbar },
|
||
|
} = useSnackbar()
|
||
|
|
||
|
const {
|
||
|
actions: { loadSession },
|
||
|
} = useSession()
|
||
|
|
||
|
const {
|
||
|
actions: { showConfirm },
|
||
|
} = useConfirm()
|
||
|
|
||
|
createEffect(() => {
|
||
|
if (Object.keys(form).length > 0 && !isFormInitialized()) {
|
||
|
setPrevForm(form)
|
||
|
setSocial(form.links)
|
||
|
setIsFormInitialized(true)
|
||
|
}
|
||
|
})
|
||
|
|
||
|
const slugInputRef: { current: HTMLInputElement } = { current: null }
|
||
|
const nameInputRef: { current: HTMLInputElement } = { current: null }
|
||
|
|
||
|
const handleChangeSocial = (value: string) => {
|
||
|
if (validateUrl(value)) {
|
||
|
updateFormField('links', value)
|
||
|
setAddLinkForm(false)
|
||
|
} else {
|
||
|
setIncorrectUrl(true)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const handleSubmit = async (event: Event) => {
|
||
|
event.preventDefault()
|
||
|
if (nameInputRef.current.value.length === 0) {
|
||
|
setNameError(t('Required'))
|
||
|
nameInputRef.current.focus()
|
||
|
return
|
||
|
}
|
||
|
if (slugInputRef.current.value.length === 0) {
|
||
|
setSlugError(t('Required'))
|
||
|
slugInputRef.current.focus()
|
||
|
return
|
||
|
}
|
||
|
try {
|
||
|
await submit(form)
|
||
|
setPrevForm(clone(form))
|
||
|
showSnackbar({ body: t('Profile successfully saved') })
|
||
|
} catch (error) {
|
||
|
if (error.code === 'duplicate_slug') {
|
||
|
setSlugError(t('The address is already taken'))
|
||
|
slugInputRef.current.focus()
|
||
|
return
|
||
|
}
|
||
|
showSnackbar({ type: 'error', body: t('Error') })
|
||
|
}
|
||
|
loadSession()
|
||
|
}
|
||
|
|
||
|
const handleCancel = async () => {
|
||
|
const isConfirmed = await showConfirm({
|
||
|
confirmBody: t('Do you really want to reset all changes?'),
|
||
|
confirmButtonVariant: 'primary',
|
||
|
declineButtonVariant: 'secondary',
|
||
|
})
|
||
|
if (isConfirmed) {
|
||
|
setForm(clone(prevForm))
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' })
|
||
|
|
||
|
const handleUploadAvatar = async () => {
|
||
|
selectFiles(async ([uploadFile]) => {
|
||
|
try {
|
||
|
setUploadError(false)
|
||
|
setIsUserpicUpdating(true)
|
||
|
const result = await handleImageUpload(uploadFile)
|
||
|
updateFormField('userpic', result.url)
|
||
|
setIsUserpicUpdating(false)
|
||
|
} catch (error) {
|
||
|
setUploadError(true)
|
||
|
console.error('[upload avatar] error', error)
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
|
||
|
onMount(() => {
|
||
|
setHostname(window?.location.host)
|
||
|
|
||
|
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||
|
const handleBeforeUnload = (event) => {
|
||
|
if (!deepEqual(form, prevForm)) {
|
||
|
event.returnValue = t(
|
||
|
'There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?',
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||
|
onCleanup(() => window.removeEventListener('beforeunload', handleBeforeUnload))
|
||
|
})
|
||
|
|
||
|
createEffect(() => {
|
||
|
if (!deepEqual(form, prevForm)) {
|
||
|
setIsFloatingPanelVisible(true)
|
||
|
}
|
||
|
})
|
||
|
|
||
|
const handleDeleteSocialLink = (link) => {
|
||
|
updateFormField('links', link, true)
|
||
|
}
|
||
|
|
||
|
return (
|
||
|
<Show when={Object.keys(form).length > 0 && isFormInitialized()} fallback={<Loading />}>
|
||
|
<>
|
||
|
<div class="wide-container">
|
||
|
<div class="row">
|
||
|
<div class="col-md-5">
|
||
|
<div class={clsx('left-navigation', styles.leftNavigation)}>
|
||
|
<ProfileSettingsNavigation />
|
||
|
</div>
|
||
|
</div>
|
||
|
<div class="col-md-19">
|
||
|
<div class="row">
|
||
|
<div class="col-md-20 col-lg-18 col-xl-16">
|
||
|
<h1>{t('Profile settings')}</h1>
|
||
|
<p class="description">{t('Here you can customize your profile the way you want.')}</p>
|
||
|
<form enctype="multipart/form-data">
|
||
|
<h4>{t('Userpic')}</h4>
|
||
|
<div class="pretty-form__item">
|
||
|
<div
|
||
|
class={clsx(styles.userpic, { [styles.hasControls]: form.userpic })}
|
||
|
onClick={!form.userpic && handleUploadAvatar}
|
||
|
>
|
||
|
<Switch>
|
||
|
<Match when={isUserpicUpdating()}>
|
||
|
<Loading />
|
||
|
</Match>
|
||
|
<Match when={form.userpic}>
|
||
|
<div
|
||
|
class={styles.userpicImage}
|
||
|
style={{
|
||
|
'background-image': `url(${getImageUrl(form.userpic, {
|
||
|
width: 180,
|
||
|
height: 180,
|
||
|
})})`,
|
||
|
}}
|
||
|
/>
|
||
|
<div class={styles.controls}>
|
||
|
<Popover content={t('Delete userpic')}>
|
||
|
{(triggerRef: (el) => void) => (
|
||
|
<button
|
||
|
ref={triggerRef}
|
||
|
class={styles.control}
|
||
|
onClick={() => updateFormField('userpic', '')}
|
||
|
>
|
||
|
<Icon name="close" />
|
||
|
</button>
|
||
|
)}
|
||
|
</Popover>
|
||
|
<Popover content={t('Upload userpic')}>
|
||
|
{(triggerRef: (el) => void) => (
|
||
|
<button
|
||
|
ref={triggerRef}
|
||
|
class={styles.control}
|
||
|
onClick={handleUploadAvatar}
|
||
|
>
|
||
|
<Icon name="user-image-black" />
|
||
|
</button>
|
||
|
)}
|
||
|
</Popover>
|
||
|
</div>
|
||
|
</Match>
|
||
|
<Match when={!form.userpic}>
|
||
|
<Icon name="user-image-gray" />
|
||
|
{t('Here you can upload your photo')}
|
||
|
</Match>
|
||
|
</Switch>
|
||
|
</div>
|
||
|
<Show when={uploadError()}>
|
||
|
<div class={styles.error}>{t('Upload error')}</div>
|
||
|
</Show>
|
||
|
</div>
|
||
|
<h4>{t('Name')}</h4>
|
||
|
<p class="description">
|
||
|
{t(
|
||
|
'Your name will appear on your profile page and as your signature in publications, comments and responses.',
|
||
|
)}
|
||
|
</p>
|
||
|
<div class="pretty-form__item">
|
||
|
<input
|
||
|
type="text"
|
||
|
name="username"
|
||
|
id="username"
|
||
|
placeholder={t('Name')}
|
||
|
onInput={(event) => updateFormField('name', event.currentTarget.value)}
|
||
|
value={form.name}
|
||
|
ref={(el) => (nameInputRef.current = el)}
|
||
|
/>
|
||
|
<label for="username">{t('Name')}</label>
|
||
|
<Show when={nameError()}>
|
||
|
<div
|
||
|
style={{ position: 'absolute', 'margin-top': '-4px' }}
|
||
|
class="form-message form-message--error"
|
||
|
>
|
||
|
{t(`${nameError()}`)}
|
||
|
</div>
|
||
|
</Show>
|
||
|
</div>
|
||
|
|
||
|
<h4>{t('Address on Discourse')}</h4>
|
||
|
<div class="pretty-form__item">
|
||
|
<div class={styles.discoursName}>
|
||
|
<label for="user-address">https://{hostname()}/author/</label>
|
||
|
<div class={styles.discoursNameField}>
|
||
|
<input
|
||
|
type="text"
|
||
|
name="user-address"
|
||
|
id="user-address"
|
||
|
onInput={(event) => updateFormField('slug', event.currentTarget.value)}
|
||
|
value={form.slug}
|
||
|
ref={(el) => (slugInputRef.current = el)}
|
||
|
class="nolabel"
|
||
|
/>
|
||
|
<Show when={slugError()}>
|
||
|
<p class="form-message form-message--error">{t(`${slugError()}`)}</p>
|
||
|
</Show>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
<h4>{t('Introduce')}</h4>
|
||
|
<GrowingTextarea
|
||
|
variant="bordered"
|
||
|
placeholder={t('Introduce')}
|
||
|
value={(value) => updateFormField('bio', value)}
|
||
|
initialValue={form.bio || ''}
|
||
|
allowEnterKey={false}
|
||
|
maxLength={120}
|
||
|
/>
|
||
|
|
||
|
<h4>{t('About')}</h4>
|
||
|
<SimplifiedEditor
|
||
|
variant="bordered"
|
||
|
onlyBubbleControls={true}
|
||
|
smallHeight={true}
|
||
|
placeholder={t('About')}
|
||
|
label={t('About')}
|
||
|
initialContent={form.about || ''}
|
||
|
autoFocus={false}
|
||
|
onChange={(value) => updateFormField('about', value)}
|
||
|
/>
|
||
|
<div class={clsx(styles.multipleControls, 'pretty-form__item')}>
|
||
|
<div class={styles.multipleControlsHeader}>
|
||
|
<h4>{t('Social networks')}</h4>
|
||
|
<button type="button" class="button" onClick={() => setAddLinkForm(!addLinkForm())}>
|
||
|
+
|
||
|
</button>
|
||
|
</div>
|
||
|
<Show when={addLinkForm()}>
|
||
|
<SocialNetworkInput
|
||
|
isExist={false}
|
||
|
autofocus={true}
|
||
|
handleInput={(value) => handleChangeSocial(value)}
|
||
|
/>
|
||
|
<Show when={incorrectUrl()}>
|
||
|
<p class="form-message form-message--error">{t('It does not look like url')}</p>
|
||
|
</Show>
|
||
|
</Show>
|
||
|
<For each={profileSocialLinks(social())}>
|
||
|
{(network) => (
|
||
|
<SocialNetworkInput
|
||
|
class={styles.socialInput}
|
||
|
link={network.link}
|
||
|
network={network.name}
|
||
|
handleInput={(value) => handleChangeSocial(value)}
|
||
|
isExist={!network.isPlaceholder}
|
||
|
slug={form.slug}
|
||
|
handleDelete={() => handleDeleteSocialLink(network.link)}
|
||
|
/>
|
||
|
)}
|
||
|
</For>
|
||
|
</div>
|
||
|
</form>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
<Show when={isFloatingPanelVisible()}>
|
||
|
<div class={styles.formActions}>
|
||
|
<div class="wide-container">
|
||
|
<div class="row">
|
||
|
<div class="col-md-19 offset-md-5">
|
||
|
<div class="row">
|
||
|
<div class="col-md-20 col-lg-18 col-xl-16">
|
||
|
<div class={styles.content}>
|
||
|
<Button variant="light" value={t('Cancel')} onClick={handleCancel} />
|
||
|
<Button onClick={handleSubmit} variant="primary" value={t('Save settings')} />
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</Show>
|
||
|
</>
|
||
|
</Show>
|
||
|
)
|
||
|
}
|