Merge remote-tracking branch 'hub/main' into feature/sse-connect

This commit is contained in:
Untone 2023-12-08 10:37:10 +03:00
commit 1ea72651cf
27 changed files with 958 additions and 605 deletions

View File

@ -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",
@ -330,6 +331,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?",

View File

@ -71,6 +71,7 @@
"Collections": "Коллекции", "Collections": "Коллекции",
"Come up with a subtitle for your story": "Придумайте подзаголовок вашей истории", "Come up with a subtitle for your story": "Придумайте подзаголовок вашей истории",
"Come up with a title for your story": "Придумайте заголовок вашей истории", "Come up with a title for your story": "Придумайте заголовок вашей истории",
"Comment": "Комментировать",
"Comment successfully deleted": "Комментарий успешно удален", "Comment successfully deleted": "Комментарий успешно удален",
"Comments": "Комментарии", "Comments": "Комментарии",
"Communities": "Сообщества", "Communities": "Сообщества",
@ -106,6 +107,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": "Черновики",
@ -349,6 +351,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?": "В настройках публикации есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?",

View File

@ -43,8 +43,23 @@
} }
} }
.authorActionsLabel {
@include media-breakpoint-down(sm) {
display: none;
}
}
.authorActionsLabelMobile {
display: none;
@include media-breakpoint-down(sm) {
display: block;
}
}
.authorDetails { .authorDetails {
display: block; display: block;
margin-bottom: 0;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
flex: 1 100%; flex: 1 100%;
@ -147,7 +162,7 @@
.authorSubscribeSocial { .authorSubscribeSocial {
align-items: center; align-items: center;
display: flex; display: flex;
margin: 2rem 0; margin: 0.5rem 0 2rem -0.4rem;
.socialLink { .socialLink {
border: none; border: none;
@ -403,7 +418,6 @@
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
flex: 1 100%; flex: 1 100%;
justify-content: center; justify-content: center;
margin-top: 1em;
} }
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
@ -420,7 +434,6 @@
flex-wrap: wrap; flex-wrap: wrap;
font-size: 1.4rem; font-size: 1.4rem;
margin-top: 1.5rem; margin-top: 1.5rem;
gap: 1rem;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
justify-content: center; justify-content: center;
@ -431,10 +444,18 @@
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
margin-right: 3rem; margin: 0 2% 1rem;
vertical-align: top; vertical-align: top;
border-bottom: unset !important; border-bottom: unset !important;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
.subscribersItem { .subscribersItem {
position: relative; position: relative;

View File

@ -132,7 +132,9 @@ export const AuthorCard = (props: Props) => {
<div class={clsx('col-md-15 col-xl-13', styles.authorDetails)}> <div class={clsx('col-md-15 col-xl-13', styles.authorDetails)}>
<div class={styles.authorDetailsWrapper}> <div class={styles.authorDetailsWrapper}>
<div class={styles.authorName}>{name()}</div> <div class={styles.authorName}>{name()}</div>
<Show when={props.author.bio}>
<div class={styles.authorAbout} innerHTML={props.author.bio} /> <div class={styles.authorAbout} innerHTML={props.author.bio} />
</Show>
<Show <Show
when={ when={
(props.followers && props.followers.length > 0) || (props.followers && props.followers.length > 0) ||
@ -233,7 +235,12 @@ export const AuthorCard = (props: Props) => {
<Button <Button
variant="secondary" variant="secondary"
onClick={() => redirectPage(router, 'profileSettings')} 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 <SharePopup
title={props.author.name} title={props.author.name}

View File

@ -155,7 +155,7 @@
} }
.shoutDetails { .shoutDetails {
align-items: end; align-items: center;
display: flex; display: flex;
margin-bottom: 1rem; margin-bottom: 1rem;
} }

View File

@ -19,7 +19,7 @@
&:hover { &:hover {
background: #000; background: #000;
color: #fff; color: #fff !important;
} }
} }
} }

View File

@ -36,7 +36,11 @@
.floor--group { .floor--group {
background: #e8e5f0; background: #e8e5f0;
padding: 4rem 0 3rem; padding: 4rem 0 0;
@include media-breakpoint-up(md) {
padding-bottom: 3rem;
}
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
.col-lg-12 { .col-lg-12 {

View File

@ -17,7 +17,9 @@ export const Row3 = (props: {
<div class="floor"> <div class="floor">
<div class="wide-container"> <div class="wide-container">
<div class="row"> <div class="row">
<Show when={props.header}>
<div class="floor-header">{props.header}</div> <div class="floor-header">{props.header}</div>
</Show>
<For each={props.articles}> <For each={props.articles}>
{(a) => ( {(a) => (
<div class="col-md-8"> <div class="col-md-8">

View File

@ -1,11 +1,12 @@
.confirmModal { .confirmModal {
padding: 2rem;
position: relative; position: relative;
.confirmModalTitle { .confirmModalTitle {
@include font-size(3.2rem); @include font-size(3.2rem);
font-weight: 700;
color: var(--default-color); color: var(--default-color);
font-weight: 700;
margin: 0 3rem;
text-align: center; text-align: center;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {

View File

@ -61,6 +61,10 @@
border-bottom: 2px solid #000; border-bottom: 2px solid #000;
} }
} }
> * {
margin-bottom: 0 !important;
}
} }
.mainLogo { .mainLogo {

View 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>
)
}

View File

@ -0,0 +1 @@
export { ProfileSettings } from './ProfileSettings'

View File

@ -15,7 +15,7 @@ import { slugify } from '../../utils/slugify'
import { DropArea } from '../_shared/DropArea' import { DropArea } from '../_shared/DropArea'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { Popover } from '../_shared/Popover' import { Popover } from '../_shared/Popover'
import { ImageSwiper } from '../_shared/SolidSwiper' import { EditorSwiper } from '../_shared/SolidSwiper'
import { Editor, Panel } from '../Editor' import { Editor, Panel } from '../Editor'
import { AudioUploader } from '../Editor/AudioUploader' import { AudioUploader } from '../Editor/AudioUploader'
import { AutoSaveNotice } from '../Editor/AutoSaveNotice' import { AutoSaveNotice } from '../Editor/AutoSaveNotice'
@ -368,8 +368,7 @@ export const EditView = (props: Props) => {
</div> </div>
<Show when={props.shout.layout === 'image'}> <Show when={props.shout.layout === 'image'}>
<ImageSwiper <EditorSwiper
editorMode={true}
images={mediaItems()} images={mediaItems()}
onImageChange={handleMediaChange} onImageChange={handleMediaChange}
onImageDelete={(index) => handleMediaDelete(index)} onImageDelete={(index) => handleMediaDelete(index)}

View File

@ -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)}

View File

@ -60,10 +60,11 @@
} }
&.horizontalAnchorCenter { &.horizontalAnchorCenter {
left: -24px; right: 0;
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
left: 50%; left: 50%;
right: auto;
transform: translateX(-50%); transform: translateX(-50%);
} }
} }

View File

@ -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}>

View File

@ -36,7 +36,7 @@ export const ArticleCardSwiper = (props: Props) => {
ref={(el) => (mainSwipeRef.current = el)} ref={(el) => (mainSwipeRef.current = el)}
centered-slides={true} centered-slides={true}
observer={true} observer={true}
space-between={20} space-between={10}
breakpoints={{ breakpoints={{
576: { spaceBetween: 20, slidesPerView: 1.5 }, 576: { spaceBetween: 20, slidesPerView: 1.5 },
992: { spaceBetween: 52, slidesPerView: 1.5 }, 992: { spaceBetween: 52, slidesPerView: 1.5 },
@ -44,13 +44,11 @@ export const ArticleCardSwiper = (props: Props) => {
round-lengths={true} round-lengths={true}
loop={true} loop={true}
speed={800} speed={800}
/*
autoplay={{ autoplay={{
disableOnInteraction: false, disableOnInteraction: false,
delay: 6000, delay: 6000,
pauseOnMouseEnter: true pauseOnMouseEnter: true,
}} }}
*/
> >
<For each={props.slides}> <For each={props.slides}>
{(slide, index) => ( {(slide, index) => (

View 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>
)
}

View File

@ -1,59 +1,34 @@
import { createFileUploader } from '@solid-primitives/upload'
import { clsx } from 'clsx' 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 SwiperCore, { Manipulation, Navigation, Pagination } from 'swiper'
import { throttle } from 'throttle-debounce'
import { useLocalize } from '../../../context/localize' import { MediaItem } from '../../../pages/types'
import { useSnackbar } from '../../../context/snackbar'
import { MediaItem, UploadedFile } from '../../../pages/types'
import { composeMediaItems } from '../../../utils/composeMediaItems'
import { getImageUrl } from '../../../utils/getImageUrl' import { getImageUrl } from '../../../utils/getImageUrl'
import { handleImageUpload } from '../../../utils/handleImageUpload'
import { validateFiles } from '../../../utils/validateFile'
import { DropArea } from '../DropArea'
import { Icon } from '../Icon' import { Icon } from '../Icon'
import { Image } from '../Image' import { Image } from '../Image'
import { Loading } from '../Loading'
import { Popover } from '../Popover'
import { SwiperRef } from './swiper' import { SwiperRef } from './swiper'
import styles from './Swiper.module.scss' import styles from './Swiper.module.scss'
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
type Props = { type Props = {
images: MediaItem[] images: MediaItem[]
editorMode?: boolean
onImagesAdd?: (value: MediaItem[]) => void onImagesAdd?: (value: MediaItem[]) => void
onImagesSorted?: (value: MediaItem[]) => void onImagesSorted?: (value: MediaItem[]) => void
onImageDelete?: (mediaItemIndex: number) => void onImageDelete?: (mediaItemIndex: number) => void
onImageChange?: (index: number, value: MediaItem) => void onImageChange?: (index: number, value: MediaItem) => void
} }
export const ImageSwiper = (props: Props) => { const MIN_WIDTH = 540
const { t } = useLocalize()
const [loading, setLoading] = createSignal(false)
const [slideIndex, setSlideIndex] = createSignal(0)
const [slideBody, setSlideBody] = createSignal<string>()
export const ImageSwiper = (props: Props) => {
const [slideIndex, setSlideIndex] = createSignal(0)
const [isMobileView, setIsMobileView] = createSignal(false)
const mainSwipeRef: { current: SwiperRef } = { current: null } const mainSwipeRef: { current: SwiperRef } = { current: null }
const thumbSwipeRef: { 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 = () => { const handleSlideChange = () => {
thumbSwipeRef.current.swiper.slideTo(mainSwipeRef.current.swiper.activeIndex) thumbSwipeRef.current.swiper.slideTo(mainSwipeRef.current.swiper.activeIndex)
setSlideIndex(mainSwipeRef.current.swiper.activeIndex) setSlideIndex(mainSwipeRef.current.swiper.activeIndex)
@ -69,74 +44,6 @@ export const ImageSwiper = (props: Props) => {
{ defer: true }, { 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 () => { onMount(async () => {
const { register } = await import('swiper/element/bundle') const { register } = await import('swiper/element/bundle')
@ -144,24 +51,34 @@ export const ImageSwiper = (props: Props) => {
SwiperCore.use([Pagination, Navigation, Manipulation]) SwiperCore.use([Pagination, Navigation, Manipulation])
}) })
return ( onMount(() => {
<div class={clsx(styles.Swiper, props.editorMode ? styles.editorMode : styles.articleMode)}> const updateDirection = () => {
<div class={styles.container}> const width = window.innerWidth
<Show when={props.editorMode && props.images.length === 0}> const direction = width > MIN_WIDTH ? 'vertical' : 'horizontal'
<DropArea if (direction === 'horizontal') {
fileType="image" setIsMobileView(true)
isMultiply={true} } else {
placeholder={t('Add images')} setIsMobileView(false)
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>
} }
/> thumbSwipeRef.current?.swiper?.changeDirection(direction)
</Show> }
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}> <Show when={props.images.length > 0}>
<div class={styles.holder}> <div class={styles.holder}>
<swiper-container <swiper-container
@ -170,8 +87,7 @@ export const ImageSwiper = (props: Props) => {
thumbs-swiper={'.thumbSwiper'} thumbs-swiper={'.thumbSwiper'}
observer={true} observer={true}
onSlideChange={handleSlideChange} onSlideChange={handleSlideChange}
onBeforeSlideChangeStart={handleSaveBeforeSlideChange} space-between={isMobileView() ? 20 : 10}
space-between={20}
> >
<For each={props.images}> <For each={props.images}>
{(slide, index) => ( {(slide, index) => (
@ -179,28 +95,7 @@ export const ImageSwiper = (props: Props) => {
// @ts-ignore // @ts-ignore
<swiper-slide lazy="true" virtual-index={index()}> <swiper-slide lazy="true" virtual-index={index()}>
<div class={styles.image}> <div class={styles.image}>
<Image <Image src={slide.url} alt={slide.title} width={800} />
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>
</div> </div>
</swiper-slide> </swiper-slide>
)} )}
@ -232,13 +127,10 @@ export const ImageSwiper = (props: Props) => {
class={'thumbSwiper'} class={'thumbSwiper'}
ref={(el) => (thumbSwipeRef.current = el)} ref={(el) => (thumbSwipeRef.current = el)}
slides-per-view={'auto'} slides-per-view={'auto'}
space-between={20} space-between={isMobileView() ? 20 : 10}
auto-scroll-offset={1} auto-scroll-offset={1}
watch-overflow={true} watch-overflow={true}
watch-slides-visibility={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}> <For each={props.images}>
{(slide, index) => ( {(slide, index) => (
@ -250,47 +142,10 @@ export const ImageSwiper = (props: Props) => {
style={{ style={{
'background-image': `url(${getImageUrl(slide.url, { width: 110, height: 75 })})`, '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> </swiper-slide>
)} )}
</For> </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> </swiper-container>
<div <div
class={clsx(styles.navigation, styles.thumbsNav, styles.prev, { class={clsx(styles.navigation, styles.thumbsNav, styles.prev, {
@ -312,9 +167,6 @@ export const ImageSwiper = (props: Props) => {
</div> </div>
</Show> </Show>
</div> </div>
<Show
when={props.editorMode}
fallback={
<div class={styles.slideDescription}> <div class={styles.slideDescription}>
<Show when={props.images[slideIndex()]?.title}> <Show when={props.images[slideIndex()]?.title}>
<div class={styles.articleTitle}>{props.images[slideIndex()].title}</div> <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} /> <div class={styles.body} innerHTML={props.images[slideIndex()].body} />
</Show> </Show>
</div> </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> </div>
) )
} }

View File

@ -38,7 +38,6 @@
} }
.container { .container {
// max-width: 800px;
margin: auto; margin: auto;
position: relative; position: relative;
padding: 24px 0; padding: 24px 0;
@ -48,6 +47,7 @@
width: 100%; width: 100%;
.thumbsHolder { .thumbsHolder {
min-width: 110px;
width: auto; width: auto;
} }
@ -60,13 +60,6 @@
margin: 0; margin: 0;
position: relative; position: relative;
& > swiper-container {
position: absolute;
top: 52px;
bottom: 52px;
left: 0;
}
.thumbsNav { .thumbsNav {
height: 52px; height: 52px;
padding: 14px 0; 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 { &.editorMode {

View File

@ -1 +1,2 @@
export { ImageSwiper } from './ImageSwiper' export { ImageSwiper } from './ImageSwiper'
export { EditorSwiper } from './EditorSwiper'

View File

@ -1,6 +1,6 @@
import type { ProfileInput } from '../graphql/schema/core.gen' 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 { createStore } from 'solid-js/store'
import { apiClient as coreClient } from '../graphql/client/core' import { apiClient as coreClient } from '../graphql/client/core'
@ -8,15 +8,32 @@ import { loadAuthor } from '../stores/zine/authors'
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 && 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 { author: currentAuthor } = useSession() const { author: currentAuthor } = useSession()
const [slugError, setSlugError] = createSignal<string>() const [form, setForm] = createStore<ProfileInput>({})
const currentSlug = createMemo(() => session()?.user?.slug)
const apiClient = createMemo(() => { const apiClient = createMemo(() => {
if (!coreClient.private) coreClient.connect() if (!coreClient.private) coreClient.connect()
@ -26,38 +43,27 @@ const useProfileForm = () => {
const submit = async (profile: ProfileInput) => { const submit = async (profile: ProfileInput) => {
const response = await apiClient().updateProfile(profile) const response = await apiClient().updateProfile(profile)
if (response.error) { if (response.error) {
setSlugError(response.error) console.error(response.error)
return response.error throw response.error
} }
return response
} }
const [form, setForm] = createStore<ProfileInput>({
name: '',
bio: '',
about: '',
slug: '',
pic: '',
links: [],
})
createEffect(async () => { createEffect(async () => {
if (!currentAuthor()) return if (!currentSlug()) return
try { try {
await loadAuthor({ slug: currentAuthor().slug }) 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,
pic: userpicUrl(currentAuthor()?.pic), pic: userpicUrl(currentAuthor.pic),
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) {
@ -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>
}

View File

@ -279,3 +279,44 @@ 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-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;
}
}

View File

@ -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>
) )

View File

@ -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 }) 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[] }) => {

View File

@ -719,6 +719,10 @@ figure {
} }
} }
.floor-header {
margin-bottom: 0 !important;
}
.floor { .floor {
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
margin-bottom: 6.4rem; margin-bottom: 6.4rem;
@ -810,6 +814,12 @@ figure {
} }
.row { .row {
@include media-breakpoint-down(md) {
> * {
margin-bottom: 2.4rem;
}
}
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
margin-left: divide(-$container-padding-x, 2); margin-left: divide(-$container-padding-x, 2);
margin-right: divide(-$container-padding-x, 2); margin-right: divide(-$container-padding-x, 2);

0
src/utils/apiClient.ts Normal file
View File