From 9f7d5d04b6c36c5a81751d1c31f7a73efa6d1d3e Mon Sep 17 00:00:00 2001 From: Ilya Y <75578537+ilya-bkv@users.noreply.github.com> Date: Sun, 2 Jul 2023 08:08:42 +0300 Subject: [PATCH] Feature/gallery upload (#117) * upgrade Swiper --- package-lock.json | 35 +- package.json | 2 +- public/icons/delete-white.svg | 3 + public/icons/swiper-l-arr.svg | 3 + public/icons/swiper-plus.svg | 4 + public/icons/swiper-r-arr.svg | 3 + public/locales/en/translation.json | 13 +- public/locales/ru/translation.json | 16 +- .../Article/AudioPlayer/AudioPlayer.tsx | 2 +- src/components/Article/CommentDate.tsx | 5 +- src/components/Article/FullArticle.tsx | 132 +++---- src/components/Discours/Subscribe.tsx | 4 +- .../Editor/VideoUploader/VideoUploader.tsx | 15 +- src/components/Editor/extensions/Article.ts | 1 + src/components/Feed/ArticleCard.tsx | 26 +- .../Nav/AuthModal/ForgotPasswordForm.tsx | 4 +- src/components/Nav/AuthModal/LoginForm.tsx | 4 +- src/components/Nav/AuthModal/RegisterForm.tsx | 6 +- src/components/Nav/HeaderAuth.tsx | 6 +- src/components/Views/Edit.tsx | 49 ++- src/components/Views/Feed.tsx | 1 - src/components/Views/Home.tsx | 2 +- src/components/Views/Topic.tsx | 2 +- .../_shared/DropArea/DropArea.module.scss | 71 ++++ src/components/_shared/DropArea/DropArea.tsx | 103 +++++ src/components/_shared/DropArea/index.ts | 1 + .../GrowingTextarea/GrowingTextarea.tsx | 4 +- src/components/_shared/Loading.module.scss | 5 + src/components/_shared/Loading.tsx | 12 +- .../_shared/{ => Slider}/Slider.scss | 40 ++ .../_shared/{ => Slider}/Slider.tsx | 60 +-- src/components/_shared/Slider/index.ts | 1 + .../_shared/SolidSwiper/SolidSwiper.tsx | 352 ++++++++++++++++++ .../_shared/SolidSwiper/Swiper.module.scss | 323 ++++++++++++++++ src/components/_shared/SolidSwiper/index.ts | 1 + .../_shared/SolidSwiper/swiper.d.ts | 45 +++ src/pages/create.page.tsx | 7 +- src/pages/layoutShouts.page.tsx | 2 +- src/pages/types.ts | 11 +- src/stores/zine/layouts.ts | 48 +++ src/styles/app.scss | 6 + src/utils/handleFileUpload.ts | 1 + src/utils/{validators.ts => validateEmail.ts} | 2 +- src/utils/validateFile.ts | 37 ++ 44 files changed, 1271 insertions(+), 199 deletions(-) create mode 100644 public/icons/delete-white.svg create mode 100644 public/icons/swiper-l-arr.svg create mode 100644 public/icons/swiper-plus.svg create mode 100644 public/icons/swiper-r-arr.svg create mode 100644 src/components/_shared/DropArea/DropArea.module.scss create mode 100644 src/components/_shared/DropArea/DropArea.tsx create mode 100644 src/components/_shared/DropArea/index.ts rename src/components/_shared/{ => Slider}/Slider.scss (85%) rename src/components/_shared/{ => Slider}/Slider.tsx (65%) create mode 100644 src/components/_shared/Slider/index.ts create mode 100644 src/components/_shared/SolidSwiper/SolidSwiper.tsx create mode 100644 src/components/_shared/SolidSwiper/Swiper.module.scss create mode 100644 src/components/_shared/SolidSwiper/index.ts create mode 100644 src/components/_shared/SolidSwiper/swiper.d.ts create mode 100644 src/stores/zine/layouts.ts rename src/utils/{validators.ts => validateEmail.ts} (66%) create mode 100644 src/utils/validateFile.ts diff --git a/package-lock.json b/package-lock.json index e98981fa..8140903c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -142,7 +142,7 @@ "stylelint-config-standard-scss": "9.0.0", "stylelint-order": "6.0.3", "stylelint-scss": "5.0.0", - "swiper": "8.4.7", + "swiper": "9.4.1", "ts-node": "10.9.1", "typescript": "5.0.4", "undici": "5.21.0", @@ -8792,15 +8792,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/dom7": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/dom7/-/dom7-4.0.6.tgz", - "integrity": "sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==", - "dev": true, - "dependencies": { - "ssr-window": "^4.0.0" - } - }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -19178,9 +19169,9 @@ } }, "node_modules/swiper": { - "version": "8.4.7", - "resolved": "https://registry.npmjs.org/swiper/-/swiper-8.4.7.tgz", - "integrity": "sha512-VwO/KU3i9IV2Sf+W2NqyzwWob4yX9Qdedq6vBtS0rFqJ6Fa5iLUJwxQkuD4I38w0WDJwmFl8ojkdcRFPHWD+2g==", + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-9.4.1.tgz", + "integrity": "sha512-1nT2T8EzUpZ0FagEqaN/YAhRj33F2x/lN6cyB0/xoYJDMf8KwTFT3hMOeoB8Tg4o3+P/CKqskP+WX0Df046fqA==", "dev": true, "funding": [ { @@ -19192,9 +19183,7 @@ "url": "http://opencollective.com/swiper" } ], - "hasInstallScript": true, "dependencies": { - "dom7": "^4.0.4", "ssr-window": "^4.0.2" }, "engines": { @@ -27161,15 +27150,6 @@ } } }, - "dom7": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/dom7/-/dom7-4.0.6.tgz", - "integrity": "sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==", - "dev": true, - "requires": { - "ssr-window": "^4.0.0" - } - }, "domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -34870,12 +34850,11 @@ } }, "swiper": { - "version": "8.4.7", - "resolved": "https://registry.npmjs.org/swiper/-/swiper-8.4.7.tgz", - "integrity": "sha512-VwO/KU3i9IV2Sf+W2NqyzwWob4yX9Qdedq6vBtS0rFqJ6Fa5iLUJwxQkuD4I38w0WDJwmFl8ojkdcRFPHWD+2g==", + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-9.4.1.tgz", + "integrity": "sha512-1nT2T8EzUpZ0FagEqaN/YAhRj33F2x/lN6cyB0/xoYJDMf8KwTFT3hMOeoB8Tg4o3+P/CKqskP+WX0Df046fqA==", "dev": true, "requires": { - "dom7": "^4.0.4", "ssr-window": "^4.0.2" } }, diff --git a/package.json b/package.json index 78f2dd31..867fa707 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,7 @@ "stylelint-config-standard-scss": "9.0.0", "stylelint-order": "6.0.3", "stylelint-scss": "5.0.0", - "swiper": "8.4.7", + "swiper": "9.4.1", "ts-node": "10.9.1", "typescript": "5.0.4", "undici": "5.21.0", diff --git a/public/icons/delete-white.svg b/public/icons/delete-white.svg new file mode 100644 index 00000000..0d73c7b8 --- /dev/null +++ b/public/icons/delete-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/swiper-l-arr.svg b/public/icons/swiper-l-arr.svg new file mode 100644 index 00000000..037f4032 --- /dev/null +++ b/public/icons/swiper-l-arr.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/swiper-plus.svg b/public/icons/swiper-plus.svg new file mode 100644 index 00000000..2861f158 --- /dev/null +++ b/public/icons/swiper-plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/swiper-r-arr.svg b/public/icons/swiper-r-arr.svg new file mode 100644 index 00000000..1f615fe1 --- /dev/null +++ b/public/icons/swiper-r-arr.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 8ec92dcd..99e2c25f 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -5,6 +5,7 @@ "Add another image": "Add another image", "Add comment": "Comment", "Add image": "Add image", + "Add images": "Add images", "Add link": "Add link", "Add signature": "Add signature", "Add url": "Add url", @@ -52,7 +53,6 @@ "Cooperate": "Cooperate", "Copy": "Copy", "Copy link": "Copy link", - "Link copied": "Link copied", "Corrections history": "Corrections history", "Create Chat": "Create Chat", "Create Group": "Create a group", @@ -74,18 +74,16 @@ "Dogma": "Dogma", "Drafts": "Drafts", "Drag the image to this area": "Drag the image to this area", + "Each image must be no larger than 5 MB.": "Each image must be no larger than 5 MB.", "Edit": "Edit", "Editing": "Editing", "Email": "Mail", "Enter": "Enter", "Enter URL address": "Enter URL address", + "Enter image description": "Enter image description", + "Enter image title": "Enter image title", "Enter text": "Enter text", "Enter the Discours": "Enter the Discours", - "Enter the Discours from bookmark": "Sign in to add to bookmarks", - "Enter the Discours from discussions": "Sign in to participate in the discussions", - "Enter the Discours from follow": "Sign in to follow", - "Enter the Discours from subscribe": "Sign in to subscribe to new publications", - "Enter the Discours from vote": "Sign in to vote", "Enter the code or click the link from email to confirm": "Enter the code from the email or follow the link in the email to confirm registration", "Enter your new password": "Enter your new password", "Error": "Error", @@ -147,6 +145,7 @@ "Knowledge base": "Knowledge base", "Last rev.": "Посл. изм.", "Let's log in": "Let's log in", + "Link copied": "Link copied", "Link sent, check your email": "Link sent, check your email", "Lists": "Lists", "Literature": "Literature", @@ -232,6 +231,7 @@ "Something went wrong, please try again": "Something went wrong, please try again", "Sorry, this address is already taken, please choose another one.": "Sorry, this address is already taken, please choose another one", "Special projects": "Special projects", + "Specify the source and the name of the author": "Specify the source and the name of the author", "Start conversation": "Start a conversation", "Subsccriptions": "Subscriptions", "Subscribe": "Subscribe", @@ -246,7 +246,6 @@ "Terms of use": "Site rules", "Text checking": "Text checking", "Thank you": "Thank you", - "Thank you for subscribing": "Thank you for subscribing", "This comment has not yet been rated": "This comment has not yet been rated", "This email is already taken. If it's you": "This email is already taken. If it's you", "This functionality is currently not available, we would like to work on this issue. Use the download link.": "This functionality is currently not available, we would like to work on this issue. Use the download link.", diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index eead45a2..d7326f93 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -4,9 +4,11 @@ "About myself": "О себе", "About the project": "О проекте", "Accomplices": "Соучастники", + "Accomplices": "Соучастники", "Add another image": "Добавить другое изображение", "Add comment": "Комментировать", "Add image": "Добавить изображение", + "Add images": "Добавить изображения", "Add link": "Добавить ссылку", "Add signature": "Добавить подпись", "Add to bookmarks": "Добавить в закладки", @@ -55,7 +57,6 @@ "Cooperate": "Соучаствовать", "Copy": "Скопировать", "Copy link": "Скопировать ссылку", - "Link copied": "Ссылка скопирована", "Corrections history": "История правок", "Create Chat": "Создать чат", "Create Group": "Создать группу", @@ -77,19 +78,17 @@ "Dogma": "Догма", "Drafts": "Черновики", "Drag the image to this area": "Перетащите изображение в эту область", + "Each image must be no larger than 5 MB.": "Каждое изображение должно быть размером не больше 5 мб.", "Edit": "Редактировать", "Edited": "Отредактирован", "Editing": "Редактирование", "Email": "Почта", "Enter": "Войти", "Enter URL address": "Введите адрес ссылки", + "Enter image description": "Введите описание изображения", + "Enter image title": "Введите название изображения", "Enter text": "Введите текст", "Enter the Discours": "Войти в Дискурс", - "Enter the Discours from bookmark": "Войдите, чтобы добавить в закладки", - "Enter the Discours from discussions": "Войдите для участия в дискуссиях", - "Enter the Discours from follow": "Войдите, чтобы подписаться", - "Enter the Discours from subscribe": "Войдите для подписки на новые публикации", - "Enter the Discours from vote": "Войдите, чтобы голосовать", "Enter the code or click the link from email to confirm": "Введите код из письма или пройдите по ссылке в письме для подтверждения регистрации", "Enter your new password": "Введите новый пароль", "Error": "Ошибка", @@ -155,6 +154,7 @@ "Knowledge base": "База знаний", "Last rev.": "Посл. изм.", "Let's log in": "Давайте авторизуемся", + "Link copied": "Ссылка скопирована", "Link sent, check your email": "Ссылка отправлена, проверьте почту", "Lists": "Списки", "Literature": "Литература", @@ -245,6 +245,7 @@ "Something went wrong, please try again": "Что-то пошло не так, попробуйте еще раз", "Sorry, this address is already taken, please choose another one.": "Увы, этот адрес уже занят, выберите другой", "Special projects": "Спецпроекты", + "Specify the source and the name of the author": "Укажите источник и имя автора", "Start conversation": "Начать беседу", "Subheader": "Подзаголовок", "Subscribe": "Подписаться", @@ -260,10 +261,9 @@ "Terms of use": "Правила сайта", "Text checking": "Проверка текста", "Thank you": "Благодарности", - "Thank you for subscribing": "Спасибо, что подписались на рассылку", "This comment has not yet been rated": "Этот комментарий еще пока никто не оценил", "This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы", - "This functionality is currently not available, we would like to work on this issue. Use the download link.": "В данный момент этот функционал недоступен, мы работаем над этой проблемой. Воспользуйтесь загрузкой по ссылке.", + "This functionality is currently not available, we would like to work on this issue. Use the download link.": "В данный момент этот функционал не доступен, бы работаем над этой проблемой. Воспользуйтесь загрузкой по ссылке.", "This post has not been rated yet": "Эту публикацию еще пока никто не оценил", "To leave a comment please": "Чтобы оставить комментарий, необходимо", "To write a comment, you must": "Чтобы написать комментарий, необходимо", diff --git a/src/components/Article/AudioPlayer/AudioPlayer.tsx b/src/components/Article/AudioPlayer/AudioPlayer.tsx index 4007593c..f0b36bd6 100644 --- a/src/components/Article/AudioPlayer/AudioPlayer.tsx +++ b/src/components/Article/AudioPlayer/AudioPlayer.tsx @@ -80,7 +80,7 @@ export default (props: { media: MediaItem[]; articleSlug: string; body: string } setTracks( tracks().map((track) => ({ ...track, - isCurrent: track.id === m.id ? true : false, + isCurrent: track.id === m.id, isPlaying: track.id === m.id ? !track.isPlaying : false })) ) diff --git a/src/components/Article/CommentDate.tsx b/src/components/Article/CommentDate.tsx index 148c742b..28500a9d 100644 --- a/src/components/Article/CommentDate.tsx +++ b/src/components/Article/CommentDate.tsx @@ -1,10 +1,10 @@ -import styles from './CommentDate.module.scss' -import { Icon } from '../_shared/Icon' import { Show } from 'solid-js' +import { Icon } from '../_shared/Icon' import type { Reaction } from '../../graphql/types.gen' import { formatDate } from '../../utils' import { useLocalize } from '../../context/localize' import { clsx } from 'clsx' +import styles from './CommentDate.module.scss' type Props = { comment: Reaction @@ -15,7 +15,6 @@ type Props = { export const CommentDate = (props: Props) => { const { t } = useLocalize() - const formattedDate = (date) => { const formatDateOptions: Intl.DateTimeFormatOptions = props.isShort ? { month: 'long', day: 'numeric', year: 'numeric' } diff --git a/src/components/Article/FullArticle.tsx b/src/components/Article/FullArticle.tsx index f879d1e5..5f15083a 100644 --- a/src/components/Article/FullArticle.tsx +++ b/src/components/Article/FullArticle.tsx @@ -1,5 +1,3 @@ -import { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from 'solid-js' - import { capitalize, formatDate } from '../../utils' import { Icon } from '../_shared/Icon' import { AuthorCard } from '../Author/AuthorCard' @@ -13,7 +11,6 @@ import { clsx } from 'clsx' import { CommentsTree } from './CommentsTree' import { useSession } from '../../context/session' import { VideoPlayer } from '../_shared/VideoPlayer' -import Slider from '../_shared/Slider' import { getPagePath } from '@nanostores/router' import { router, useRouter } from '../../stores/router' import { useReactions } from '../../context/reactions' @@ -23,6 +20,9 @@ import stylesHeader from '../Nav/Header.module.scss' import styles from './Article.module.scss' import { imageProxy } from '../../utils/imageProxy' import { Popover } from '../_shared/Popover' +import article from '../Editor/extensions/Article' +import { SolidSwiper } from '../_shared/SolidSwiper' +import { createEffect, For, createMemo, Match, onMount, Show, Switch, createSignal } from 'solid-js' interface ArticleProps { article: Shout @@ -35,33 +35,6 @@ interface MediaItem { body?: string } -const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => { - const { t } = useLocalize() - - return ( - <> - {t('Cannot show this media type')}}> - - - - -
-
{props.media.title}
- -
-
-
-
- - ) -} - export const FullArticle = (props: ArticleProps) => { const { t } = useLocalize() const { @@ -95,8 +68,10 @@ export const FullArticle = (props: ArticleProps) => { }, 'bookmark') } - const media = createMemo(() => JSON.parse(props.article.media || '[]')) - const body = createMemo(() => props.article.body || '') + const body = createMemo(() => props.article.body) + const media = createMemo(() => { + return JSON.parse(props.article.media || '[]') + }) const commentsRef: { current: HTMLDivElement } = { current: null } const scrollToComments = () => { @@ -141,7 +116,8 @@ export const FullArticle = (props: ArticleProps) => {
-
+ {/*TODO: Check styles.shoutTopic*/} +
-
-
-

{props.article.title}

-
- +

{props.article.title}

+ +

{capitalize(props.article.subtitle, false)}

+
- {/* @@TODO add album's year and genre -
year
-
genre
*/} -
-
- - {/* @@TODO implement image zoom */} - -
- Article cover -
-
+
+ + {(a: Author, index) => ( + <> + 0}>, + {a.name} + + )} +
- - -
- }> - - -
+ +
+ +
+ + {(m: MediaItem) => ( +
+ + + + +
+ )} +
+
+
+ 0 && props.article.layout !== 'image'}>
+ + +
+ }> + + +
+
- - - - {(m) => ( -
-
- {m.title} -
-
-
-
- )} - - - -
diff --git a/src/components/Discours/Subscribe.tsx b/src/components/Discours/Subscribe.tsx index 0d794589..6357cedb 100644 --- a/src/components/Discours/Subscribe.tsx +++ b/src/components/Discours/Subscribe.tsx @@ -1,7 +1,7 @@ import { createSignal, JSX, Show } from 'solid-js' import { useLocalize } from '../../context/localize' -import { isValidEmail } from '../../utils/validators' +import { validateEmail } from '../../utils/validateEmail' import { Button } from '../_shared/Button' import styles from './Subscribe.module.scss' @@ -23,7 +23,7 @@ export default () => { return false } - if (!isValidEmail(email())) { + if (!validateEmail(email())) { setEmailError(t('Please check your email address')) return false } diff --git a/src/components/Editor/VideoUploader/VideoUploader.tsx b/src/components/Editor/VideoUploader/VideoUploader.tsx index 85725c6a..86bdab77 100644 --- a/src/components/Editor/VideoUploader/VideoUploader.tsx +++ b/src/components/Editor/VideoUploader/VideoUploader.tsx @@ -6,32 +6,27 @@ import { createEffect, createSignal, Show } from 'solid-js' import { useSnackbar } from '../../../context/snackbar' import { validateUrl } from '../../../utils/validateUrl' import { VideoPlayer } from '../../_shared/VideoPlayer' +import type { MediaItem } from '../../../pages/types' // import { handleFileUpload } from '../../../utils/handleFileUpload' -type VideoItem = { - url: string - title: string - body: string -} - type Props = { class?: string - data: (value: VideoItem) => void + data: (value: MediaItem[]) => void } export const VideoUploader = (props: Props) => { const { t } = useLocalize() const [dragActive, setDragActive] = createSignal(false) - const [dragError, setDragError] = createSignal() + const [dragError, setDragError] = createSignal() const [incorrectUrl, setIncorrectUrl] = createSignal(false) - const [data, setData] = createSignal() + const [data, setData] = createSignal() const updateData = (key, value) => { setData((prev) => ({ ...prev, [key]: value })) } createEffect(() => { - props.data(data()) + props.data([data()]) }) const { diff --git a/src/components/Editor/extensions/Article.ts b/src/components/Editor/extensions/Article.ts index 15a91511..fc16dac0 100644 --- a/src/components/Editor/extensions/Article.ts +++ b/src/components/Editor/extensions/Article.ts @@ -48,6 +48,7 @@ export default Node.create({ return { toggleArticle: () => + // eslint-disable-next-line unicorn/consistent-function-scoping ({ commands }) => { return commands.toggleWrap('article') }, diff --git a/src/components/Feed/ArticleCard.tsx b/src/components/Feed/ArticleCard.tsx index 7ba125f3..b4b4e697 100644 --- a/src/components/Feed/ArticleCard.tsx +++ b/src/components/Feed/ArticleCard.tsx @@ -1,7 +1,6 @@ import { createMemo, createSignal, For, Show } from 'solid-js' import type { Shout } from '../../graphql/types.gen' import { capitalize, formatDate } from '../../utils' -import { translit } from '../../utils/ru2en' import { Icon } from '../_shared/Icon' import styles from './ArticleCard.module.scss' import { clsx } from 'clsx' @@ -221,7 +220,10 @@ export const ArticleCard = (props: ArticleCardProps) => { @@ -233,7 +235,10 @@ export const ArticleCard = (props: ArticleCardProps) => { )} @@ -244,7 +249,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
)} @@ -263,7 +271,10 @@ export const ArticleCard = (props: ArticleCardProps) => { trigger={ } /> @@ -282,7 +293,10 @@ export const ArticleCard = (props: ArticleCardProps) => { trigger={ } /> diff --git a/src/components/Nav/AuthModal/ForgotPasswordForm.tsx b/src/components/Nav/AuthModal/ForgotPasswordForm.tsx index 04e76b3d..fb9268d0 100644 --- a/src/components/Nav/AuthModal/ForgotPasswordForm.tsx +++ b/src/components/Nav/AuthModal/ForgotPasswordForm.tsx @@ -7,7 +7,7 @@ import type { AuthModalSearchParams } from './types' import { ApiError } from '../../../utils/apiClient' import { signSendLink } from '../../../stores/auth' import { useLocalize } from '../../../context/localize' -import { isValidEmail } from '../../../utils/validators' +import { validateEmail } from '../../../utils/validateEmail' type FormFields = { email: string @@ -38,7 +38,7 @@ export const ForgotPasswordForm = () => { if (!email()) { newValidationErrors.email = t('Please enter email') - } else if (!isValidEmail(email())) { + } else if (!validateEmail(email())) { newValidationErrors.email = t('Invalid email') } diff --git a/src/components/Nav/AuthModal/LoginForm.tsx b/src/components/Nav/AuthModal/LoginForm.tsx index f90c4dba..f62bb35f 100644 --- a/src/components/Nav/AuthModal/LoginForm.tsx +++ b/src/components/Nav/AuthModal/LoginForm.tsx @@ -9,7 +9,7 @@ import type { AuthModalSearchParams } from './types' import { hideModal } from '../../../stores/ui' import { useSession } from '../../../context/session' import { signSendLink } from '../../../stores/auth' -import { isValidEmail } from '../../../utils/validators' +import { validateEmail } from '../../../utils/validateEmail' import { generateModalTitleFromSource } from '../../../utils/custom-i18n' import { useSnackbar } from '../../../context/snackbar' @@ -76,7 +76,7 @@ export const LoginForm = () => { if (!email()) { newValidationErrors.email = t('Please enter email') - } else if (!isValidEmail(email())) { + } else if (!validateEmail(email())) { newValidationErrors.email = t('Invalid email') } diff --git a/src/components/Nav/AuthModal/RegisterForm.tsx b/src/components/Nav/AuthModal/RegisterForm.tsx index e82ae40f..91775e3f 100644 --- a/src/components/Nav/AuthModal/RegisterForm.tsx +++ b/src/components/Nav/AuthModal/RegisterForm.tsx @@ -11,7 +11,7 @@ import { hideModal } from '../../../stores/ui' import { checkEmail, useEmailChecks } from '../../../stores/emailChecks' import { register } from '../../../stores/auth' import { useLocalize } from '../../../context/localize' -import { isValidEmail } from '../../../utils/validators' +import { validateEmail } from '../../../utils/validateEmail' import { generateModalTitleFromSource } from '../../../utils/custom-i18n' type FormFields = { @@ -40,7 +40,7 @@ export const RegisterForm = () => { } const handleEmailBlur = () => { - if (isValidEmail(email())) { + if (validateEmail(email())) { checkEmail(email()) } } @@ -93,7 +93,7 @@ export const RegisterForm = () => { if (!cleanEmail) { newValidationErrors.email = t('Please enter email') - } else if (!isValidEmail(email())) { + } else if (!validateEmail(email())) { newValidationErrors.email = t('Invalid email') } diff --git a/src/components/Nav/HeaderAuth.tsx b/src/components/Nav/HeaderAuth.tsx index e1adb93c..d0db14da 100644 --- a/src/components/Nav/HeaderAuth.tsx +++ b/src/components/Nav/HeaderAuth.tsx @@ -195,7 +195,11 @@ export const HeaderAuth = (props: HeaderAuthProps) => { {/*FIXME: replace with route*/}
- +
diff --git a/src/components/Views/Edit.tsx b/src/components/Views/Edit.tsx index 7b0c7637..6a5bcb78 100644 --- a/src/components/Views/Edit.tsx +++ b/src/components/Views/Edit.tsx @@ -1,4 +1,4 @@ -import { createSignal, For, onCleanup, onMount, Show } from 'solid-js' +import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js' import { useLocalize } from '../../context/localize' import { clsx } from 'clsx' import { Title } from '@solidjs/meta' @@ -18,6 +18,7 @@ import { GrowingTextarea } from '../_shared/GrowingTextarea' import { VideoUploader } from '../Editor/VideoUploader' import { VideoPlayer } from '../_shared/VideoPlayer' import { slugify } from '../../utils/slugify' +import { SolidSwiper } from '../_shared/SolidSwiper' type Props = { shout: Shout @@ -42,7 +43,7 @@ export const EditView = (props: Props) => { const [isScrolled, setIsScrolled] = createSignal(false) const [topics, setTopics] = createSignal(null) const [coverImage, setCoverImage] = createSignal(null) - const [media, setMedia] = createSignal(props.shout.media) + const { page } = useRouter() const { form, @@ -61,10 +62,14 @@ export const EditView = (props: Props) => { mainTopic: shoutTopics.find((topic) => topic.slug === props.shout.mainTopic) || EMPTY_TOPIC, body: props.shout.body, coverImageUrl: props.shout.cover, - media: media(), + media: props.shout.media, layout: props.shout.layout }) + const mediaItems = createMemo(() => { + return JSON.parse(form.media || '[]') + }) + onMount(async () => { const allTopics = await apiClient.getAllTopics() setTopics(allTopics) @@ -120,8 +125,23 @@ export const EditView = (props: Props) => { setForm('selectedTopics', newSelectedTopics) } - const handleAddMedia = (data) => { - setForm('media', JSON.stringify([data])) + const handleAddImages = (data) => { + const newImages = [...mediaItems(), ...data] + setForm('media', JSON.stringify(newImages)) + } + const handleSortedImages = (data) => { + setForm('media', JSON.stringify(data)) + } + + const handleImageDelete = (index) => { + const copy = [...mediaItems()] + copy.splice(index, 1) + setForm('media', JSON.stringify(copy)) + } + + const handleImageChange = (index, value) => { + const updated = mediaItems().map((item, idx) => (idx === index ? value : item)) + setForm('media', JSON.stringify(updated)) } return ( @@ -167,25 +187,36 @@ export const EditView = (props: Props) => { maxLength={100} /> + + handleImageDelete(index)} + onImagesAdd={(value) => handleAddImages(value)} + onImagesSorted={(value) => handleSortedImages(value)} + /> + + { - handleAddMedia(data) + handleAddImages(data) }} /> } > - + {(mediaItem) => ( <> setMedia(null)} + deleteAction={() => setForm('media', null)} /> )} diff --git a/src/components/Views/Feed.tsx b/src/components/Views/Feed.tsx index e0ba8337..9bdde1a6 100644 --- a/src/components/Views/Feed.tsx +++ b/src/components/Views/Feed.tsx @@ -18,7 +18,6 @@ import styles from './Feed.module.scss' import stylesTopic from '../Feed/CardTopic.module.scss' import stylesBeside from '../../components/Feed/Beside.module.scss' import { CommentDate } from '../Article/CommentDate' -import {Beside} from "../Feed/Beside"; export const FEED_PAGE_SIZE = 20 diff --git a/src/components/Views/Home.tsx b/src/components/Views/Home.tsx index 6d401d49..ac77037f 100644 --- a/src/components/Views/Home.tsx +++ b/src/components/Views/Home.tsx @@ -8,7 +8,7 @@ import { Row1 } from '../Feed/Row1' import Hero from '../Discours/Hero' import { Beside } from '../Feed/Beside' import RowShort from '../Feed/RowShort' -import Slider from '../_shared/Slider' +import { Slider } from '../_shared/Slider' import Group from '../Feed/Group' import type { Shout, Topic } from '../../graphql/types.gen' diff --git a/src/components/Views/Topic.tsx b/src/components/Views/Topic.tsx index aca0d116..ec1772e8 100644 --- a/src/components/Views/Topic.tsx +++ b/src/components/Views/Topic.tsx @@ -13,7 +13,7 @@ import { useAuthorsStore } from '../../stores/zine/authors' import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll' import { splitToPages } from '../../utils/splitToPages' import { clsx } from 'clsx' -import Slider from '../_shared/Slider' +import { Slider } from '../_shared/Slider' import { Row1 } from '../Feed/Row1' import { ArticleCard } from '../Feed/ArticleCard' import { useLocalize } from '../../context/localize' diff --git a/src/components/_shared/DropArea/DropArea.module.scss b/src/components/_shared/DropArea/DropArea.module.scss new file mode 100644 index 00000000..049537c3 --- /dev/null +++ b/src/components/_shared/DropArea/DropArea.module.scss @@ -0,0 +1,71 @@ +.DropArea { + .field { + border: 2px dashed rgba(38, 56, 217, 0.3); + border-radius: 16px; + color: #2638d9; + display: flex; + align-items: center; + justify-content: center; + font-weight: 500; + padding: 24px; + transition: background-color 0.3s ease-in-out; + cursor: pointer; + overflow: hidden; + position: relative; + + .text { + position: relative; + z-index: 1; + } + + &.active, + &:hover { + background-color: rgba(#2638d9, 0.3); + + &::after { + content: ''; + top: 0; + transform: translateX(100%); + width: 100%; + height: 100%; + position: absolute; + z-index: 0; + animation: slide 1.8s infinite; + background: linear-gradient( + to right, + rgba(#fff, 0) 0%, + rgba(#fff, 0.8) 50%, + rgb(128 186 232 / 0%) 99%, + rgb(125 185 232 / 0%) 100% + ); + } + } + } + + .description { + @include font-size(1.2rem); + + margin-top: 1.6rem; + text-align: center; + color: var(--secondary-color); + } + + .error { + @include font-size(1.4rem); + + color: var(--danger-color); + margin-top: 1.6rem; + text-align: center; + padding: 1rem; + } +} + +@keyframes slide { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } +} diff --git a/src/components/_shared/DropArea/DropArea.tsx b/src/components/_shared/DropArea/DropArea.tsx new file mode 100644 index 00000000..b827368e --- /dev/null +++ b/src/components/_shared/DropArea/DropArea.tsx @@ -0,0 +1,103 @@ +import { clsx } from 'clsx' +import styles from './DropArea.module.scss' +import { createSignal, JSX, Show } from 'solid-js' +import { createDropzone, createFileUploader } from '@solid-primitives/upload' +import { useLocalize } from '../../../context/localize' +import { validateFiles } from '../../../utils/validateFile' +import type { FileTypeToUpload } from '../../../pages/types' +import { handleFileUpload } from '../../../utils/handleFileUpload' + +type Props = { + class?: string + placeholder: string + description?: string | JSX.Element + fileType: FileTypeToUpload + isMultiply: boolean + onUpload: (value: string[]) => void +} + +export const DropArea = (props: Props) => { + const { t } = useLocalize() + const [dragActive, setDragActive] = createSignal(false) + const [dropAreaError, setDropAreaError] = createSignal() + const [loading, setLoading] = createSignal(false) + + const runUpload = async (files) => { + try { + setLoading(true) + + const results: string[] = [] + for (const file of files) { + const result = await handleFileUpload(file) + results.push(result) + } + props.onUpload(results) + setLoading(false) + } catch (error) { + setDropAreaError('Error') + console.error('[runUpload]', error) + } + } + + const initUpload = async (selectedFiles) => { + if (!props.isMultiply && files.length > 1) { + setDropAreaError(t('Many files, choose only one')) + return + } + const isValid = validateFiles(props.fileType, selectedFiles) + if (isValid) { + await runUpload(selectedFiles) + } else { + setDropAreaError(t('Invalid file type')) + return false + } + } + + const { files, selectFiles } = createFileUploader({ + multiple: true, + accept: `${props.fileType}/*` + }) + + const { setRef: dropzoneRef, files: droppedFiles } = createDropzone({ + onDrop: async () => { + setDragActive(false) + await initUpload(droppedFiles()) + } + }) + const handleDrag = (event) => { + if (event.type === 'dragenter' || event.type === 'dragover') { + setDragActive(true) + } else if (event.type === 'dragleave') { + setDragActive(false) + } + } + const handleDropFieldClick = async () => { + selectFiles((selectedFiles) => { + const filesArray = selectedFiles.map((file) => { + return file + }) + initUpload(filesArray) + }) + } + + return ( +
+
+
{loading() ? 'Loading...' : props.placeholder}
+
+ +
{dropAreaError()}
+
+ +
{props.description}
+
+
+ ) +} diff --git a/src/components/_shared/DropArea/index.ts b/src/components/_shared/DropArea/index.ts new file mode 100644 index 00000000..e88fc9d3 --- /dev/null +++ b/src/components/_shared/DropArea/index.ts @@ -0,0 +1 @@ +export { DropArea } from './DropArea' diff --git a/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx b/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx index 434753a9..7644e042 100644 --- a/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx +++ b/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx @@ -11,11 +11,10 @@ type Props = { } export const GrowingTextarea = (props: Props) => { - const [value, setValue] = createSignal('') + const [value, setValue] = createSignal(props.initialValue ?? '') const [isFocused, setIsFocused] = createSignal(false) const handleChangeValue = (event) => { setValue(event.target.value) - props.value(event.target.value) } const handleKeyDown = async (event) => { @@ -39,6 +38,7 @@ export const GrowingTextarea = (props: Props) => { value={props.initialValue} onKeyDown={handleKeyDown} onInput={(event) => handleChangeValue(event)} + onChange={(event) => props.value(event.target.value)} placeholder={props.placeholder} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} diff --git a/src/components/_shared/Loading.module.scss b/src/components/_shared/Loading.module.scss index de09f09b..7f28bbb2 100644 --- a/src/components/_shared/Loading.module.scss +++ b/src/components/_shared/Loading.module.scss @@ -24,4 +24,9 @@ animation-duration: 2s; animation-iteration-count: infinite; animation-timing-function: linear; + + .small & { + width: 32px; + height: 32px; + } } diff --git a/src/components/_shared/Loading.tsx b/src/components/_shared/Loading.tsx index 252c0f52..44c764c5 100644 --- a/src/components/_shared/Loading.tsx +++ b/src/components/_shared/Loading.tsx @@ -1,8 +1,16 @@ import styles from './Loading.module.scss' +import { clsx } from 'clsx' -export const Loading = () => { +type Props = { + size?: 'small' +} +export const Loading = (props: Props) => { return ( -
+
) diff --git a/src/components/_shared/Slider.scss b/src/components/_shared/Slider/Slider.scss similarity index 85% rename from src/components/_shared/Slider.scss rename to src/components/_shared/Slider/Slider.scss index 7438b45e..1aa7d959 100644 --- a/src/components/_shared/Slider.scss +++ b/src/components/_shared/Slider/Slider.scss @@ -211,3 +211,43 @@ padding: 1rem; width: auto; } + +.uploadPreview { + background: unset; + position: relative; + padding: 0 40px; + + .sliders-container { + position: relative; + } + + .swiper { + background: unset; + + .swiper-wrapper { + min-height: 400px; + } + } + + .slider-arrow-next, + .slider-arrow-prev { + background: none; + filter: invert(1); + width: 40px; + max-height: 540px; + + .icon { + margin: auto; + width: 12px; + height: 20px; + } + } + + // + //.slider-arrow-prev { + // margin-left: -40px; + //} + //.slider-arrow-next { + // margin-right: -40px; + //} +} diff --git a/src/components/_shared/Slider.tsx b/src/components/_shared/Slider/Slider.tsx similarity index 65% rename from src/components/_shared/Slider.tsx rename to src/components/_shared/Slider/Slider.tsx index a4db0a32..d5744504 100644 --- a/src/components/_shared/Slider.tsx +++ b/src/components/_shared/Slider/Slider.tsx @@ -1,26 +1,28 @@ -import { Swiper, Navigation, Pagination, Lazy, Thumbs } from 'swiper' +//TODO: Replace with SolidSwiper.tsx + +import { Swiper, Navigation, Pagination, Thumbs } from 'swiper' import type { SwiperOptions } from 'swiper' import 'swiper/scss' import 'swiper/scss/navigation' import 'swiper/scss/pagination' -import 'swiper/scss/lazy' import 'swiper/scss/thumbs' import './Slider.scss' import { createEffect, createSignal, JSX, Show } from 'solid-js' -import { Icon } from './Icon' +import { Icon } from '../Icon' import { clsx } from 'clsx' -interface SliderProps { +interface Props { title?: string slidesPerView?: number isCardsWithCover?: boolean children?: JSX.Element - class?: string isPageGallery?: boolean hasThumbs?: boolean + variant?: 'uploadPreview' + slideIndex?: (value: number) => void } -export default (props: SliderProps) => { +export const Slider = (props: Props) => { let el: HTMLDivElement | undefined let thumbsEl: HTMLDivElement | undefined let pagEl: HTMLDivElement | undefined @@ -31,15 +33,20 @@ export default (props: SliderProps) => { const [swiper, setSwiper] = createSignal() const [swiperThumbs, setSwiperThumbs] = createSignal() - const opts: SwiperOptions = { - lazy: true, roundLengths: true, loop: true, centeredSlides: true, slidesPerView: 1, - modules: [Navigation, Pagination, Lazy, Thumbs], + modules: [Navigation, Pagination, Thumbs], speed: 500, + on: { + slideChange: () => { + if (swiper()) { + props.slideIndex(swiper().realIndex || 0) + } + } + }, navigation: { nextEl, prevEl }, breakpoints: { 768: { @@ -62,8 +69,7 @@ export default (props: SliderProps) => { setSwiperThumbs( new Swiper(thumbsEl, { slidesPerView: 'auto', - modules: [Lazy, Thumbs], - lazy: true, + modules: [Thumbs], roundLengths: true, spaceBetween: 20, freeMode: true, @@ -98,14 +104,16 @@ export default (props: SliderProps) => { }) return ( -
+
-

{props.title}

+ +

{props.title}

+
{ ref={el} >
{props.children}
-
swiper()?.slideNext()}> - -
-
swiper()?.slidePrev()}> - -
-
+ +
swiper()?.slideNext()}> + +
+
swiper()?.slidePrev()}> + +
+
+ {/*
*/}
@@ -132,6 +142,14 @@ export default (props: SliderProps) => {
+ +
swiper()?.slideNext()}> + +
+
swiper()?.slidePrev()}> + +
+
) } diff --git a/src/components/_shared/Slider/index.ts b/src/components/_shared/Slider/index.ts new file mode 100644 index 00000000..6d43da3c --- /dev/null +++ b/src/components/_shared/Slider/index.ts @@ -0,0 +1 @@ +export { Slider } from './Slider' diff --git a/src/components/_shared/SolidSwiper/SolidSwiper.tsx b/src/components/_shared/SolidSwiper/SolidSwiper.tsx new file mode 100644 index 00000000..e6802a3a --- /dev/null +++ b/src/components/_shared/SolidSwiper/SolidSwiper.tsx @@ -0,0 +1,352 @@ +import { createEffect, createSignal, For, Match, Show, Switch, on } from 'solid-js' +import { MediaItem } from '../../../pages/types' +import { Icon } from '../Icon' +import { Popover } from '../Popover' +import { useLocalize } from '../../../context/localize' +import { register } from 'swiper/element/bundle' +import { DropArea } from '../DropArea' +import { GrowingTextarea } from '../GrowingTextarea' +import MD from '../../Article/MD' +import { createFileUploader } from '@solid-primitives/upload' +import SwiperCore, { Manipulation, Navigation, Pagination } from 'swiper' +import { SwiperRef } from './swiper' +import { validateFiles } from '../../../utils/validateFile' +import { handleFileUpload } from '../../../utils/handleFileUpload' +import { useSnackbar } from '../../../context/snackbar' +import { Loading } from '../Loading' +import { imageProxy } from '../../../utils/imageProxy' +import { clsx } from 'clsx' +import styles from './Swiper.module.scss' + +type Props = { + images: MediaItem[] + editorMode?: boolean + onImagesAdd?: (value: MediaItem[]) => void + onImagesSorted?: (value: MediaItem[]) => void + onImageDelete?: (mediaItemIndex: number) => void + onImageChange?: (index: number, value: MediaItem) => void +} + +const composeMediaItem = (value) => { + return value.map((url) => { + return { + url: url, + source: '', + title: '', + body: '' + } + }) +} + +register() + +SwiperCore.use([Pagination, Navigation, Manipulation]) + +export const SolidSwiper = (props: Props) => { + const { t } = useLocalize() + const [loading, setLoading] = createSignal(false) + const [slideIndex, setSlideIndex] = createSignal(0) + + const dropAreaRef: { current: HTMLElement } = { current: null } + const mainSwipeRef: { current: SwiperRef } = { current: null } + const thumbSwipeRef: { current: SwiperRef } = { current: null } + + const { + actions: { showSnackbar } + } = useSnackbar() + + const handleSlideDescriptionChange = (index: number, field: string, value) => { + 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() + } + ) + ) + + const handleDropAreaUpload = (value: string[]) => { + props.onImagesAdd(composeMediaItem(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: string[] = [] + for (const file of selectedFiles) { + const result = await handleFileUpload(file) + results.push(result) + } + props.onImagesAdd(composeMediaItem(results)) + setLoading(false) + swipeToUploaded() + } catch (error) { + await showSnackbar({ type: 'error', body: t('Error') }) + console.error('[runUpload]', error) + } + } else { + await showSnackbar({ type: 'error', body: t('Invalid file type') }) + 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) + } + + return ( +
+
+ + (dropAreaRef.current = el)} + fileType="image" + isMultiply={true} + placeholder={t('Add images')} + onUpload={handleDropAreaUpload} + description={ +
+ {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} + > + + {(slide, index) => ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + +
+ {slide.title} + + + {(triggerRef: (el) => void) => ( +
handleDelete(index())} + class={styles.action} + > + +
+ )} +
+
+
+ + +
+ + handleSlideDescriptionChange(index(), 'title', event.target.value) + } + /> + + handleSlideDescriptionChange(index(), 'source', event.target.value) + } + /> + handleSlideDescriptionChange(index(), 'body', value)} + /> +
+
+ +
+ +
{slide.title}
+
+ +
{slide.source}
+
+ +
+ +
+
+
+
+
+
+ )} +
+
+
mainSwipeRef.current.swiper.slidePrev()} + > + +
+
mainSwipeRef.current.swiper.slideNext()} + > + +
+
+ {slideIndex() + 1} / {props.images.length} +
+
+ +
+
+ (thumbSwipeRef.current = el)} + slides-per-view={'auto'} + free-mode={true} + observer={true} + space-between={20} + auto-scroll-offset={1} + watch-overflow={true} + slide-to-clicked-slide={true} + watch-slides-visibility={true} + watch-slides-progress={true} + direction={props.editorMode ? 'horizontal' : 'vertical'} + slides-offset-after={props.editorMode && 140} + > + + {(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()} + > + +
+
+
+
+
+
+ ) +} diff --git a/src/components/_shared/SolidSwiper/Swiper.module.scss b/src/components/_shared/SolidSwiper/Swiper.module.scss new file mode 100644 index 00000000..6b4d1534 --- /dev/null +++ b/src/components/_shared/SolidSwiper/Swiper.module.scss @@ -0,0 +1,323 @@ +$navigation-reserve: 32px; +$slide-height: 500px; + +.Swiper { + display: block; + margin: 2rem 0; + + &.articleMode { + background: var(--background-color-invert); + color: var(--default-color-invert); + display: flex; + align-items: center; + justify-content: center; + + .container { + margin: auto; + max-width: 800px; + position: relative; + padding: 24px 0; + display: flex; + justify-content: center; + gap: 20px; + + .holder { + width: 600px; + } + + .thumbsHolder { + width: unset; + } + + .thumbs { + padding: 52px 0; + width: 110px; + overflow: hidden; + height: $slide-height + 40px; + box-sizing: border-box; + margin: 0; + position: relative; + + & > swiper-container { + position: absolute; + top: 52px; + bottom: 52px; + left: 0; + } + + .thumbsNav { + height: 52px; + padding: 14px 0; + display: flex; + align-items: center; + justify-content: center; + width: 110px; + left: 0; + right: 0; + + .icon { + transform: rotate(45deg); + } + + &.prev { + top: 0; + } + + &.next { + top: unset; + bottom: 0; + } + } + } + } + } + + &.editorMode { + color: #0d0d0d; + } + + .action { + border-radius: 50%; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + position: absolute; + top: 16px; + right: 16px; + background: rgba(#000, 0.3); + cursor: pointer; + display: none; + + .icon { + width: 14px; + height: 14px; + } + } + + .holder { + position: relative; + box-sizing: border-box; + padding: 0 $navigation-reserve; + overflow: hidden; + + .counter { + @include font-size(1.2rem); + + position: absolute; + z-index: 2; + top: 476px; + right: $navigation-reserve; + font-weight: 600; + padding: 0.2rem 0.8rem; + color: var(--background-color); + background-color: var(--default-color); + } + + .image { + display: flex; + align-items: center; + justify-content: center; + height: $slide-height; + background: var(--placeholder-color-semi); + position: relative; + + &:hover .action { + display: flex; + } + + img { + max-height: 100%; + } + } + } + + .navigation { + display: flex; + position: absolute; + top: 0; + bottom: 0; + justify-content: center; + align-items: center; + width: $navigation-reserve; + cursor: pointer; + height: $slide-height; + + &.disabled { + opacity: 0.5; + cursor: inherit; + } + + &.prev { + left: 0; + } + + &.next { + right: 0; + } + + .icon { + height: $navigation-reserve; + width: $navigation-reserve; + transition: 0.3s ease-in-out; + } + + &:not(.disabled):hover .icon { + scale: 1.1; + } + } + + &.articleMode .navigation { + filter: invert(1); + } + + .slideDescription { + margin-top: 8px; + .articleTitle { + @include font-size(1.4rem); + } + + .source { + @include font-size(1.2rem); + + color: var(--secondary-color); + } + .body { + @include font-size(1.7rem); + + margin-top: 24px; + } + } + + .thumbs { + margin: 3rem 0; + max-height: 488px; + position: relative; + + .navigation { + height: unset; + + &.prev { + left: -$navigation-reserve; + } + + &.next { + right: -$navigation-reserve; + } + } + + .upload { + border: 1px solid #ccced3; + box-sizing: border-box; + cursor: pointer; + + .inner { + position: relative; + z-index: 1000; + width: 110px; + height: 75px; + display: flex; + align-items: center; + justify-content: center; + } + } + + .imageThumb { + width: 110px; + height: 75px; + background-size: cover; + background-position: 50% 50%; + background-color: var(--placeholder-color-semi); + opacity: 0.5; + filter: grayscale(1); + transition: filter 0.3s ease-in-out, opacity 0.5s ease-in-out; + + .thumbAction { + display: none; + position: absolute; + top: 6px; + right: 6px; + flex-direction: column; + gap: 5px; + + .action { + position: static; + display: flex; + width: 18px; + height: 18px; + + &.hidden { + display: none; + } + + .icon { + width: 8px; + height: 8px; + } + } + } + + &:hover { + opacity: 1; + cursor: pointer; + filter: unset; + + .thumbAction { + display: flex; + } + } + } + } + + .addSlides { + display: flex; + align-items: center; + justify-content: center; + margin: 2rem auto; + } + + .description { + display: flex; + flex-direction: column; + gap: 0.5em; + margin: 1em 0; + + .descriptionText { + @include font-size(1.4rem); + + line-height: 1.1; + } + + .input { + @include font-size(1.4rem); + + padding: 0; + margin: 0; + border: none; + height: 1.2em; + + &:focus { + outline: none; + } + + &::placeholder { + color: var(--placeholder-color); + } + + &.title { + font-weight: 500; + } + } + } +} + +:global(.swiper-slide-thumb-active) { + .imageThumb { + opacity: 1 !important; + filter: unset !important; + + .thumbAction { + display: flex !important; + } + } +} diff --git a/src/components/_shared/SolidSwiper/index.ts b/src/components/_shared/SolidSwiper/index.ts new file mode 100644 index 00000000..fbd847e0 --- /dev/null +++ b/src/components/_shared/SolidSwiper/index.ts @@ -0,0 +1 @@ +export { SolidSwiper } from './SolidSwiper' diff --git a/src/components/_shared/SolidSwiper/swiper.d.ts b/src/components/_shared/SolidSwiper/swiper.d.ts new file mode 100644 index 00000000..e7b06700 --- /dev/null +++ b/src/components/_shared/SolidSwiper/swiper.d.ts @@ -0,0 +1,45 @@ +import 'solid-js' +import { SwiperOptions } from 'swiper' +import { SwiperSlideProps } from 'swiper/react' + +type Kebab = T extends `${infer F}${infer R}` + ? Kebab ? '' : '-'}${Lowercase}`> + : A + +/** + * Helper for converting object keys to kebab case because Swiper web components parameters are available as kebab-case attributes. + * @link https://swiperjs.com/element#parameters-as-attributes + */ +type KebabObjectKeys = { + // eslint-disable-next-line @typescript-eslint/ban-types + [key in keyof T as Kebab]: T[key] extends Object ? KebabObjectKeys : T[key] +} + +/** + * Swiper 9 doesn't support Typescript yet, we are watching the following issue: + * @link https://github.com/nolimits4web/swiper/issues/6466 + * + * All parameters can be found on the following page: + * @link https://swiperjs.com/swiper-api#parameters + */ +type SwiperRef = HTMLElement & { swiper: Swiper; initialize: () => void } + +declare module 'solid-js' { + namespace JSX { + interface IntrinsicElements { + 'swiper-container': SwiperContainerAttributes + 'swiper-slide': SwiperSlideAttributes + } + + interface SwiperContainerAttributes extends KebabObjectKeys { + ref?: RefObject + children?: JSX.Element + onSlideChange?: () => void + class?: string + } + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface SwiperSlideAttributes extends KebabObjectKeys { + style?: unknown + } + } +} diff --git a/src/pages/create.page.tsx b/src/pages/create.page.tsx index f48d0109..c6af1819 100644 --- a/src/pages/create.page.tsx +++ b/src/pages/create.page.tsx @@ -7,8 +7,9 @@ import styles from '../styles/Create.module.scss' import { apiClient } from '../utils/apiClient' import { redirectPage } from '@nanostores/router' import { router } from '../stores/router' +import { LayoutType } from './types' -const handleCreate = async (layout: 'article' | 'video') => { +const handleCreate = async (layout: LayoutType) => { const shout = await apiClient.createArticle({ article: { layout: layout } }) redirectPage(router, 'edit', { shoutId: shout.id.toString() @@ -35,10 +36,10 @@ export const CreatePage = () => {
  • - +
  • diff --git a/src/pages/layoutShouts.page.tsx b/src/pages/layoutShouts.page.tsx index 581d1921..d7671f5e 100644 --- a/src/pages/layoutShouts.page.tsx +++ b/src/pages/layoutShouts.page.tsx @@ -12,7 +12,7 @@ import { clsx } from 'clsx' import { Row3 } from '../components/Feed/Row3' import { Row2 } from '../components/Feed/Row2' import { Beside } from '../components/Feed/Beside' -import Slider from '../components/_shared/Slider' +import { Slider } from '../components/_shared/Slider' import { Row1 } from '../components/Feed/Row1' import styles from '../styles/Topic.module.scss' import { ArticleCard } from '../components/Feed/ArticleCard' diff --git a/src/pages/types.ts b/src/pages/types.ts index 3297c9c0..e76de3c1 100644 --- a/src/pages/types.ts +++ b/src/pages/types.ts @@ -14,7 +14,7 @@ export type PageProps = { topic?: Topic allTopics?: Topic[] searchQuery?: string - layout?: string // LayoutType + layout?: LayoutType // other types? searchResults?: Shout[] chats?: Chat[] @@ -33,3 +33,12 @@ export type UploadFile = { } export type LayoutType = 'article' | 'audio' | 'video' | 'image' | 'literature' + +export type FileTypeToUpload = 'image' | 'video' | 'doc' + +export type MediaItem = { + url: string + title: string + body: string + source?: string +} diff --git a/src/stores/zine/layouts.ts b/src/stores/zine/layouts.ts new file mode 100644 index 00000000..b96ea96c --- /dev/null +++ b/src/stores/zine/layouts.ts @@ -0,0 +1,48 @@ +import type { Shout, LoadShoutsOptions } from '../../graphql/types.gen' +import { apiClient } from '../../utils/apiClient' +import { createSignal } from 'solid-js' + +export type LayoutType = 'article' | 'audio' | 'video' | 'image' | 'literature' + +const [sortedLayoutShouts, setSortedLayoutShouts] = createSignal>(new Map()) + +const addLayoutShouts = (layout: LayoutType, shouts: Shout[]) => { + setSortedLayoutShouts((prevSorted: Map) => { + const siblings = prevSorted.get(layout) + if (siblings) { + const uniqued = [...new Set([...siblings, ...shouts])] + prevSorted.set(layout, uniqued) + } + return prevSorted + }) +} + +export const resetSortedLayoutShouts = () => { + setSortedLayoutShouts(new Map()) +} + +export const loadLayoutShoutsBy = async (options: LoadShoutsOptions): Promise<{ hasMore: boolean }> => { + const newLayoutShouts = await apiClient.getShouts({ + ...options, + limit: options.limit + 1 + }) + + const hasMore = newLayoutShouts.length === options.limit + 1 + + if (hasMore) { + newLayoutShouts.splice(-1) + } + addLayoutShouts(options.filters.layout as LayoutType, newLayoutShouts) + + return { hasMore } +} + +export const useLayoutsStore = (layout: LayoutType, initialData: Shout[]) => { + addLayoutShouts(layout, initialData || []) + + return { + addLayoutShouts, + sortedLayoutShouts, + loadLayoutShoutsBy + } +} diff --git a/src/styles/app.scss b/src/styles/app.scss index 2fb367ed..1c72112e 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -10,10 +10,14 @@ :root { --background-color: #fff; --default-color: #121416; + --background-color-invert: #000; + --default-color-invert: #fff; --link-color: #000; --link-hover-color: #fff; --link-hover-background: #000; --secondary-color: #85878a; + --placeholder-color: #9fa1a7; + --placeholder-color-semi: rgba(159, 169, 167, 0.2); --danger-color: #fc6847; --lightgray-color: rgb(84 16 17 / 6%); --font: -apple-system, blinkmacsystemfont, 'Segoe UI', roboto, oxygen, ubuntu, cantarell, 'Open Sans', @@ -28,6 +32,8 @@ [data-editor-dark-mode='true'] { --background-color: #121416; --default-color: #fff; + --background-color-invert: #fff; + --default-color-invert: #121416; --link-color: #fff; --link-hover-color: #000; --link-hover-background: #fff; diff --git a/src/utils/handleFileUpload.ts b/src/utils/handleFileUpload.ts index 2094b5a7..2eecde03 100644 --- a/src/utils/handleFileUpload.ts +++ b/src/utils/handleFileUpload.ts @@ -2,6 +2,7 @@ import { UploadFile } from '@solid-primitives/upload' import { isDev } from './config' const api = isDev ? 'https://new.discours.io/api/upload' : '/api/upload' + export const handleFileUpload = async (uploadFile: UploadFile) => { const formData = new FormData() formData.append('file', uploadFile.file, uploadFile.name) diff --git a/src/utils/validators.ts b/src/utils/validateEmail.ts similarity index 66% rename from src/utils/validators.ts rename to src/utils/validateEmail.ts index 564a964e..e3cd2913 100644 --- a/src/utils/validators.ts +++ b/src/utils/validateEmail.ts @@ -1,4 +1,4 @@ -export const isValidEmail = (email: string) => { +export const validateEmail = (email: string) => { if (!email) { return false } diff --git a/src/utils/validateFile.ts b/src/utils/validateFile.ts new file mode 100644 index 00000000..ea26df50 --- /dev/null +++ b/src/utils/validateFile.ts @@ -0,0 +1,37 @@ +import { UploadFile } from '@solid-primitives/upload' +import { FileTypeToUpload } from '../pages/types' + +export const validateFiles = (fileType: FileTypeToUpload, files: UploadFile[]): boolean => { + const imageExtensions = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp']) + const docExtensions = new Set(['doc', 'docx', 'pdf', 'txt']) + + for (const file of files) { + let isValid: boolean + + switch (fileType) { + case 'image': { + const fileExtension = file.name.split('.').pop()?.toLowerCase() + isValid = fileExtension ? imageExtensions.has(fileExtension) : false + break + } + case 'video': { + isValid = file.file.type.startsWith('video/') + break + } + case 'doc': { + const docExtension = file.name.split('.').pop()?.toLowerCase() + isValid = docExtension ? docExtensions.has(docExtension) : false + break + } + default: { + isValid = false + } + } + + if (!isValid) { + return false + } + } + + return true +}