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 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?",

View File

@ -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?": "В настройках публикации есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?",

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 {
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -61,6 +61,10 @@
border-bottom: 2px solid #000;
}
}
> * {
margin-bottom: 0 !important;
}
}
.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 { 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)}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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 })
addAuthors([author])
return author
}
export const addAuthorsByTopic = (newAuthorsByTopic: { [topicSlug: string]: Author[] }) => {

View File

@ -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
View File