diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 66a27c5e..44ceaa2e 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -103,6 +103,7 @@ "Discussion rules": "Discussion rules", "Discussion rules in social networks": "Discussion rules", "Discussions": "Discussions", + "Do you really want to reset all changes?": "Do you really want to reset all changes?", "Dogma": "Dogma", "Draft successfully deleted": "Draft successfully deleted", "Drafts": "Drafts", @@ -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?", diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 726a04ff..743353f1 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -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?": "В настройках публикации есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?", diff --git a/src/components/Author/AuthorCard/AuthorCard.module.scss b/src/components/Author/AuthorCard/AuthorCard.module.scss index eee554b7..3e13863e 100644 --- a/src/components/Author/AuthorCard/AuthorCard.module.scss +++ b/src/components/Author/AuthorCard/AuthorCard.module.scss @@ -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; diff --git a/src/components/Author/AuthorCard/AuthorCard.tsx b/src/components/Author/AuthorCard/AuthorCard.tsx index 3f8cd232..100b8c66 100644 --- a/src/components/Author/AuthorCard/AuthorCard.tsx +++ b/src/components/Author/AuthorCard/AuthorCard.tsx @@ -132,7 +132,9 @@ export const AuthorCard = (props: Props) => {
{name()}
-
+ +
+ 0) || @@ -233,7 +235,12 @@ export const AuthorCard = (props: Props) => { + )} + + + {(triggerRef: (el) => void) => ( + + )} + +
+ + + + {t('Here you can upload your photo')} + + +
+ +
{t('Upload error')}
+
+
+

{t('Name')}

+

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

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

{t('Address on Discourse')}

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

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

+
+
+
+
+ +

{t('Introduce')}

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

{t('About')}

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

{t('Social networks')}

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

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

+
+
+ + + {(network) => ( + handleChangeSocial(value)} + isExist={!network.isPlaceholder} + slug={form.slug} + handleDelete={() => handleDeleteSocialLink(network.link)} + /> + )} + + +
+ +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + ) +} diff --git a/src/components/ProfileSettings/index.ts b/src/components/ProfileSettings/index.ts new file mode 100644 index 00000000..af814349 --- /dev/null +++ b/src/components/ProfileSettings/index.ts @@ -0,0 +1 @@ +export { ProfileSettings } from './ProfileSettings' diff --git a/src/components/Views/Edit.tsx b/src/components/Views/Edit.tsx index 41aa3ba1..8b21566c 100644 --- a/src/components/Views/Edit.tsx +++ b/src/components/Views/Edit.tsx @@ -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) => { - handleMediaDelete(index)} diff --git a/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx b/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx index 2c725f06..4541e1b2 100644 --- a/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx +++ b/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx @@ -28,8 +28,9 @@ const GrowingTextarea = (props: Props) => { setValue(props.initialValue ?? '') } }) - const handleChangeValue = (event) => { - setValue(event.target.value) + const handleChangeValue = (textareaValue) => { + setValue(textareaValue) + props.value(textareaValue) } const handleKeyDown = async (event) => { @@ -66,8 +67,7 @@ const GrowingTextarea = (props: Props) => { : props.initialValue } onKeyDown={props.allowEnterKey ? handleKeyDown : null} - onInput={(event) => handleChangeValue(event)} - onChange={(event) => props.value(event.target.value)} + onInput={(event) => handleChangeValue(event.target.value)} placeholder={props.placeholder} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} diff --git a/src/components/_shared/Popup/Popup.module.scss b/src/components/_shared/Popup/Popup.module.scss index 0bdde227..f69995be 100644 --- a/src/components/_shared/Popup/Popup.module.scss +++ b/src/components/_shared/Popup/Popup.module.scss @@ -60,10 +60,11 @@ } &.horizontalAnchorCenter { - left: -24px; + right: 0; @include media-breakpoint-up(md) { left: 50%; + right: auto; transform: translateX(-50%); } } diff --git a/src/components/_shared/SocialNetworkInput/SocialNetworkInput.tsx b/src/components/_shared/SocialNetworkInput/SocialNetworkInput.tsx index 98f228a6..c47b0597 100644 --- a/src/components/_shared/SocialNetworkInput/SocialNetworkInput.tsx +++ b/src/components/_shared/SocialNetworkInput/SocialNetworkInput.tsx @@ -10,7 +10,7 @@ type Props = { network?: string link?: string isExist: boolean - handleChange: (value: string) => void + handleInput: (value: string) => void handleDelete?: () => void slug?: string autofocus?: boolean @@ -33,7 +33,7 @@ export const SocialNetworkInput = (props: Props) => { class={styles.input} type="text" value={props.isExist ? props.link : null} - onChange={(event) => props.handleChange(event.currentTarget.value)} + onInput={(event) => props.handleInput(event.currentTarget.value)} placeholder={props.autofocus ? null : `${props.link}${props.slug}`} /> diff --git a/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx b/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx index 23eb5a5f..0b9cefcc 100644 --- a/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx +++ b/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx @@ -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, }} -*/ > {(slide, index) => ( diff --git a/src/components/_shared/SolidSwiper/EditorSwiper.tsx b/src/components/_shared/SolidSwiper/EditorSwiper.tsx new file mode 100644 index 00000000..dc9f06e6 --- /dev/null +++ b/src/components/_shared/SolidSwiper/EditorSwiper.tsx @@ -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() + + 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 ( +
+
+ + + {t('You can upload up to 100 images in .jpg, .png format.')} +
+ {t('Each image must be no larger than 5 MB.')} +
+ } + /> + + 0}> +
+ (mainSwipeRef.current = el)} + slides-per-view={1} + thumbs-swiper={'.thumbSwiper'} + observer={true} + onSlideChange={handleSlideChange} + onBeforeSlideChangeStart={handleSaveBeforeSlideChange} + space-between={20} + > + + {(slide, index) => ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + +
+ {slide.title} + + + {(triggerRef: (el) => void) => ( +
handleDelete(index())} class={styles.action}> + +
+ )} +
+
+
+ )} +
+
+
mainSwipeRef.current.swiper.slidePrev()} + > + +
+
mainSwipeRef.current.swiper.slideNext()} + > + +
+
+ {slideIndex() + 1} / {props.images.length} +
+
+
+
+ (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} + > + + {(slide, index) => ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + +
+
+
handleDelete(index())}> + +
+
handleChangeIndex('left', index())} + > + +
+
handleChangeIndex('right', index())} + > + +
+
+
+
+ )} +
+ +
+
+ }> + + +
+
+
+
thumbSwipeRef.current.swiper.slidePrev()} + > + +
+
thumbSwipeRef.current.swiper.slideNext()} + > + +
+
+
+
+
+ + 0}> +
+ handleSlideDescriptionChange(slideIndex(), 'title', event.target.value)} + /> + handleSlideDescriptionChange(slideIndex(), 'source', event.target.value)} + /> + setSlideBody(value)} + /> +
+
+ + ) +} diff --git a/src/components/_shared/SolidSwiper/ImageSwiper.tsx b/src/components/_shared/SolidSwiper/ImageSwiper.tsx index a19d9885..888929c6 100644 --- a/src/components/_shared/SolidSwiper/ImageSwiper.tsx +++ b/src/components/_shared/SolidSwiper/ImageSwiper.tsx @@ -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() +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]) }) + onMount(() => { + const updateDirection = () => { + const width = window.innerWidth + const direction = width > MIN_WIDTH ? 'vertical' : 'horizontal' + if (direction === 'horizontal') { + setIsMobileView(true) + } else { + setIsMobileView(false) + } + thumbSwipeRef.current?.swiper?.changeDirection(direction) + } + + updateDirection() + + const handleResize = throttle(100, () => { + updateDirection() + }) + + window.addEventListener('resize', handleResize) + + onCleanup(() => { + window.removeEventListener('resize', handleResize) + }) + }) + return ( -
-
- - - {t('You can upload up to 100 images in .jpg, .png format.')} -
- {t('Each image must be no larger than 5 MB.')} -
- } - /> - +
+
(swiperMainContainer.current = el)}> 0}>
{ thumbs-swiper={'.thumbSwiper'} observer={true} onSlideChange={handleSlideChange} - onBeforeSlideChangeStart={handleSaveBeforeSlideChange} - space-between={20} + space-between={isMobileView() ? 20 : 10} > {(slide, index) => ( @@ -179,28 +95,7 @@ export const ImageSwiper = (props: Props) => { // @ts-ignore
- {slide.title} - - - {(triggerRef: (el) => void) => ( -
handleDelete(index())} - class={styles.action} - > - -
- )} -
-
+ {slide.title}
)} @@ -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} > {(slide, index) => ( @@ -250,47 +142,10 @@ export const ImageSwiper = (props: Props) => { style={{ 'background-image': `url(${getImageUrl(slide.url, { width: 110, height: 75 })})`, }} - > - -
-
handleDelete(index())}> - -
-
handleChangeIndex('left', index())} - > - -
-
handleChangeIndex('right', index())} - > - -
-
-
-
+ /> )} - -
-
- }> - - -
-
-
{
- - -
{props.images[slideIndex()].title}
-
- -
{props.images[slideIndex()].source}
-
- -
- -
- } - > - 0}> -
- handleSlideDescriptionChange(slideIndex(), 'title', event.target.value)} - /> - handleSlideDescriptionChange(slideIndex(), 'source', event.target.value)} - /> - setSlideBody(value)} - /> -
+
+ +
{props.images[slideIndex()].title}
- + +
{props.images[slideIndex()].source}
+
+ +
+ +
) } diff --git a/src/components/_shared/SolidSwiper/Swiper.module.scss b/src/components/_shared/SolidSwiper/Swiper.module.scss index e953b565..6ae644b0 100644 --- a/src/components/_shared/SolidSwiper/Swiper.module.scss +++ b/src/components/_shared/SolidSwiper/Swiper.module.scss @@ -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 + 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 { diff --git a/src/components/_shared/SolidSwiper/index.ts b/src/components/_shared/SolidSwiper/index.ts index 54c84efc..b8d29439 100644 --- a/src/components/_shared/SolidSwiper/index.ts +++ b/src/components/_shared/SolidSwiper/index.ts @@ -1 +1,2 @@ export { ImageSwiper } from './ImageSwiper' +export { EditorSwiper } from './EditorSwiper' diff --git a/src/context/profile.tsx b/src/context/profile.tsx index 807858b7..9a04f9d5 100644 --- a/src/context/profile.tsx +++ b/src/context/profile.tsx @@ -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 + updateFormField: (fieldName: string, value: string, remove?: boolean) => void + } +} + +const ProfileFormContext = createContext() + +export function useProfileForm() { + return useContext(ProfileFormContext) +} + const userpicUrl = (userpic: string) => { - if (userpic.includes('assets.discours.io')) { + 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() + const [form, setForm] = createStore({}) + + 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({ - 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 {props.children} +} diff --git a/src/pages/profile/Settings.module.scss b/src/pages/profile/Settings.module.scss index 50473218..898b2496 100644 --- a/src/pages/profile/Settings.module.scss +++ b/src/pages/profile/Settings.module.scss @@ -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; + } +} diff --git a/src/pages/profile/profileSettings.page.tsx b/src/pages/profile/profileSettings.page.tsx index a51b5084..81398499 100644 --- a/src/pages/profile/profileSettings.page.tsx +++ b/src/pages/profile/profileSettings.page.tsx @@ -1,331 +1,18 @@ -import { createFileUploader } from '@solid-primitives/upload' -import { clsx } from 'clsx' -import deepEqual from 'fast-deep-equal' -import { For, createSignal, Show, onMount, onCleanup, createEffect, Switch, Match, lazy } from 'solid-js' -import { createStore } from 'solid-js/store' - -import FloatingPanel from '../../components/_shared/FloatingPanel/FloatingPanel' -import { Icon } from '../../components/_shared/Icon' -import { Loading } from '../../components/_shared/Loading' import { PageLayout } from '../../components/_shared/PageLayout' -import { Popover } from '../../components/_shared/Popover' -import { SocialNetworkInput } from '../../components/_shared/SocialNetworkInput' import { AuthGuard } from '../../components/AuthGuard' -import { ProfileSettingsNavigation } from '../../components/Nav/ProfileSettingsNavigation' +import { ProfileSettings } from '../../components/ProfileSettings' import { useLocalize } from '../../context/localize' -import { useProfileForm } from '../../context/profile' -import { useSession } from '../../context/session' -import { useSnackbar } from '../../context/snackbar' -import { clone } from '../../utils/clone' -import { getImageUrl } from '../../utils/getImageUrl' -import { handleImageUpload } from '../../utils/handleImageUpload' -import { profileSocialLinks } from '../../utils/profileSocialLinks' -import { validateUrl } from '../../utils/validateUrl' - -import styles from './Settings.module.scss' - -const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor')) -const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTextarea/GrowingTextarea')) +import { ProfileFormProvider } from '../../context/profile' export const ProfileSettingsPage = () => { const { t } = useLocalize() - const [addLinkForm, setAddLinkForm] = createSignal(false) - const [incorrectUrl, setIncorrectUrl] = createSignal(false) - - const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false) - const [uploadError, setUploadError] = createSignal(false) - const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false) - - const { - actions: { showSnackbar }, - } = useSnackbar() - - const { - actions: { loadSession }, - } = useSession() - - const { form, updateFormField, submit, slugError } = useProfileForm() - const [prevForm, setPrevForm] = createStore(clone(form)) - const [social, setSocial] = createSignal(form.links) - const handleChangeSocial = (value: string) => { - if (validateUrl(value)) { - updateFormField('links', value) - setAddLinkForm(false) - } else { - setIncorrectUrl(true) - } - } - - const handleSubmit = async (event: Event) => { - event.preventDefault() - try { - await submit(form) - setPrevForm(clone(form)) - showSnackbar({ body: t('Profile successfully saved') }) - } catch { - showSnackbar({ type: 'error', body: t('Error') }) - } - - loadSession() - } - - const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' }) - - const handleUploadAvatar = async () => { - selectFiles(async ([uploadFile]) => { - try { - setUploadError(false) - setIsUserpicUpdating(true) - const result = await handleImageUpload(uploadFile) - updateFormField('userpic', result.url) - setIsUserpicUpdating(false) - setIsFloatingPanelVisible(true) - } catch (error) { - setUploadError(true) - console.error('[upload avatar] error', error) - } - }) - } - - const [hostname, setHostname] = createSignal(null) - - onMount(() => { - setHostname(window?.location.host) - - // eslint-disable-next-line unicorn/consistent-function-scoping - const handleBeforeUnload = (event) => { - if (!deepEqual(form, prevForm)) { - event.returnValue = t( - 'There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?', - ) - } - } - - window.addEventListener('beforeunload', handleBeforeUnload) - onCleanup(() => window.removeEventListener('beforeunload', handleBeforeUnload)) - }) - - const handleSaveProfile = () => { - setIsFloatingPanelVisible(false) - setPrevForm(clone(form)) - } - - createEffect(() => { - if (!deepEqual(form, prevForm)) { - setIsFloatingPanelVisible(true) - } - }) - - const handleDeleteSocialLink = (link) => { - updateFormField('links', link, true) - } - - createEffect(() => { - setSocial(form.links) - }) return ( - -
-
-
-
- -
-
-
-
-
-

{t('Profile settings')}

-

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

-
-

{t('Userpic')}

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

{t('Name')}

-

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

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

{t('Address on Discourse')}

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

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

-
-
-
-
- -

{t('Introduce')}

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

{t('About')}

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

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

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

{t('Where')}

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

{t('Date of Birth')}

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

{t('Social networks')}

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

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

-
-
- - {(network) => ( - handleChangeSocial(value)} - isExist={!network.isPlaceholder} - slug={form.slug} - handleDelete={() => handleDeleteSocialLink(network.link)} - /> - )} - -
-
- setIsFloatingPanelVisible(false)} - /> - -
-
-
-
-
- + + + ) diff --git a/src/stores/zine/authors.ts b/src/stores/zine/authors.ts index 98a3e416..20a44f24 100644 --- a/src/stores/zine/authors.ts +++ b/src/stores/zine/authors.ts @@ -56,9 +56,10 @@ const addAuthors = (authors: Author[]) => { ) } -export const loadAuthor = async ({ slug }: { slug: string }): Promise => { +export const loadAuthor = async ({ slug }: { slug: string }): Promise => { const author = await apiClient.getAuthor({ slug }) addAuthors([author]) + return author } export const addAuthorsByTopic = (newAuthorsByTopic: { [topicSlug: string]: Author[] }) => { diff --git a/src/styles/app.scss b/src/styles/app.scss index 12b1f0eb..376ab52d 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -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); diff --git a/src/utils/apiClient.ts b/src/utils/apiClient.ts new file mode 100644 index 00000000..e69de29b