diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 35a11b7d..d93d00b4 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -103,6 +103,7 @@ "Discussion rules": "Discussion rules", "Discussion rules in social networks": "Discussion rules", "Discussions": "Discussions", + "Do you really want to reset all changes?": "Do you really want to reset all changes?", "Dogma": "Dogma", "Draft successfully deleted": "Draft successfully deleted", "Drafts": "Drafts", @@ -329,6 +330,7 @@ "Terms of use": "Site rules", "Text checking": "Text checking", "Thank you": "Thank you", + "The address is already taken": "The address is already taken", "Theory": "Theory", "There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?", "There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?", diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index a4daed51..de85f893 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -106,6 +106,7 @@ "Discussion rules": "Правила дискуссий", "Discussion rules in social networks": "Правила сообществ самиздата в соцсетях", "Discussions": "Дискуссии", + "Do you really want to reset all changes?": "Вы действительно хотите сбросить все изменения?", "Dogma": "Догма", "Draft successfully deleted": "Черновик успешно удален", "Drafts": "Черновики", @@ -347,6 +348,7 @@ "Terms of use": "Правила сайта", "Text checking": "Проверка текста", "Thank you": "Благодарности", + "The address is already taken": "Адрес уже занят", "Theory": "Теории", "There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?": "В настройках вашего профиля есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?", "There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?": "В настройках публикации есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?", diff --git a/src/components/ProfileSettings/ProfileSettings.tsx b/src/components/ProfileSettings/ProfileSettings.tsx new file mode 100644 index 00000000..08e5aeb7 --- /dev/null +++ b/src/components/ProfileSettings/ProfileSettings.tsx @@ -0,0 +1,359 @@ +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(false) + const [incorrectUrl, setIncorrectUrl] = createSignal(false) + const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false) + const [uploadError, setUploadError] = createSignal(false) + const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false) + const [hostname, setHostname] = createSignal(null) + const [slugError, setSlugError] = createSignal() + const [nameError, setNameError] = createSignal() + + 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 ( + 0 && isFormInitialized()} fallback={}> + <> +
+
+
+
+ +
+
+
+
+
+

{t('Profile settings')}

+

{t('Here you can customize your profile the way you want.')}

+
+

{t('Userpic')}

+
+
+ + + + + +
+
+ + {(triggerRef: (el) => void) => ( + + )} + + + {(triggerRef: (el) => void) => ( + + )} + +
+ + + + {t('Here you can upload your photo')} + + +
+ +
{t('Upload error')}
+
+
+

{t('Name')}

+

+ {t( + 'Your name will appear on your profile page and as your signature in publications, comments and responses.', + )} +

+
+ updateFormField('name', event.currentTarget.value)} + value={form.name} + ref={(el) => (nameInputRef.current = el)} + /> + + +
+ {t(`${nameError()}`)} +
+
+
+ +

{t('Address on Discourse')}

+
+
+ +
+ updateFormField('slug', event.currentTarget.value)} + value={form.slug} + ref={(el) => (slugInputRef.current = el)} + class="nolabel" + /> + +

{t(`${slugError()}`)}

+
+
+
+
+ +

{t('Introduce')}

+ updateFormField('bio', value)} + initialValue={form.bio || ''} + allowEnterKey={false} + maxLength={120} + /> + +

{t('About')}

+ updateFormField('about', value)} + /> +
+
+

{t('Social networks')}

+ +
+ + handleChangeSocial(value)} + /> + +

{t('It does not look like url')}

+
+
+ + {(network) => ( + handleChangeSocial(value)} + isExist={!network.isPlaceholder} + slug={form.slug} + handleDelete={() => handleDeleteSocialLink(network.link)} + /> + )} + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + ) +} diff --git a/src/components/ProfileSettings/index.ts b/src/components/ProfileSettings/index.ts new file mode 100644 index 00000000..af814349 --- /dev/null +++ b/src/components/ProfileSettings/index.ts @@ -0,0 +1 @@ +export { ProfileSettings } from './ProfileSettings' diff --git a/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx b/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx index 2c725f06..4541e1b2 100644 --- a/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx +++ b/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx @@ -28,8 +28,9 @@ const GrowingTextarea = (props: Props) => { setValue(props.initialValue ?? '') } }) - const handleChangeValue = (event) => { - setValue(event.target.value) + const handleChangeValue = (textareaValue) => { + setValue(textareaValue) + props.value(textareaValue) } const handleKeyDown = async (event) => { @@ -66,8 +67,7 @@ const GrowingTextarea = (props: Props) => { : props.initialValue } onKeyDown={props.allowEnterKey ? handleKeyDown : null} - onInput={(event) => handleChangeValue(event)} - onChange={(event) => props.value(event.target.value)} + onInput={(event) => handleChangeValue(event.target.value)} placeholder={props.placeholder} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} diff --git a/src/components/_shared/SocialNetworkInput/SocialNetworkInput.tsx b/src/components/_shared/SocialNetworkInput/SocialNetworkInput.tsx index 98f228a6..c47b0597 100644 --- a/src/components/_shared/SocialNetworkInput/SocialNetworkInput.tsx +++ b/src/components/_shared/SocialNetworkInput/SocialNetworkInput.tsx @@ -10,7 +10,7 @@ type Props = { network?: string link?: string isExist: boolean - handleChange: (value: string) => void + handleInput: (value: string) => void handleDelete?: () => void slug?: string autofocus?: boolean @@ -33,7 +33,7 @@ export const SocialNetworkInput = (props: Props) => { class={styles.input} type="text" value={props.isExist ? props.link : null} - onChange={(event) => props.handleChange(event.currentTarget.value)} + onInput={(event) => props.handleInput(event.currentTarget.value)} placeholder={props.autofocus ? null : `${props.link}${props.slug}`} /> diff --git a/src/context/profile.tsx b/src/context/profile.tsx index 3050dbc0..1af825ad 100644 --- a/src/context/profile.tsx +++ b/src/context/profile.tsx @@ -1,6 +1,6 @@ import type { ProfileInput } from '../graphql/types.gen' -import { createEffect, createMemo, createSignal } from 'solid-js' +import { createContext, createEffect, createMemo, JSX, useContext } from 'solid-js' import { createStore } from 'solid-js/store' import { loadAuthor, useAuthorsStore } from '../stores/zine/authors' @@ -8,54 +8,58 @@ import { apiClient } from '../utils/apiClient' import { useSession } from './session' +type ProfileFormContextType = { + form: ProfileInput + actions: { + setForm: (profile: ProfileInput) => void + submit: (profile: ProfileInput) => Promise + updateFormField: (fieldName: string, value: string, remove?: boolean) => void + } +} + +const ProfileFormContext = createContext() + +export function useProfileForm() { + return useContext(ProfileFormContext) +} + const userpicUrl = (userpic: string) => { if (userpic.includes('assets.discours.io')) { return userpic.replace('100x', '500x500') } return userpic } -const useProfileForm = () => { +export const ProfileFormProvider = (props: { children: JSX.Element }) => { const { session } = useSession() + const [form, setForm] = createStore({}) + const currentSlug = createMemo(() => session()?.user?.slug) - const { authorEntities } = useAuthorsStore({ authors: [] }) - const currentAuthor = createMemo(() => authorEntities()[currentSlug()]) - const [slugError, setSlugError] = createSignal() const submit = async (profile: ProfileInput) => { - const response = await apiClient.updateProfile(profile) - if (response.error) { - setSlugError(response.error) - return response.error + try { + await apiClient.updateProfile(profile) + } catch (error) { + console.error('[ProfileFormProvider]', error) + throw error } - return response } - const [form, setForm] = createStore({ - name: '', - bio: '', - about: '', - slug: '', - userpic: '', - links: [], - }) - createEffect(async () => { if (!currentSlug()) return try { - await loadAuthor({ slug: currentSlug() }) + const currentAuthor = await loadAuthor({ slug: currentSlug() }) setForm({ - name: currentAuthor()?.name, - slug: currentAuthor()?.slug, - bio: currentAuthor()?.bio, - about: currentAuthor()?.about, - userpic: userpicUrl(currentAuthor()?.userpic), - links: currentAuthor()?.links, + name: currentAuthor.name, + slug: currentAuthor.slug, + bio: currentAuthor.bio, + about: currentAuthor.about, + userpic: userpicUrl(currentAuthor.userpic), + links: currentAuthor.links, }) } catch (error) { console.error(error) } }) - const updateFormField = (fieldName: string, value: string, remove?: boolean) => { if (fieldName === 'links') { if (remove) { @@ -73,7 +77,14 @@ const useProfileForm = () => { } } - return { form, submit, updateFormField, slugError } -} + const value: ProfileFormContextType = { + form, + actions: { + submit, + updateFormField, + setForm, + }, + } -export { useProfileForm } + return {props.children} +} diff --git a/src/pages/profile/Settings.module.scss b/src/pages/profile/Settings.module.scss index 50473218..22040ba6 100644 --- a/src/pages/profile/Settings.module.scss +++ b/src/pages/profile/Settings.module.scss @@ -279,3 +279,25 @@ h5 { .socialInput { margin-top: 1rem; } + +.formActions { + background: var(--background-color); + position: sticky; + z-index: 12; + bottom: 0; + border-top: 2px solid var(--black-100); + margin-bottom: -40px; + + .content { + display: flex; + align-items: center; + justify-content: space-around; + flex-direction: row; + padding: 1rem 0; + gap: 1rem; + } + + .cancel { + margin-right: auto; + } +} diff --git a/src/pages/profile/profileSettings.page.tsx b/src/pages/profile/profileSettings.page.tsx index a51b5084..81398499 100644 --- a/src/pages/profile/profileSettings.page.tsx +++ b/src/pages/profile/profileSettings.page.tsx @@ -1,331 +1,18 @@ -import { createFileUploader } from '@solid-primitives/upload' -import { clsx } from 'clsx' -import deepEqual from 'fast-deep-equal' -import { For, createSignal, Show, onMount, onCleanup, createEffect, Switch, Match, lazy } from 'solid-js' -import { createStore } from 'solid-js/store' - -import FloatingPanel from '../../components/_shared/FloatingPanel/FloatingPanel' -import { Icon } from '../../components/_shared/Icon' -import { Loading } from '../../components/_shared/Loading' import { PageLayout } from '../../components/_shared/PageLayout' -import { Popover } from '../../components/_shared/Popover' -import { SocialNetworkInput } from '../../components/_shared/SocialNetworkInput' import { AuthGuard } from '../../components/AuthGuard' -import { ProfileSettingsNavigation } from '../../components/Nav/ProfileSettingsNavigation' +import { ProfileSettings } from '../../components/ProfileSettings' 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 styles from './Settings.module.scss' - -const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor')) -const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTextarea/GrowingTextarea')) +import { ProfileFormProvider } from '../../context/profile' export const ProfileSettingsPage = () => { const { t } = useLocalize() - const [addLinkForm, setAddLinkForm] = createSignal(false) - const [incorrectUrl, setIncorrectUrl] = createSignal(false) - - const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false) - const [uploadError, setUploadError] = createSignal(false) - const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false) - - const { - actions: { showSnackbar }, - } = useSnackbar() - - const { - actions: { loadSession }, - } = useSession() - - const { form, updateFormField, submit, slugError } = useProfileForm() - const [prevForm, setPrevForm] = createStore(clone(form)) - const [social, setSocial] = createSignal(form.links) - const handleChangeSocial = (value: string) => { - if (validateUrl(value)) { - updateFormField('links', value) - setAddLinkForm(false) - } else { - setIncorrectUrl(true) - } - } - - const handleSubmit = async (event: Event) => { - event.preventDefault() - try { - await submit(form) - setPrevForm(clone(form)) - showSnackbar({ body: t('Profile successfully saved') }) - } catch { - showSnackbar({ type: 'error', body: t('Error') }) - } - - loadSession() - } - - 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) - setIsFloatingPanelVisible(true) - } catch (error) { - setUploadError(true) - console.error('[upload avatar] error', error) - } - }) - } - - const [hostname, setHostname] = createSignal(null) - - 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)) - }) - - const handleSaveProfile = () => { - setIsFloatingPanelVisible(false) - setPrevForm(clone(form)) - } - - createEffect(() => { - if (!deepEqual(form, prevForm)) { - setIsFloatingPanelVisible(true) - } - }) - - const handleDeleteSocialLink = (link) => { - updateFormField('links', link, true) - } - - createEffect(() => { - setSocial(form.links) - }) return ( - -
-
-
-
- -
-
-
-
-
-

{t('Profile settings')}

-

{t('Here you can customize your profile the way you want.')}

-
-

{t('Userpic')}

-
-
- - - - - -
-
- - {(triggerRef: (el) => void) => ( - - )} - - - {(triggerRef: (el) => void) => ( - - )} - -
- - - - {t('Here you can upload your photo')} - - -
- -
{t('Upload error')}
-
-
-

{t('Name')}

-

- {t( - 'Your name will appear on your profile page and as your signature in publications, comments and responses.', - )} -

-
- updateFormField('name', event.currentTarget.value)} - value={form.name} - /> - -
- -

{t('Address on Discourse')}

-
-
- -
- updateFormField('slug', event.currentTarget.value)} - value={form.slug} - class="nolabel" - /> - -

{t(`${slugError()}`)}

-
-
-
-
- -

{t('Introduce')}

- updateFormField('bio', value)} - initialValue={form.bio} - allowEnterKey={false} - maxLength={120} - /> - -

{t('About')}

- updateFormField('about', value)} - /> - {/*Нет реализации полей на бэке*/} - {/*

{t('How can I help/skills')}

*/} - {/*
*/} - {/* */} - {/*
*/} - {/*

{t('Where')}

*/} - {/*
*/} - {/* */} - {/* */} - {/*
*/} - - {/*

{t('Date of Birth')}

*/} - {/*
*/} - {/* */} - {/*
*/} - -
-
-

{t('Social networks')}

- -
- - handleChangeSocial(value)} - /> - -

{t('It does not look like url')}

-
-
- - {(network) => ( - handleChangeSocial(value)} - isExist={!network.isPlaceholder} - slug={form.slug} - handleDelete={() => handleDeleteSocialLink(network.link)} - /> - )} - -
-
- setIsFloatingPanelVisible(false)} - /> - -
-
-
-
-
- + + + ) diff --git a/src/stores/zine/authors.ts b/src/stores/zine/authors.ts index 816a043b..e031bf69 100644 --- a/src/stores/zine/authors.ts +++ b/src/stores/zine/authors.ts @@ -57,9 +57,10 @@ const addAuthors = (authors: Author[]) => { ) } -export const loadAuthor = async ({ slug }: { slug: string }): Promise => { +export const loadAuthor = async ({ slug }: { slug: string }): Promise => { const author = await apiClient.getAuthor({ slug }) addAuthors([author]) + return author } export const addAuthorsByTopic = (newAuthorsByTopic: { [topicSlug: string]: Author[] }) => { diff --git a/src/utils/apiClient.ts b/src/utils/apiClient.ts index 8ba484f8..5256ee95 100644 --- a/src/utils/apiClient.ts +++ b/src/utils/apiClient.ts @@ -70,6 +70,7 @@ type ApiErrorCode = | 'user_already_exists' | 'token_expired' | 'token_invalid' + | 'duplicate_slug' export class ApiError extends Error { code: ApiErrorCode @@ -249,6 +250,15 @@ export const apiClient = { }, updateProfile: async (input: ProfileInput) => { const response = await privateGraphQLClient.mutation(updateProfile, { profile: input }).toPromise() + if (response.error) { + if ( + response.error.message.includes('duplicate key value violates unique constraint "user_slug_key"') + ) { + throw new ApiError('duplicate_slug', response.error.message) + } + throw new ApiError('unknown', response.error.message) + } + return response.data.updateProfile }, getTopic: async ({ slug }: { slug: string }): Promise => {