Merge remote-tracking branch 'hub/main' into feature/sse-connect
This commit is contained in:
commit
1ea72651cf
|
@ -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",
|
||||
|
@ -330,6 +331,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?",
|
||||
|
|
|
@ -71,6 +71,7 @@
|
|||
"Collections": "Коллекции",
|
||||
"Come up with a subtitle for your story": "Придумайте подзаголовок вашей истории",
|
||||
"Come up with a title for your story": "Придумайте заголовок вашей истории",
|
||||
"Comment": "Комментировать",
|
||||
"Comment successfully deleted": "Комментарий успешно удален",
|
||||
"Comments": "Комментарии",
|
||||
"Communities": "Сообщества",
|
||||
|
@ -106,6 +107,7 @@
|
|||
"Discussion rules": "Правила дискуссий",
|
||||
"Discussion rules in social networks": "Правила сообществ самиздата в соцсетях",
|
||||
"Discussions": "Дискуссии",
|
||||
"Do you really want to reset all changes?": "Вы действительно хотите сбросить все изменения?",
|
||||
"Dogma": "Догма",
|
||||
"Draft successfully deleted": "Черновик успешно удален",
|
||||
"Drafts": "Черновики",
|
||||
|
@ -349,6 +351,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?": "В настройках публикации есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?",
|
||||
|
|
|
@ -43,8 +43,23 @@
|
|||
}
|
||||
}
|
||||
|
||||
.authorActionsLabel {
|
||||
@include media-breakpoint-down(sm) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.authorActionsLabelMobile {
|
||||
display: none;
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.authorDetails {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
flex: 1 100%;
|
||||
|
@ -147,7 +162,7 @@
|
|||
.authorSubscribeSocial {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin: 2rem 0;
|
||||
margin: 0.5rem 0 2rem -0.4rem;
|
||||
|
||||
.socialLink {
|
||||
border: none;
|
||||
|
@ -403,7 +418,6 @@
|
|||
@include media-breakpoint-down(sm) {
|
||||
flex: 1 100%;
|
||||
justify-content: center;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
|
@ -420,7 +434,6 @@
|
|||
flex-wrap: wrap;
|
||||
font-size: 1.4rem;
|
||||
margin-top: 1.5rem;
|
||||
gap: 1rem;
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
justify-content: center;
|
||||
|
@ -431,10 +444,18 @@
|
|||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
margin-right: 3rem;
|
||||
margin: 0 2% 1rem;
|
||||
vertical-align: top;
|
||||
border-bottom: unset !important;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.subscribersItem {
|
||||
position: relative;
|
||||
|
||||
|
|
|
@ -132,7 +132,9 @@ export const AuthorCard = (props: Props) => {
|
|||
<div class={clsx('col-md-15 col-xl-13', styles.authorDetails)}>
|
||||
<div class={styles.authorDetailsWrapper}>
|
||||
<div class={styles.authorName}>{name()}</div>
|
||||
<Show when={props.author.bio}>
|
||||
<div class={styles.authorAbout} innerHTML={props.author.bio} />
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
(props.followers && props.followers.length > 0) ||
|
||||
|
@ -233,7 +235,12 @@ export const AuthorCard = (props: Props) => {
|
|||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => redirectPage(router, 'profileSettings')}
|
||||
value={t('Edit profile')}
|
||||
value={
|
||||
<>
|
||||
<span class={styles.authorActionsLabel}>{t('Edit profile')}</span>
|
||||
<span class={styles.authorActionsLabelMobile}>{t('Edit')}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SharePopup
|
||||
title={props.author.name}
|
||||
|
|
|
@ -155,7 +155,7 @@
|
|||
}
|
||||
|
||||
.shoutDetails {
|
||||
align-items: end;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
&:hover {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,11 @@
|
|||
|
||||
.floor--group {
|
||||
background: #e8e5f0;
|
||||
padding: 4rem 0 3rem;
|
||||
padding: 4rem 0 0;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
.col-lg-12 {
|
||||
|
|
|
@ -17,7 +17,9 @@ export const Row3 = (props: {
|
|||
<div class="floor">
|
||||
<div class="wide-container">
|
||||
<div class="row">
|
||||
<Show when={props.header}>
|
||||
<div class="floor-header">{props.header}</div>
|
||||
</Show>
|
||||
<For each={props.articles}>
|
||||
{(a) => (
|
||||
<div class="col-md-8">
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
.confirmModal {
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
|
||||
.confirmModalTitle {
|
||||
@include font-size(3.2rem);
|
||||
|
||||
font-weight: 700;
|
||||
color: var(--default-color);
|
||||
font-weight: 700;
|
||||
margin: 0 3rem;
|
||||
text-align: center;
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
|
|
|
@ -61,6 +61,10 @@
|
|||
border-bottom: 2px solid #000;
|
||||
}
|
||||
}
|
||||
|
||||
> * {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mainLogo {
|
||||
|
|
371
src/components/ProfileSettings/ProfileSettings.tsx
Normal file
371
src/components/ProfileSettings/ProfileSettings.tsx
Normal file
|
@ -0,0 +1,371 @@
|
|||
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>
|
||||
<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}
|
||||
slug={form.slug}
|
||||
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}>
|
||||
<Button
|
||||
class={styles.cancel}
|
||||
variant="light"
|
||||
value={
|
||||
<>
|
||||
<span class={styles.cancelLabel}>{t('Cancel changes')}</span>
|
||||
<span class={styles.cancelLabelMobile}>{t('Cancel')}</span>
|
||||
</>
|
||||
}
|
||||
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'
|
|
@ -15,7 +15,7 @@ import { slugify } from '../../utils/slugify'
|
|||
import { DropArea } from '../_shared/DropArea'
|
||||
import { Icon } from '../_shared/Icon'
|
||||
import { Popover } from '../_shared/Popover'
|
||||
import { ImageSwiper } from '../_shared/SolidSwiper'
|
||||
import { EditorSwiper } from '../_shared/SolidSwiper'
|
||||
import { Editor, Panel } from '../Editor'
|
||||
import { AudioUploader } from '../Editor/AudioUploader'
|
||||
import { AutoSaveNotice } from '../Editor/AutoSaveNotice'
|
||||
|
@ -368,8 +368,7 @@ export const EditView = (props: Props) => {
|
|||
</div>
|
||||
|
||||
<Show when={props.shout.layout === 'image'}>
|
||||
<ImageSwiper
|
||||
editorMode={true}
|
||||
<EditorSwiper
|
||||
images={mediaItems()}
|
||||
onImageChange={handleMediaChange}
|
||||
onImageDelete={(index) => handleMediaDelete(index)}
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -60,10 +60,11 @@
|
|||
}
|
||||
|
||||
&.horizontalAnchorCenter {
|
||||
left: -24px;
|
||||
right: 0;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}`}
|
||||
/>
|
||||
<Show when={props.isExist}>
|
||||
|
|
|
@ -36,7 +36,7 @@ export const ArticleCardSwiper = (props: Props) => {
|
|||
ref={(el) => (mainSwipeRef.current = el)}
|
||||
centered-slides={true}
|
||||
observer={true}
|
||||
space-between={20}
|
||||
space-between={10}
|
||||
breakpoints={{
|
||||
576: { spaceBetween: 20, slidesPerView: 1.5 },
|
||||
992: { spaceBetween: 52, slidesPerView: 1.5 },
|
||||
|
@ -44,13 +44,11 @@ export const ArticleCardSwiper = (props: Props) => {
|
|||
round-lengths={true}
|
||||
loop={true}
|
||||
speed={800}
|
||||
/*
|
||||
autoplay={{
|
||||
disableOnInteraction: false,
|
||||
delay: 6000,
|
||||
pauseOnMouseEnter: true
|
||||
pauseOnMouseEnter: true,
|
||||
}}
|
||||
*/
|
||||
>
|
||||
<For each={props.slides}>
|
||||
{(slide, index) => (
|
||||
|
|
326
src/components/_shared/SolidSwiper/EditorSwiper.tsx
Normal file
326
src/components/_shared/SolidSwiper/EditorSwiper.tsx
Normal file
|
@ -0,0 +1,326 @@
|
|||
import { createFileUploader } from '@solid-primitives/upload'
|
||||
import { clsx } from 'clsx'
|
||||
import { createEffect, createSignal, For, Show, on, onMount, lazy } from 'solid-js'
|
||||
import SwiperCore, { Manipulation, Navigation, Pagination } from 'swiper'
|
||||
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import { useSnackbar } from '../../../context/snackbar'
|
||||
import { MediaItem, UploadedFile } from '../../../pages/types'
|
||||
import { composeMediaItems } from '../../../utils/composeMediaItems'
|
||||
import { getImageUrl } from '../../../utils/getImageUrl'
|
||||
import { handleImageUpload } from '../../../utils/handleImageUpload'
|
||||
import { validateFiles } from '../../../utils/validateFile'
|
||||
import { DropArea } from '../DropArea'
|
||||
import { Icon } from '../Icon'
|
||||
import { Image } from '../Image'
|
||||
import { Loading } from '../Loading'
|
||||
import { Popover } from '../Popover'
|
||||
|
||||
import { SwiperRef } from './swiper'
|
||||
|
||||
import styles from './Swiper.module.scss'
|
||||
|
||||
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
||||
|
||||
type Props = {
|
||||
images: MediaItem[]
|
||||
onImagesAdd?: (value: MediaItem[]) => void
|
||||
onImagesSorted?: (value: MediaItem[]) => void
|
||||
onImageDelete?: (mediaItemIndex: number) => void
|
||||
onImageChange?: (index: number, value: MediaItem) => void
|
||||
}
|
||||
|
||||
export const EditorSwiper = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [slideIndex, setSlideIndex] = createSignal(0)
|
||||
const [slideBody, setSlideBody] = createSignal<string>()
|
||||
|
||||
const mainSwipeRef: { current: SwiperRef } = { current: null }
|
||||
const thumbSwipeRef: { current: SwiperRef } = { current: null }
|
||||
|
||||
const {
|
||||
actions: { showSnackbar },
|
||||
} = useSnackbar()
|
||||
|
||||
const handleSlideDescriptionChange = (index: number, field: string, value) => {
|
||||
if (props.onImageChange) {
|
||||
props.onImageChange(index, { ...props.images[index], [field]: value })
|
||||
}
|
||||
}
|
||||
const swipeToUploaded = () => {
|
||||
setTimeout(() => {
|
||||
mainSwipeRef.current.swiper.slideTo(props.images.length - 1)
|
||||
}, 0)
|
||||
}
|
||||
const handleSlideChange = () => {
|
||||
thumbSwipeRef.current.swiper.slideTo(mainSwipeRef.current.swiper.activeIndex)
|
||||
setSlideIndex(mainSwipeRef.current.swiper.activeIndex)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.images.length,
|
||||
() => {
|
||||
mainSwipeRef.current?.swiper.update()
|
||||
thumbSwipeRef.current?.swiper.update()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const handleDropAreaUpload = (value: UploadedFile[]) => {
|
||||
props.onImagesAdd(composeMediaItems(value))
|
||||
swipeToUploaded()
|
||||
}
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
props.onImageDelete(index)
|
||||
|
||||
if (index === 0) {
|
||||
mainSwipeRef.current.swiper.update()
|
||||
} else {
|
||||
mainSwipeRef.current.swiper.slideTo(index - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const { selectFiles } = createFileUploader({
|
||||
multiple: true,
|
||||
accept: `image/*`,
|
||||
})
|
||||
|
||||
const initUpload = async (selectedFiles) => {
|
||||
const isValid = validateFiles('image', selectedFiles)
|
||||
|
||||
if (!isValid) {
|
||||
await showSnackbar({ type: 'error', body: t('Invalid file type') })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const results: UploadedFile[] = []
|
||||
for (const file of selectedFiles) {
|
||||
const result = await handleImageUpload(file)
|
||||
results.push(result)
|
||||
}
|
||||
props.onImagesAdd(composeMediaItems(results))
|
||||
setLoading(false)
|
||||
swipeToUploaded()
|
||||
} catch (error) {
|
||||
console.error('[runUpload]', error)
|
||||
showSnackbar({ type: 'error', body: t('Error') })
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const handleUploadThumb = async () => {
|
||||
selectFiles((selectedFiles) => {
|
||||
initUpload(selectedFiles)
|
||||
})
|
||||
}
|
||||
|
||||
const handleChangeIndex = (direction: 'left' | 'right', index: number) => {
|
||||
const images = [...props.images]
|
||||
if (direction === 'left' && index > 0) {
|
||||
const copy = images.splice(index, 1)[0]
|
||||
images.splice(index - 1, 0, copy)
|
||||
} else if (direction === 'right' && index < images.length - 1) {
|
||||
const copy = images.splice(index, 1)[0]
|
||||
images.splice(index + 1, 0, copy)
|
||||
}
|
||||
props.onImagesSorted(images)
|
||||
setTimeout(() => {
|
||||
mainSwipeRef.current.swiper.slideTo(direction === 'left' ? index - 1 : index + 1)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const handleSaveBeforeSlideChange = () => {
|
||||
handleSlideDescriptionChange(slideIndex(), 'body', slideBody())
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const { register } = await import('swiper/element/bundle')
|
||||
register()
|
||||
SwiperCore.use([Pagination, Navigation, Manipulation])
|
||||
})
|
||||
|
||||
return (
|
||||
<div class={clsx(styles.Swiper, styles.editorMode)}>
|
||||
<div class={styles.container}>
|
||||
<Show when={props.images.length === 0}>
|
||||
<DropArea
|
||||
fileType="image"
|
||||
isMultiply={true}
|
||||
placeholder={t('Add images')}
|
||||
onUpload={handleDropAreaUpload}
|
||||
description={
|
||||
<div>
|
||||
{t('You can upload up to 100 images in .jpg, .png format.')}
|
||||
<br />
|
||||
{t('Each image must be no larger than 5 MB.')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.images.length > 0}>
|
||||
<div class={styles.holder}>
|
||||
<swiper-container
|
||||
ref={(el) => (mainSwipeRef.current = el)}
|
||||
slides-per-view={1}
|
||||
thumbs-swiper={'.thumbSwiper'}
|
||||
observer={true}
|
||||
onSlideChange={handleSlideChange}
|
||||
onBeforeSlideChangeStart={handleSaveBeforeSlideChange}
|
||||
space-between={20}
|
||||
>
|
||||
<For each={props.images}>
|
||||
{(slide, index) => (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
<swiper-slide lazy="true" virtual-index={index()}>
|
||||
<div class={styles.image}>
|
||||
<Image src={slide.url} alt={slide.title} width={800} />
|
||||
|
||||
<Popover content={t('Delete')}>
|
||||
{(triggerRef: (el) => void) => (
|
||||
<div ref={triggerRef} onClick={() => handleDelete(index())} class={styles.action}>
|
||||
<Icon class={styles.icon} name="delete-white" />
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
</swiper-slide>
|
||||
)}
|
||||
</For>
|
||||
</swiper-container>
|
||||
<div
|
||||
class={clsx(styles.navigation, styles.prev, {
|
||||
[styles.disabled]: slideIndex() === 0,
|
||||
})}
|
||||
onClick={() => mainSwipeRef.current.swiper.slidePrev()}
|
||||
>
|
||||
<Icon name="swiper-l-arr" class={styles.icon} />
|
||||
</div>
|
||||
<div
|
||||
class={clsx(styles.navigation, styles.next, {
|
||||
[styles.disabled]: slideIndex() + 1 === props.images.length,
|
||||
})}
|
||||
onClick={() => mainSwipeRef.current.swiper.slideNext()}
|
||||
>
|
||||
<Icon name="swiper-r-arr" class={styles.icon} />
|
||||
</div>
|
||||
<div class={styles.counter}>
|
||||
{slideIndex() + 1} / {props.images.length}
|
||||
</div>
|
||||
</div>
|
||||
<div class={clsx(styles.holder, styles.thumbsHolder)}>
|
||||
<div class={styles.thumbs}>
|
||||
<swiper-container
|
||||
class={'thumbSwiper'}
|
||||
ref={(el) => (thumbSwipeRef.current = el)}
|
||||
slides-per-view={'auto'}
|
||||
space-between={20}
|
||||
auto-scroll-offset={1}
|
||||
watch-overflow={true}
|
||||
watch-slides-visibility={true}
|
||||
direction={'horizontal'}
|
||||
slides-offset-after={160}
|
||||
slides-offset-before={30}
|
||||
>
|
||||
<For each={props.images}>
|
||||
{(slide, index) => (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
<swiper-slide virtual-index={index()} style={{ width: 'auto', height: 'auto' }}>
|
||||
<div
|
||||
class={clsx(styles.imageThumb)}
|
||||
style={{
|
||||
'background-image': `url(${getImageUrl(slide.url, { width: 110, height: 75 })})`,
|
||||
}}
|
||||
>
|
||||
<div class={styles.thumbAction}>
|
||||
<div class={clsx(styles.action)} onClick={() => handleDelete(index())}>
|
||||
<Icon class={styles.icon} name="delete-white" />
|
||||
</div>
|
||||
<div
|
||||
class={clsx(styles.action, {
|
||||
[styles.hidden]: index() === 0,
|
||||
})}
|
||||
onClick={() => handleChangeIndex('left', index())}
|
||||
>
|
||||
<Icon
|
||||
class={styles.icon}
|
||||
name="arrow-right-white"
|
||||
style={{ transform: 'rotate(-180deg)' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class={clsx(styles.action, {
|
||||
[styles.hidden]: index() === props.images.length - 1,
|
||||
})}
|
||||
onClick={() => handleChangeIndex('right', index())}
|
||||
>
|
||||
<Icon class={styles.icon} name="arrow-right-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</swiper-slide>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<div class={styles.upload}>
|
||||
<div class={styles.inner} onClick={handleUploadThumb}>
|
||||
<Show when={!loading()} fallback={<Loading size="small" />}>
|
||||
<Icon name="swiper-plus" />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</swiper-container>
|
||||
<div
|
||||
class={clsx(styles.navigation, styles.thumbsNav, styles.prev, {
|
||||
[styles.disabled]: slideIndex() === 0,
|
||||
})}
|
||||
onClick={() => thumbSwipeRef.current.swiper.slidePrev()}
|
||||
>
|
||||
<Icon name="swiper-l-arr" class={styles.icon} />
|
||||
</div>
|
||||
<div
|
||||
class={clsx(styles.navigation, styles.thumbsNav, styles.next, {
|
||||
[styles.disabled]: slideIndex() + 1 === props.images.length,
|
||||
})}
|
||||
onClick={() => thumbSwipeRef.current.swiper.slideNext()}
|
||||
>
|
||||
<Icon name="swiper-r-arr" class={styles.icon} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.images.length > 0}>
|
||||
<div class={styles.description}>
|
||||
<input
|
||||
type="text"
|
||||
class={clsx(styles.input, styles.title)}
|
||||
placeholder={t('Enter image title')}
|
||||
value={props.images[slideIndex()]?.title}
|
||||
onChange={(event) => handleSlideDescriptionChange(slideIndex(), 'title', event.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class={styles.input}
|
||||
placeholder={t('Specify the source and the name of the author')}
|
||||
value={props.images[slideIndex()]?.source}
|
||||
onChange={(event) => handleSlideDescriptionChange(slideIndex(), 'source', event.target.value)}
|
||||
/>
|
||||
<SimplifiedEditor
|
||||
initialContent={props.images[slideIndex()]?.body}
|
||||
smallHeight={true}
|
||||
placeholder={t('Enter image description')}
|
||||
onChange={(value) => setSlideBody(value)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,59 +1,34 @@
|
|||
import { createFileUploader } from '@solid-primitives/upload'
|
||||
import { clsx } from 'clsx'
|
||||
import { createEffect, createSignal, For, Show, on, onMount, lazy } from 'solid-js'
|
||||
import { createEffect, createSignal, For, Show, on, onMount, lazy, onCleanup } from 'solid-js'
|
||||
import SwiperCore, { Manipulation, Navigation, Pagination } from 'swiper'
|
||||
import { throttle } from 'throttle-debounce'
|
||||
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import { useSnackbar } from '../../../context/snackbar'
|
||||
import { MediaItem, UploadedFile } from '../../../pages/types'
|
||||
import { composeMediaItems } from '../../../utils/composeMediaItems'
|
||||
import { MediaItem } from '../../../pages/types'
|
||||
import { getImageUrl } from '../../../utils/getImageUrl'
|
||||
import { handleImageUpload } from '../../../utils/handleImageUpload'
|
||||
import { validateFiles } from '../../../utils/validateFile'
|
||||
import { DropArea } from '../DropArea'
|
||||
import { Icon } from '../Icon'
|
||||
import { Image } from '../Image'
|
||||
import { Loading } from '../Loading'
|
||||
import { Popover } from '../Popover'
|
||||
|
||||
import { SwiperRef } from './swiper'
|
||||
|
||||
import styles from './Swiper.module.scss'
|
||||
|
||||
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
||||
|
||||
type Props = {
|
||||
images: MediaItem[]
|
||||
editorMode?: boolean
|
||||
onImagesAdd?: (value: MediaItem[]) => void
|
||||
onImagesSorted?: (value: MediaItem[]) => void
|
||||
onImageDelete?: (mediaItemIndex: number) => void
|
||||
onImageChange?: (index: number, value: MediaItem) => void
|
||||
}
|
||||
|
||||
export const ImageSwiper = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [slideIndex, setSlideIndex] = createSignal(0)
|
||||
const [slideBody, setSlideBody] = createSignal<string>()
|
||||
const MIN_WIDTH = 540
|
||||
|
||||
export const ImageSwiper = (props: Props) => {
|
||||
const [slideIndex, setSlideIndex] = createSignal(0)
|
||||
const [isMobileView, setIsMobileView] = createSignal(false)
|
||||
const mainSwipeRef: { current: SwiperRef } = { current: null }
|
||||
const thumbSwipeRef: { current: SwiperRef } = { current: null }
|
||||
const swiperMainContainer: { current: HTMLDivElement } = { current: null }
|
||||
|
||||
const {
|
||||
actions: { showSnackbar },
|
||||
} = useSnackbar()
|
||||
|
||||
const handleSlideDescriptionChange = (index: number, field: string, value) => {
|
||||
if (props.onImageChange) {
|
||||
props.onImageChange(index, { ...props.images[index], [field]: value })
|
||||
}
|
||||
}
|
||||
const swipeToUploaded = () => {
|
||||
setTimeout(() => {
|
||||
mainSwipeRef.current.swiper.slideTo(props.images.length - 1)
|
||||
}, 0)
|
||||
}
|
||||
const handleSlideChange = () => {
|
||||
thumbSwipeRef.current.swiper.slideTo(mainSwipeRef.current.swiper.activeIndex)
|
||||
setSlideIndex(mainSwipeRef.current.swiper.activeIndex)
|
||||
|
@ -69,74 +44,6 @@ export const ImageSwiper = (props: Props) => {
|
|||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const handleDropAreaUpload = (value: UploadedFile[]) => {
|
||||
props.onImagesAdd(composeMediaItems(value))
|
||||
swipeToUploaded()
|
||||
}
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
props.onImageDelete(index)
|
||||
|
||||
if (index === 0) {
|
||||
mainSwipeRef.current.swiper.update()
|
||||
} else {
|
||||
mainSwipeRef.current.swiper.slideTo(index - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const { selectFiles } = createFileUploader({
|
||||
multiple: true,
|
||||
accept: `image/*`,
|
||||
})
|
||||
|
||||
const initUpload = async (selectedFiles) => {
|
||||
const isValid = validateFiles('image', selectedFiles)
|
||||
if (isValid) {
|
||||
try {
|
||||
setLoading(true)
|
||||
const results: UploadedFile[] = []
|
||||
for (const file of selectedFiles) {
|
||||
const result = await handleImageUpload(file)
|
||||
results.push(result)
|
||||
}
|
||||
props.onImagesAdd(composeMediaItems(results))
|
||||
setLoading(false)
|
||||
swipeToUploaded()
|
||||
} catch (error) {
|
||||
await showSnackbar({ type: 'error', body: t('Error') })
|
||||
console.error('[runUpload]', error)
|
||||
setLoading(false)
|
||||
}
|
||||
} else {
|
||||
await showSnackbar({ type: 'error', body: t('Invalid file type') })
|
||||
setLoading(false)
|
||||
return false
|
||||
}
|
||||
}
|
||||
const handleUploadThumb = async () => {
|
||||
selectFiles((selectedFiles) => {
|
||||
initUpload(selectedFiles)
|
||||
})
|
||||
}
|
||||
|
||||
const handleChangeIndex = (direction: 'left' | 'right', index: number) => {
|
||||
const images = [...props.images]
|
||||
if (direction === 'left' && index > 0) {
|
||||
const copy = images.splice(index, 1)[0]
|
||||
images.splice(index - 1, 0, copy)
|
||||
} else if (direction === 'right' && index < images.length - 1) {
|
||||
const copy = images.splice(index, 1)[0]
|
||||
images.splice(index + 1, 0, copy)
|
||||
}
|
||||
props.onImagesSorted(images)
|
||||
setTimeout(() => {
|
||||
mainSwipeRef.current.swiper.slideTo(direction === 'left' ? index - 1 : index + 1)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const handleSaveBeforeSlideChange = () => {
|
||||
handleSlideDescriptionChange(slideIndex(), 'body', slideBody())
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const { register } = await import('swiper/element/bundle')
|
||||
|
@ -144,24 +51,34 @@ export const ImageSwiper = (props: Props) => {
|
|||
SwiperCore.use([Pagination, Navigation, Manipulation])
|
||||
})
|
||||
|
||||
return (
|
||||
<div class={clsx(styles.Swiper, props.editorMode ? styles.editorMode : styles.articleMode)}>
|
||||
<div class={styles.container}>
|
||||
<Show when={props.editorMode && props.images.length === 0}>
|
||||
<DropArea
|
||||
fileType="image"
|
||||
isMultiply={true}
|
||||
placeholder={t('Add images')}
|
||||
onUpload={handleDropAreaUpload}
|
||||
description={
|
||||
<div>
|
||||
{t('You can upload up to 100 images in .jpg, .png format.')}
|
||||
<br />
|
||||
{t('Each image must be no larger than 5 MB.')}
|
||||
</div>
|
||||
onMount(() => {
|
||||
const updateDirection = () => {
|
||||
const width = window.innerWidth
|
||||
const direction = width > MIN_WIDTH ? 'vertical' : 'horizontal'
|
||||
if (direction === 'horizontal') {
|
||||
setIsMobileView(true)
|
||||
} else {
|
||||
setIsMobileView(false)
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
thumbSwipeRef.current?.swiper?.changeDirection(direction)
|
||||
}
|
||||
|
||||
updateDirection()
|
||||
|
||||
const handleResize = throttle(100, () => {
|
||||
updateDirection()
|
||||
})
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div class={clsx(styles.Swiper, styles.articleMode, { [styles.mobileView]: isMobileView() })}>
|
||||
<div class={styles.container} ref={(el) => (swiperMainContainer.current = el)}>
|
||||
<Show when={props.images.length > 0}>
|
||||
<div class={styles.holder}>
|
||||
<swiper-container
|
||||
|
@ -170,8 +87,7 @@ export const ImageSwiper = (props: Props) => {
|
|||
thumbs-swiper={'.thumbSwiper'}
|
||||
observer={true}
|
||||
onSlideChange={handleSlideChange}
|
||||
onBeforeSlideChangeStart={handleSaveBeforeSlideChange}
|
||||
space-between={20}
|
||||
space-between={isMobileView() ? 20 : 10}
|
||||
>
|
||||
<For each={props.images}>
|
||||
{(slide, index) => (
|
||||
|
@ -179,28 +95,7 @@ export const ImageSwiper = (props: Props) => {
|
|||
// @ts-ignore
|
||||
<swiper-slide lazy="true" virtual-index={index()}>
|
||||
<div class={styles.image}>
|
||||
<Image
|
||||
src={
|
||||
slide.url.startsWith('https://cdn.discours')
|
||||
? `https://images.discours.io/${slide.url}`
|
||||
: slide.url
|
||||
}
|
||||
alt={slide.title}
|
||||
width={800}
|
||||
/>
|
||||
<Show when={props.editorMode}>
|
||||
<Popover content={t('Delete')}>
|
||||
{(triggerRef: (el) => void) => (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onClick={() => handleDelete(index())}
|
||||
class={styles.action}
|
||||
>
|
||||
<Icon class={styles.icon} name="delete-white" />
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</Show>
|
||||
<Image src={slide.url} alt={slide.title} width={800} />
|
||||
</div>
|
||||
</swiper-slide>
|
||||
)}
|
||||
|
@ -232,13 +127,10 @@ export const ImageSwiper = (props: Props) => {
|
|||
class={'thumbSwiper'}
|
||||
ref={(el) => (thumbSwipeRef.current = el)}
|
||||
slides-per-view={'auto'}
|
||||
space-between={20}
|
||||
space-between={isMobileView() ? 20 : 10}
|
||||
auto-scroll-offset={1}
|
||||
watch-overflow={true}
|
||||
watch-slides-visibility={true}
|
||||
direction={props.editorMode ? 'horizontal' : 'vertical'}
|
||||
slides-offset-after={props.editorMode && 160}
|
||||
slides-offset-before={props.editorMode && 30}
|
||||
>
|
||||
<For each={props.images}>
|
||||
{(slide, index) => (
|
||||
|
@ -250,47 +142,10 @@ export const ImageSwiper = (props: Props) => {
|
|||
style={{
|
||||
'background-image': `url(${getImageUrl(slide.url, { width: 110, height: 75 })})`,
|
||||
}}
|
||||
>
|
||||
<Show when={props.editorMode}>
|
||||
<div class={styles.thumbAction}>
|
||||
<div class={clsx(styles.action)} onClick={() => handleDelete(index())}>
|
||||
<Icon class={styles.icon} name="delete-white" />
|
||||
</div>
|
||||
<div
|
||||
class={clsx(styles.action, {
|
||||
[styles.hidden]: index() === 0,
|
||||
})}
|
||||
onClick={() => handleChangeIndex('left', index())}
|
||||
>
|
||||
<Icon
|
||||
class={styles.icon}
|
||||
name="arrow-right-white"
|
||||
style={{ transform: 'rotate(-180deg)' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class={clsx(styles.action, {
|
||||
[styles.hidden]: index() === props.images.length - 1,
|
||||
})}
|
||||
onClick={() => handleChangeIndex('right', index())}
|
||||
>
|
||||
<Icon class={styles.icon} name="arrow-right-white" />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</swiper-slide>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.editorMode}>
|
||||
<div class={styles.upload}>
|
||||
<div class={styles.inner} onClick={handleUploadThumb}>
|
||||
<Show when={!loading()} fallback={<Loading size="small" />}>
|
||||
<Icon name="swiper-plus" />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</swiper-container>
|
||||
<div
|
||||
class={clsx(styles.navigation, styles.thumbsNav, styles.prev, {
|
||||
|
@ -312,9 +167,6 @@ export const ImageSwiper = (props: Props) => {
|
|||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={props.editorMode}
|
||||
fallback={
|
||||
<div class={styles.slideDescription}>
|
||||
<Show when={props.images[slideIndex()]?.title}>
|
||||
<div class={styles.articleTitle}>{props.images[slideIndex()].title}</div>
|
||||
|
@ -326,33 +178,6 @@ export const ImageSwiper = (props: Props) => {
|
|||
<div class={styles.body} innerHTML={props.images[slideIndex()].body} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.images.length > 0}>
|
||||
<div class={styles.description}>
|
||||
<input
|
||||
type="text"
|
||||
class={clsx(styles.input, styles.title)}
|
||||
placeholder={t('Enter image title')}
|
||||
value={props.images[slideIndex()]?.title}
|
||||
onChange={(event) => handleSlideDescriptionChange(slideIndex(), 'title', event.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class={styles.input}
|
||||
placeholder={t('Specify the source and the name of the author')}
|
||||
value={props.images[slideIndex()]?.source}
|
||||
onChange={(event) => handleSlideDescriptionChange(slideIndex(), 'source', event.target.value)}
|
||||
/>
|
||||
<SimplifiedEditor
|
||||
initialContent={props.images[slideIndex()]?.body}
|
||||
smallHeight={true}
|
||||
placeholder={t('Enter image description')}
|
||||
onChange={(value) => setSlideBody(value)}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -38,7 +38,6 @@
|
|||
}
|
||||
|
||||
.container {
|
||||
// max-width: 800px;
|
||||
margin: auto;
|
||||
position: relative;
|
||||
padding: 24px 0;
|
||||
|
@ -48,6 +47,7 @@
|
|||
width: 100%;
|
||||
|
||||
.thumbsHolder {
|
||||
min-width: 110px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
@ -60,13 +60,6 @@
|
|||
margin: 0;
|
||||
position: relative;
|
||||
|
||||
& > swiper-container {
|
||||
position: absolute;
|
||||
top: 52px;
|
||||
bottom: 52px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.thumbsNav {
|
||||
height: 52px;
|
||||
padding: 14px 0;
|
||||
|
@ -92,6 +85,48 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
&.mobileView {
|
||||
.container {
|
||||
flex-direction: column-reverse;
|
||||
padding: 0;
|
||||
|
||||
.thumbsHolder {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.thumbs {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
padding: 0;
|
||||
|
||||
& swiper-slide {
|
||||
//bind to html element <swiper-slide/>
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
.thumbsNav {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
|
||||
.icon {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&.prev {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&.next {
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.editorMode {
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export { ImageSwiper } from './ImageSwiper'
|
||||
export { EditorSwiper } from './EditorSwiper'
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { ProfileInput } from '../graphql/schema/core.gen'
|
||||
|
||||
import { createEffect, createMemo, createSignal } from 'solid-js'
|
||||
import { createContext, createEffect, createMemo, JSX, useContext } from 'solid-js'
|
||||
import { createStore } from 'solid-js/store'
|
||||
|
||||
import { apiClient as coreClient } from '../graphql/client/core'
|
||||
|
@ -8,15 +8,32 @@ import { loadAuthor } from '../stores/zine/authors'
|
|||
|
||||
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) => {
|
||||
if (userpic.includes('assets.discours.io')) {
|
||||
if (userpic && userpic.includes('assets.discours.io')) {
|
||||
return userpic.replace('100x', '500x500')
|
||||
}
|
||||
return userpic
|
||||
}
|
||||
const useProfileForm = () => {
|
||||
export const ProfileFormProvider = (props: { children: JSX.Element }) => {
|
||||
const { author: currentAuthor } = useSession()
|
||||
const [slugError, setSlugError] = createSignal<string>()
|
||||
const [form, setForm] = createStore<ProfileInput>({})
|
||||
|
||||
const currentSlug = createMemo(() => session()?.user?.slug)
|
||||
|
||||
const apiClient = createMemo(() => {
|
||||
if (!coreClient.private) coreClient.connect()
|
||||
|
@ -26,38 +43,27 @@ const useProfileForm = () => {
|
|||
const submit = async (profile: ProfileInput) => {
|
||||
const response = await apiClient().updateProfile(profile)
|
||||
if (response.error) {
|
||||
setSlugError(response.error)
|
||||
return response.error
|
||||
console.error(response.error)
|
||||
throw response.error
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const [form, setForm] = createStore<ProfileInput>({
|
||||
name: '',
|
||||
bio: '',
|
||||
about: '',
|
||||
slug: '',
|
||||
pic: '',
|
||||
links: [],
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
if (!currentAuthor()) return
|
||||
if (!currentSlug()) return
|
||||
try {
|
||||
await loadAuthor({ slug: currentAuthor().slug })
|
||||
const currentAuthor = await loadAuthor({ slug: currentSlug() })
|
||||
setForm({
|
||||
name: currentAuthor()?.name,
|
||||
slug: currentAuthor()?.slug,
|
||||
bio: currentAuthor()?.bio,
|
||||
about: currentAuthor()?.about,
|
||||
pic: userpicUrl(currentAuthor()?.pic),
|
||||
links: currentAuthor()?.links,
|
||||
name: currentAuthor.name,
|
||||
slug: currentAuthor.slug,
|
||||
bio: currentAuthor.bio,
|
||||
about: currentAuthor.about,
|
||||
pic: userpicUrl(currentAuthor.pic),
|
||||
links: currentAuthor.links,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
|
||||
const updateFormField = (fieldName: string, value: string, remove?: boolean) => {
|
||||
if (fieldName === 'links') {
|
||||
if (remove) {
|
||||
|
@ -75,7 +81,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,44 @@ 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-between;
|
||||
flex-direction: row;
|
||||
padding: 1rem 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
color: #d00820;
|
||||
padding: 0.8rem 0 !important;
|
||||
}
|
||||
|
||||
.cancelLabel {
|
||||
@include media-breakpoint-down(sm) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cancelLabelMobile {
|
||||
display: none;
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.row) > * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<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 (
|
||||
<PageLayout title={t('Profile')}>
|
||||
<AuthGuard>
|
||||
<Show when={form}>
|
||||
<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 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>
|
||||
<ProfileFormProvider>
|
||||
<ProfileSettings />
|
||||
</ProfileFormProvider>
|
||||
</AuthGuard>
|
||||
</PageLayout>
|
||||
)
|
||||
|
|
|
@ -56,9 +56,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 })
|
||||
addAuthors([author])
|
||||
return author
|
||||
}
|
||||
|
||||
export const addAuthorsByTopic = (newAuthorsByTopic: { [topicSlug: string]: Author[] }) => {
|
||||
|
|
|
@ -719,6 +719,10 @@ figure {
|
|||
}
|
||||
}
|
||||
|
||||
.floor-header {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.floor {
|
||||
@include media-breakpoint-up(md) {
|
||||
margin-bottom: 6.4rem;
|
||||
|
@ -810,6 +814,12 @@ figure {
|
|||
}
|
||||
|
||||
.row {
|
||||
@include media-breakpoint-down(md) {
|
||||
> * {
|
||||
margin-bottom: 2.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
margin-left: divide(-$container-padding-x, 2);
|
||||
margin-right: divide(-$container-padding-x, 2);
|
||||
|
|
0
src/utils/apiClient.ts
Normal file
0
src/utils/apiClient.ts
Normal file
Loading…
Reference in New Issue
Block a user