webapp/src/components/ProfileSettings/ProfileSettings.tsx

431 lines
16 KiB
TypeScript
Raw Normal View History

2024-06-24 17:50:27 +00:00
import { UploadFile, createFileUploader } from '@solid-primitives/upload'
import { clsx } from 'clsx'
import deepEqual from 'fast-deep-equal'
import {
For,
Match,
Show,
Switch,
createEffect,
createSignal,
lazy,
2024-04-04 06:02:34 +00:00
on,
onCleanup,
onMount,
2024-04-04 06:02:34 +00:00
} from 'solid-js'
import { createStore } from 'solid-js/store'
import { useLocalize } from '../../context/localize'
2024-06-24 17:50:27 +00:00
import { useProfile } from '../../context/profile'
2023-12-15 13:45:34 +00:00
import { useSession } from '../../context/session'
2024-06-24 17:50:27 +00:00
import { useSnackbar, useUI } from '../../context/ui'
import { InputMaybe, ProfileInput } from '../../graphql/schema/core.gen'
import styles from '../../pages/profile/Settings.module.scss'
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'
2024-02-04 11:25:21 +00:00
import { Modal } from '../Nav/Modal'
import { ProfileSettingsNavigation } from '../Nav/ProfileSettingsNavigation'
import { Button } from '../_shared/Button'
import { Icon } from '../_shared/Icon'
2024-01-29 09:10:30 +00:00
import { ImageCropper } from '../_shared/ImageCropper'
import { Loading } from '../_shared/Loading'
import { Popover } from '../_shared/Popover'
import { SocialNetworkInput } from '../_shared/SocialNetworkInput'
const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor'))
const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTextarea/GrowingTextarea'))
2024-06-24 17:50:27 +00:00
function filterNulls(arr: InputMaybe<string>[]): string[] {
return arr.filter((item): item is string => item !== null && item !== undefined)
}
export const ProfileSettings = () => {
const { t } = useLocalize()
const [prevForm, setPrevForm] = createStore<ProfileInput>({})
const [isFormInitialized, setIsFormInitialized] = createSignal(false)
2024-04-01 04:16:53 +00:00
const [isSaving, setIsSaving] = createSignal(false)
2024-06-24 17:50:27 +00:00
const [social, setSocial] = createSignal<string[]>([])
const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false)
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false)
2024-06-24 17:50:27 +00:00
const [userpicFile, setUserpicFile] = createSignal<UploadFile>()
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>()
2024-06-24 17:50:27 +00:00
const { form, submit, updateFormField, setForm } = useProfile()
2024-02-04 17:40:15 +00:00
const { showSnackbar } = useSnackbar()
2024-06-05 16:11:48 +00:00
const { loadSession, session } = useSession()
2024-06-24 17:50:27 +00:00
const { showConfirm } = useUI()
const [clearAbout, setClearAbout] = createSignal(false)
2024-06-24 17:50:27 +00:00
const { showModal, hideModal } = useUI()
createEffect(() => {
if (Object.keys(form).length > 0 && !isFormInitialized()) {
setPrevForm(form)
2024-06-24 17:50:27 +00:00
const soc: string[] = filterNulls(form.links || [])
setSocial(soc)
setIsFormInitialized(true)
}
})
2024-06-24 17:50:27 +00:00
let slugInputRef: HTMLInputElement | null
let nameInputRef: HTMLInputElement | null
const handleChangeSocial = (value: string) => {
if (validateUrl(value)) {
updateFormField('links', value)
setAddLinkForm(false)
} else {
setIncorrectUrl(true)
}
}
2024-06-24 17:50:27 +00:00
const handleSubmit = async (event: MouseEvent | undefined) => {
event?.preventDefault()
2024-04-01 04:16:53 +00:00
setIsSaving(true)
2024-06-24 17:50:27 +00:00
if (nameInputRef?.value.length === 0) {
setNameError(t('Required'))
2024-06-24 17:50:27 +00:00
nameInputRef?.focus()
2024-04-01 04:16:53 +00:00
setIsSaving(false)
return
}
2024-06-24 17:50:27 +00:00
if (slugInputRef?.value.length === 0) {
setSlugError(t('Required'))
2024-06-24 17:50:27 +00:00
slugInputRef?.focus()
2024-04-01 04:16:53 +00:00
setIsSaving(false)
return
}
2024-04-01 04:16:53 +00:00
try {
await submit(form)
setPrevForm(clone(form))
showSnackbar({ body: t('Profile successfully saved') })
} catch (error) {
2024-06-24 17:50:27 +00:00
if (error?.toString().search('duplicate_slug')) {
setSlugError(t('The address is already taken'))
2024-06-24 17:50:27 +00:00
slugInputRef?.focus()
return
}
showSnackbar({ type: 'error', body: t('Error') })
2024-04-01 04:16:53 +00:00
} finally {
setIsSaving(false)
}
2023-12-20 16:54:20 +00:00
2024-06-05 16:11:48 +00:00
setTimeout(loadSession, 5000) // renews author's profile
}
const handleCancel = async () => {
const isConfirmed = await showConfirm({
confirmBody: t('Do you really want to reset all changes?'),
confirmButtonVariant: 'primary',
declineButtonVariant: 'secondary',
})
if (isConfirmed) {
setClearAbout(true)
setForm(clone(prevForm))
setClearAbout(false)
}
}
2024-01-25 12:41:25 +00:00
const handleCropAvatar = () => {
const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' })
selectFiles(([uploadFile]) => {
2024-06-24 17:50:27 +00:00
setUserpicFile(uploadFile as UploadFile)
2024-01-25 12:41:25 +00:00
showModal('cropImage')
})
}
2024-06-24 17:50:27 +00:00
const handleUploadAvatar = async (uploadFile: UploadFile) => {
2024-01-25 12:41:25 +00:00
try {
setUploadError(false)
setIsUserpicUpdating(true)
2024-06-24 17:50:27 +00:00
const result = await handleImageUpload(uploadFile, session()?.access_token || '')
2024-03-18 11:55:07 +00:00
updateFormField('pic', result.url)
2024-01-25 12:41:25 +00:00
2024-06-24 17:50:27 +00:00
setUserpicFile(undefined)
2024-01-25 12:41:25 +00:00
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
2024-06-24 17:50:27 +00:00
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
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(
on(
() => deepEqual(form, prevForm),
() => {
if (Object.keys(prevForm).length > 0) {
setIsFloatingPanelVisible(!deepEqual(form, prevForm))
}
},
),
)
2024-06-24 17:50:27 +00:00
const handleDeleteSocialLink = (link: string) => {
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" autocomplete="off">
<h4>{t('Userpic')}</h4>
<div class="pretty-form__item">
<div
2023-12-13 23:56:44 +00:00
class={clsx(styles.userpic, { [styles.hasControls]: form.pic })}
2024-01-25 12:41:25 +00:00
onClick={handleCropAvatar}
>
<Switch>
<Match when={isUserpicUpdating()}>
<Loading />
</Match>
2023-12-13 23:56:44 +00:00
<Match when={form.pic}>
<div
class={styles.userpicImage}
style={{
2024-06-24 17:50:27 +00:00
'background-image': `url(${getImageUrl(form.pic || '', {
width: 180,
height: 180,
})})`,
}}
/>
<div class={styles.controls}>
<Popover content={t('Delete userpic')}>
2024-06-24 17:50:27 +00:00
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
class={styles.control}
2023-12-13 23:56:44 +00:00
onClick={() => updateFormField('pic', '')}
>
<Icon name="close" />
</button>
)}
</Popover>
2024-01-25 12:41:25 +00:00
{/* @@TODO inspect popover below. onClick causes page refreshing */}
{/* <Popover content={t('Upload userpic')}>
2024-06-24 17:50:27 +00:00
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
class={styles.control}
2024-01-25 12:41:25 +00:00
onClick={() => handleCropAvatar()}
>
<Icon name="user-image-black" />
</button>
)}
2024-01-25 12:41:25 +00:00
</Popover> */}
</div>
</Match>
2023-12-13 23:56:44 +00:00
<Match when={!form.pic}>
<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="nameOfUser"
id="nameOfUser"
data-lpignore="true"
autocomplete="one-time-code"
placeholder={t('Name')}
onInput={(event) => updateFormField('name', event.currentTarget.value)}
2024-06-24 17:50:27 +00:00
value={form.name || ''}
ref={(el) => (nameInputRef = el)}
/>
<label for="nameOfUser">{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 Discours')}</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"
data-lpignore="true"
autocomplete="one-time-code2"
onInput={(event) => updateFormField('slug', event.currentTarget.value)}
2024-06-24 17:50:27 +00:00
value={form.slug || ''}
ref={(el) => (slugInputRef = 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
resetToInitial={clearAbout()}
noLimits={true}
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>
2023-11-27 21:44:58 +00:00
<Show when={social()}>
<For each={profileSocialLinks(social())}>
{(network) => (
<SocialNetworkInput
class={styles.socialInput}
link={network.link}
network={network.name}
handleInput={(value) => handleChangeSocial(value)}
isExist={!network.isPlaceholder}
2024-06-24 17:50:27 +00:00
slug={form.slug || ''}
2023-11-27 21:44:58 +00:00
handleDelete={() => handleDeleteSocialLink(network.link)}
/>
)}
</For>
</Show>
</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}>
2023-11-27 21:44:58 +00:00
<Button
class={styles.cancel}
variant="light"
2023-11-29 22:12:06 +00:00
value={
<>
<span class={styles.cancelLabel}>{t('Cancel changes')}</span>
<span class={styles.cancelLabelMobile}>{t('Cancel')}</span>
</>
}
2023-11-27 21:44:58 +00:00
onClick={handleCancel}
/>
2024-04-01 04:16:53 +00:00
<Button
onClick={handleSubmit}
variant="primary"
disabled={isSaving()}
value={isSaving() ? t('Saving...') : t('Save settings')}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
2024-06-24 17:50:27 +00:00
<Modal variant="medium" name="cropImage" onClose={() => setUserpicFile(undefined)}>
2024-01-25 12:41:25 +00:00
<h2>{t('Crop image')}</h2>
2024-06-24 17:50:27 +00:00
<Show when={Boolean(userpicFile())}>
2024-01-25 12:41:25 +00:00
<ImageCropper
2024-06-24 17:50:27 +00:00
uploadFile={userpicFile() as UploadFile}
2024-01-25 12:41:25 +00:00
onSave={(data) => {
handleUploadAvatar(data)
hideModal()
}}
onDecline={() => hideModal()}
/>
</Show>
</Modal>
</>
</Show>
)
}