parent
57a289e383
commit
954d964384
|
@ -103,6 +103,7 @@
|
||||||
"Discussion rules": "Discussion rules",
|
"Discussion rules": "Discussion rules",
|
||||||
"Discussion rules in social networks": "Discussion rules",
|
"Discussion rules in social networks": "Discussion rules",
|
||||||
"Discussions": "Discussions",
|
"Discussions": "Discussions",
|
||||||
|
"Do you really want to reset all changes?": "Do you really want to reset all changes?",
|
||||||
"Dogma": "Dogma",
|
"Dogma": "Dogma",
|
||||||
"Draft successfully deleted": "Draft successfully deleted",
|
"Draft successfully deleted": "Draft successfully deleted",
|
||||||
"Drafts": "Drafts",
|
"Drafts": "Drafts",
|
||||||
|
@ -329,6 +330,7 @@
|
||||||
"Terms of use": "Site rules",
|
"Terms of use": "Site rules",
|
||||||
"Text checking": "Text checking",
|
"Text checking": "Text checking",
|
||||||
"Thank you": "Thank you",
|
"Thank you": "Thank you",
|
||||||
|
"The address is already taken": "The address is already taken",
|
||||||
"Theory": "Theory",
|
"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 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?",
|
"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?",
|
||||||
|
|
|
@ -106,6 +106,7 @@
|
||||||
"Discussion rules": "Правила дискуссий",
|
"Discussion rules": "Правила дискуссий",
|
||||||
"Discussion rules in social networks": "Правила сообществ самиздата в соцсетях",
|
"Discussion rules in social networks": "Правила сообществ самиздата в соцсетях",
|
||||||
"Discussions": "Дискуссии",
|
"Discussions": "Дискуссии",
|
||||||
|
"Do you really want to reset all changes?": "Вы действительно хотите сбросить все изменения?",
|
||||||
"Dogma": "Догма",
|
"Dogma": "Догма",
|
||||||
"Draft successfully deleted": "Черновик успешно удален",
|
"Draft successfully deleted": "Черновик успешно удален",
|
||||||
"Drafts": "Черновики",
|
"Drafts": "Черновики",
|
||||||
|
@ -347,6 +348,7 @@
|
||||||
"Terms of use": "Правила сайта",
|
"Terms of use": "Правила сайта",
|
||||||
"Text checking": "Проверка текста",
|
"Text checking": "Проверка текста",
|
||||||
"Thank you": "Благодарности",
|
"Thank you": "Благодарности",
|
||||||
|
"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?": "В настройках публикации есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?",
|
||||||
|
|
359
src/components/ProfileSettings/ProfileSettings.tsx
Normal file
359
src/components/ProfileSettings/ProfileSettings.tsx
Normal file
|
@ -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<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>
|
||||||
|
)
|
||||||
|
}
|
1
src/components/ProfileSettings/index.ts
Normal file
1
src/components/ProfileSettings/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { ProfileSettings } from './ProfileSettings'
|
|
@ -28,8 +28,9 @@ const GrowingTextarea = (props: Props) => {
|
||||||
setValue(props.initialValue ?? '')
|
setValue(props.initialValue ?? '')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const handleChangeValue = (event) => {
|
const handleChangeValue = (textareaValue) => {
|
||||||
setValue(event.target.value)
|
setValue(textareaValue)
|
||||||
|
props.value(textareaValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = async (event) => {
|
const handleKeyDown = async (event) => {
|
||||||
|
@ -66,8 +67,7 @@ const GrowingTextarea = (props: Props) => {
|
||||||
: props.initialValue
|
: props.initialValue
|
||||||
}
|
}
|
||||||
onKeyDown={props.allowEnterKey ? handleKeyDown : null}
|
onKeyDown={props.allowEnterKey ? handleKeyDown : null}
|
||||||
onInput={(event) => handleChangeValue(event)}
|
onInput={(event) => handleChangeValue(event.target.value)}
|
||||||
onChange={(event) => props.value(event.target.value)}
|
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
|
|
|
@ -10,7 +10,7 @@ type Props = {
|
||||||
network?: string
|
network?: string
|
||||||
link?: string
|
link?: string
|
||||||
isExist: boolean
|
isExist: boolean
|
||||||
handleChange: (value: string) => void
|
handleInput: (value: string) => void
|
||||||
handleDelete?: () => void
|
handleDelete?: () => void
|
||||||
slug?: string
|
slug?: string
|
||||||
autofocus?: boolean
|
autofocus?: boolean
|
||||||
|
@ -33,7 +33,7 @@ export const SocialNetworkInput = (props: Props) => {
|
||||||
class={styles.input}
|
class={styles.input}
|
||||||
type="text"
|
type="text"
|
||||||
value={props.isExist ? props.link : null}
|
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}`}
|
placeholder={props.autofocus ? null : `${props.link}${props.slug}`}
|
||||||
/>
|
/>
|
||||||
<Show when={props.isExist}>
|
<Show when={props.isExist}>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ProfileInput } from '../graphql/types.gen'
|
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 { createStore } from 'solid-js/store'
|
||||||
|
|
||||||
import { loadAuthor, useAuthorsStore } from '../stores/zine/authors'
|
import { loadAuthor, useAuthorsStore } from '../stores/zine/authors'
|
||||||
|
@ -8,54 +8,58 @@ import { apiClient } from '../utils/apiClient'
|
||||||
|
|
||||||
import { useSession } from './session'
|
import { useSession } from './session'
|
||||||
|
|
||||||
|
type ProfileFormContextType = {
|
||||||
|
form: ProfileInput
|
||||||
|
actions: {
|
||||||
|
setForm: (profile: ProfileInput) => void
|
||||||
|
submit: (profile: ProfileInput) => Promise<void>
|
||||||
|
updateFormField: (fieldName: string, value: string, remove?: boolean) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileFormContext = createContext<ProfileFormContextType>()
|
||||||
|
|
||||||
|
export function useProfileForm() {
|
||||||
|
return useContext(ProfileFormContext)
|
||||||
|
}
|
||||||
|
|
||||||
const userpicUrl = (userpic: string) => {
|
const userpicUrl = (userpic: string) => {
|
||||||
if (userpic.includes('assets.discours.io')) {
|
if (userpic.includes('assets.discours.io')) {
|
||||||
return userpic.replace('100x', '500x500')
|
return userpic.replace('100x', '500x500')
|
||||||
}
|
}
|
||||||
return userpic
|
return userpic
|
||||||
}
|
}
|
||||||
const useProfileForm = () => {
|
export const ProfileFormProvider = (props: { children: JSX.Element }) => {
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
|
const [form, setForm] = createStore<ProfileInput>({})
|
||||||
|
|
||||||
const currentSlug = createMemo(() => session()?.user?.slug)
|
const currentSlug = createMemo(() => session()?.user?.slug)
|
||||||
const { authorEntities } = useAuthorsStore({ authors: [] })
|
|
||||||
const currentAuthor = createMemo(() => authorEntities()[currentSlug()])
|
|
||||||
const [slugError, setSlugError] = createSignal<string>()
|
|
||||||
|
|
||||||
const submit = async (profile: ProfileInput) => {
|
const submit = async (profile: ProfileInput) => {
|
||||||
const response = await apiClient.updateProfile(profile)
|
try {
|
||||||
if (response.error) {
|
await apiClient.updateProfile(profile)
|
||||||
setSlugError(response.error)
|
} catch (error) {
|
||||||
return response.error
|
console.error('[ProfileFormProvider]', error)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
return response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [form, setForm] = createStore<ProfileInput>({
|
|
||||||
name: '',
|
|
||||||
bio: '',
|
|
||||||
about: '',
|
|
||||||
slug: '',
|
|
||||||
userpic: '',
|
|
||||||
links: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(async () => {
|
createEffect(async () => {
|
||||||
if (!currentSlug()) return
|
if (!currentSlug()) return
|
||||||
try {
|
try {
|
||||||
await loadAuthor({ slug: currentSlug() })
|
const currentAuthor = await loadAuthor({ slug: currentSlug() })
|
||||||
setForm({
|
setForm({
|
||||||
name: currentAuthor()?.name,
|
name: currentAuthor.name,
|
||||||
slug: currentAuthor()?.slug,
|
slug: currentAuthor.slug,
|
||||||
bio: currentAuthor()?.bio,
|
bio: currentAuthor.bio,
|
||||||
about: currentAuthor()?.about,
|
about: currentAuthor.about,
|
||||||
userpic: userpicUrl(currentAuthor()?.userpic),
|
userpic: userpicUrl(currentAuthor.userpic),
|
||||||
links: currentAuthor()?.links,
|
links: currentAuthor.links,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateFormField = (fieldName: string, value: string, remove?: boolean) => {
|
const updateFormField = (fieldName: string, value: string, remove?: boolean) => {
|
||||||
if (fieldName === 'links') {
|
if (fieldName === 'links') {
|
||||||
if (remove) {
|
if (remove) {
|
||||||
|
@ -73,7 +77,14 @@ const useProfileForm = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { form, submit, updateFormField, slugError }
|
const value: ProfileFormContextType = {
|
||||||
}
|
form,
|
||||||
|
actions: {
|
||||||
|
submit,
|
||||||
|
updateFormField,
|
||||||
|
setForm,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export { useProfileForm }
|
return <ProfileFormContext.Provider value={value}>{props.children}</ProfileFormContext.Provider>
|
||||||
|
}
|
||||||
|
|
|
@ -279,3 +279,25 @@ h5 {
|
||||||
.socialInput {
|
.socialInput {
|
||||||
margin-top: 1rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 { PageLayout } from '../../components/_shared/PageLayout'
|
||||||
import { Popover } from '../../components/_shared/Popover'
|
|
||||||
import { SocialNetworkInput } from '../../components/_shared/SocialNetworkInput'
|
|
||||||
import { AuthGuard } from '../../components/AuthGuard'
|
import { AuthGuard } from '../../components/AuthGuard'
|
||||||
import { ProfileSettingsNavigation } from '../../components/Nav/ProfileSettingsNavigation'
|
import { ProfileSettings } from '../../components/ProfileSettings'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { useProfileForm } from '../../context/profile'
|
import { ProfileFormProvider } 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'))
|
|
||||||
|
|
||||||
export const ProfileSettingsPage = () => {
|
export const ProfileSettingsPage = () => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
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 {
|
|
||||||
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<string | null>(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 (
|
return (
|
||||||
<PageLayout title={t('Profile')}>
|
<PageLayout title={t('Profile')}>
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<Show when={form}>
|
<ProfileFormProvider>
|
||||||
<div class="wide-container">
|
<ProfileSettings />
|
||||||
<div class="row">
|
</ProfileFormProvider>
|
||||||
<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 onSubmit={handleSubmit} 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')}
|
|
||||||
onChange={(event) => updateFormField('name', event.currentTarget.value)}
|
|
||||||
value={form.name}
|
|
||||||
/>
|
|
||||||
<label for="username">{t('Name')}</label>
|
|
||||||
</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"
|
|
||||||
onChange={(event) => updateFormField('slug', event.currentTarget.value)}
|
|
||||||
value={form.slug}
|
|
||||||
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}
|
|
||||||
onChange={(value) => updateFormField('about', value)}
|
|
||||||
/>
|
|
||||||
{/*Нет реализации полей на бэке*/}
|
|
||||||
{/*<h4>{t('How can I help/skills')}</h4>*/}
|
|
||||||
{/*<div class="pretty-form__item">*/}
|
|
||||||
{/* <input type="text" name="skills" id="skills" />*/}
|
|
||||||
{/*</div>*/}
|
|
||||||
{/*<h4>{t('Where')}</h4>*/}
|
|
||||||
{/*<div class="pretty-form__item">*/}
|
|
||||||
{/* <input type="text" name="location" id="location" placeholder="Откуда" />*/}
|
|
||||||
{/* <label for="location">{t('Where')}</label>*/}
|
|
||||||
{/*</div>*/}
|
|
||||||
|
|
||||||
{/*<h4>{t('Date of Birth')}</h4>*/}
|
|
||||||
{/*<div class="pretty-form__item">*/}
|
|
||||||
{/* <input*/}
|
|
||||||
{/* type="date"*/}
|
|
||||||
{/* name="birthdate"*/}
|
|
||||||
{/* id="birthdate"*/}
|
|
||||||
{/* placeholder="Дата рождения"*/}
|
|
||||||
{/* class="nolabel"*/}
|
|
||||||
{/* />*/}
|
|
||||||
{/*</div>*/}
|
|
||||||
|
|
||||||
<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}
|
|
||||||
handleChange={(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}
|
|
||||||
handleChange={(value) => handleChangeSocial(value)}
|
|
||||||
isExist={!network.isPlaceholder}
|
|
||||||
slug={form.slug}
|
|
||||||
handleDelete={() => handleDeleteSocialLink(network.link)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
<br />
|
|
||||||
<FloatingPanel
|
|
||||||
isVisible={isFloatingPanelVisible()}
|
|
||||||
confirmTitle={t('Save settings')}
|
|
||||||
confirmAction={handleSaveProfile}
|
|
||||||
declineTitle={t('Cancel')}
|
|
||||||
declineAction={() => setIsFloatingPanelVisible(false)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
|
|
|
@ -57,9 +57,10 @@ const addAuthors = (authors: Author[]) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadAuthor = async ({ slug }: { slug: string }): Promise<void> => {
|
export const loadAuthor = async ({ slug }: { slug: string }): Promise<Author> => {
|
||||||
const author = await apiClient.getAuthor({ slug })
|
const author = await apiClient.getAuthor({ slug })
|
||||||
addAuthors([author])
|
addAuthors([author])
|
||||||
|
return author
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addAuthorsByTopic = (newAuthorsByTopic: { [topicSlug: string]: Author[] }) => {
|
export const addAuthorsByTopic = (newAuthorsByTopic: { [topicSlug: string]: Author[] }) => {
|
||||||
|
|
|
@ -70,6 +70,7 @@ type ApiErrorCode =
|
||||||
| 'user_already_exists'
|
| 'user_already_exists'
|
||||||
| 'token_expired'
|
| 'token_expired'
|
||||||
| 'token_invalid'
|
| 'token_invalid'
|
||||||
|
| 'duplicate_slug'
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
code: ApiErrorCode
|
code: ApiErrorCode
|
||||||
|
@ -249,6 +250,15 @@ export const apiClient = {
|
||||||
},
|
},
|
||||||
updateProfile: async (input: ProfileInput) => {
|
updateProfile: async (input: ProfileInput) => {
|
||||||
const response = await privateGraphQLClient.mutation(updateProfile, { profile: input }).toPromise()
|
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
|
return response.data.updateProfile
|
||||||
},
|
},
|
||||||
getTopic: async ({ slug }: { slug: string }): Promise<Topic> => {
|
getTopic: async ({ slug }: { slug: string }): Promise<Topic> => {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user