Feature/gallery upload (#117)

* upgrade Swiper
This commit is contained in:
Ilya Y 2023-07-02 08:08:42 +03:00 committed by GitHub
parent a18b6b9e6d
commit 9f7d5d04b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1271 additions and 199 deletions

35
package-lock.json generated
View File

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

View File

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

View File

@ -0,0 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L6 6M6 6L1 11M6 6L11 1M6 6L11 11" stroke="#fff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 183 B

View File

@ -0,0 +1,3 @@
<svg width="31" height="32" viewBox="0 0 31 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 23L12.4118 16L19 9" stroke="black" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 170 B

View File

@ -0,0 +1,4 @@
<svg width="22" height="21" viewBox="0 0 22 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.5 8.5L0.5 8.5L0.5 12.5L21.5 12.5V8.5Z" fill="#CCCED3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 21L13 3.49692e-07L9 0L9 21H13Z" fill="#CCCED3"/>
</svg>

After

Width:  |  Height:  |  Size: 314 B

View File

@ -0,0 +1,3 @@
<svg width="31" height="32" viewBox="0 0 31 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 23L18.5882 16L12 9" stroke="black" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 170 B

View File

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

View File

@ -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": "Чтобы написать комментарий, необходимо",

View File

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

View File

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

View File

@ -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 (
<>
<Switch fallback={<a href={props.media.url}>{t('Cannot show this media type')}</a>}>
<Match when={props.kind === 'video'}>
<VideoPlayer
videoUrl={props.media.url}
title={props.media.title}
description={props.media.body}
/>
</Match>
<Match when={props.kind === 'audio'}>
<div>
<h5>{props.media.title}</h5>
<audio controls>
<source src={props.media.url} />
</audio>
<hr />
</div>
</Match>
</Switch>
</>
)
}
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) => {
<div class="wide-container">
<div class="row">
<article class="col-md-16 col-lg-14 col-xl-12 offset-md-5">
<div class={styles.shoutTopic}>
{/*TODO: Check styles.shoutTopic*/}
<div class={styles.shoutHeader}>
<Show when={mainTopic()}>
<div class={styles.shoutTopic}>
<a
@ -153,69 +129,61 @@ export const FullArticle = (props: ArticleProps) => {
</div>
</Show>
<div class={styles.shoutHeader}>
<div>
<h1>{props.article.title}</h1>
<div>
<div class={styles.shoutAuthor}>
<For each={props.article.authors}>
{(a: Author, index) => (
<>
<Show when={index() > 0}>, </Show>
<a href={getPagePath(router, 'author', { slug: a.slug })}>{a.name}</a>
</>
)}
</For>
</div>
<h1>{props.article.title}</h1>
<Show when={props.article.subtitle}>
<h4>{capitalize(props.article.subtitle, false)}</h4>
</Show>
{/* @@TODO add album's year and genre
<div>year</div>
<div>genre</div> */}
</div>
</div>
{/* @@TODO implement image zoom */}
<Show when={props.article.cover && props.article.layout !== 'video'}>
<div class={styles.shoutCover}>
<img src={imageProxy(props.article.cover)} alt="Article cover" />
</div>
</Show>
<div class={styles.shoutAuthor}>
<For each={props.article.authors}>
{(a: Author, index) => (
<>
<Show when={index() > 0}>, </Show>
<a href={getPagePath(router, 'author', { slug: a.slug })}>{a.name}</a>
</>
)}
</For>
</div>
<Show when={body()}>
<div class={styles.shoutBody}>
<Show when={!body().startsWith('<')} fallback={<div innerHTML={body()} />}>
<MD body={body()} />
</Show>
</div>
<Show when={props.article.cover && props.article.layout !== 'video'}>
<div
class={styles.shoutCover}
style={{ 'background-image': `url('${imageProxy(props.article.cover)}')` }}
/>
</Show>
</div>
<Show when={media() && props.article.layout === 'video'}>
<div class="media-items">
<For each={media() || []}>
{(m: MediaItem) => (
<div class={styles.shoutMediaBody}>
<VideoPlayer videoUrl={m.url} title={m.title} description={m.body} />
<Show when={m?.body}>
<MD body={m.body} />
</Show>
</div>
)}
</For>
</div>
</Show>
<Show when={media().length > 0 && props.article.layout !== 'image'}>
<div class="media-items">
<AudioPlayer media={media()} articleSlug={props.article.slug} body={body()} />
</div>
</Show>
<Show when={body()}>
<div class={styles.shoutBody}>
<Show when={!body().startsWith('<')} fallback={<div innerHTML={body()} />}>
<MD body={body()} />
</Show>
</div>
</Show>
</article>
</div>
</div>
<Show when={media() && props.article.layout === 'image'}>
<Slider slidesPerView={1} isPageGallery={true} isCardsWithCover={true} hasThumbs={true}>
<For each={media() || []}>
{(m) => (
<div class="swiper-slide">
<div class="swiper-slide__inner">
<img src={m.url || m.pic} alt={m.title} loading="lazy" />
<div class="swiper-lazy-preloader swiper-lazy-preloader-white" />
<div class="image-description" innerHTML={m.title} />
</div>
</div>
)}
</For>
</Slider>
</Show>
<div class="wide-container">
<div class="row">
<div class="col-md-16 offset-md-5">

View File

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

View File

@ -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<string | undefined>()
const [dragError, setDragError] = createSignal<string>()
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
const [data, setData] = createSignal<VideoItem>()
const [data, setData] = createSignal<MediaItem>()
const updateData = (key, value) => {
setData((prev) => ({ ...prev, [key]: value }))
}
createEffect(() => {
props.data(data())
props.data([data()])
})
const {

View File

@ -48,6 +48,7 @@ export default Node.create({
return {
toggleArticle:
() =>
// eslint-disable-next-line unicorn/consistent-function-scoping
({ commands }) => {
return commands.toggleWrap('article')
},

View File

@ -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) => {
<div class={clsx(styles.shoutCardDetailsItem, styles.shoutCardComments)}>
<a href="#" onClick={(event) => scrollToComments(event)}>
<Icon name="comment" class={clsx(styles.icon, styles.feedControlIcon)} />
<Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)} />
<Icon
name="comment-hover"
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
/>
<span class={styles.shoutCardLinkContainer}>{stat?.commented || t('Add comment')}</span>
</a>
</div>
@ -233,7 +235,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
<a href={getPagePath(router, 'edit', { shoutId: id.toString() })}>
<Icon name="pencil-outline" class={clsx(styles.icon, styles.feedControlIcon)} />
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)} />
<Icon
name="pencil-outline-hover"
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
/>
</a>
</div>
)}
@ -244,7 +249,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
<button>
<Icon name="bookmark" class={clsx(styles.icon, styles.feedControlIcon)} />
<Icon name="bookmark-hover" class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)} />
<Icon
name="bookmark-hover"
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
/>
</button>
</div>
)}
@ -263,7 +271,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
trigger={
<button>
<Icon name="share-outline" class={clsx(styles.icon, styles.feedControlIcon)} />
<Icon name="share-outline-hover" class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)} />
<Icon
name="share-outline-hover"
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
/>
</button>
}
/>
@ -282,7 +293,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
trigger={
<button>
<Icon name="ellipsis" class={clsx(styles.icon, styles.feedControlIcon)} />
<Icon name="ellipsis" class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)} />
<Icon
name="ellipsis"
class={clsx(styles.icon, styles.iconHover, styles.feedControlIcon)}
/>
</button>
}
/>

View File

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

View File

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

View File

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

View File

@ -195,7 +195,11 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
{/*FIXME: replace with route*/}
<div classList={{ entered: page().path === '/inbox' }}>
<Icon name="inbox-white" counter={session()?.news?.unread || 0} class={styles.icon} />
<Icon name="inbox-white-hover" counter={session()?.news?.unread || 0} class={clsx(styles.icon, styles.iconHover)} />
<Icon
name="inbox-white-hover"
counter={session()?.news?.unread || 0}
class={clsx(styles.icon, styles.iconHover)}
/>
</div>
</a>
</div>

View File

@ -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<Topic[]>(null)
const [coverImage, setCoverImage] = createSignal<string>(null)
const [media, setMedia] = createSignal<string>(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}
/>
<Show when={props.shout.layout === 'image'}>
<SolidSwiper
editorMode={true}
images={mediaItems()}
onImageChange={handleImageChange}
onImageDelete={(index) => handleImageDelete(index)}
onImagesAdd={(value) => handleAddImages(value)}
onImagesSorted={(value) => handleSortedImages(value)}
/>
</Show>
<Show when={props.shout.layout === 'video'}>
<Show
when={media()}
when={form.media}
fallback={
<VideoUploader
data={(data) => {
handleAddMedia(data)
handleAddImages(data)
}}
/>
}
>
<For each={JSON.parse(media())}>
<For each={mediaItems()}>
{(mediaItem) => (
<>
<VideoPlayer
videoUrl={mediaItem?.url}
title={mediaItem?.title}
description={mediaItem?.body}
deleteAction={() => setMedia(null)}
deleteAction={() => setForm('media', null)}
/>
</>
)}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string>()
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 (
<div class={clsx(styles.DropArea, props.class)}>
<div
class={clsx(styles.field, { [styles.active]: dragActive() })}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
ref={dropzoneRef}
onClick={handleDropFieldClick}
>
<div class={styles.text}>{loading() ? 'Loading...' : props.placeholder}</div>
</div>
<Show when={dropAreaError()}>
<div class={styles.error}>{dropAreaError()}</div>
</Show>
<Show when={!dropAreaError() && props.description}>
<div class={styles.description}>{props.description}</div>
</Show>
</div>
)
}

View File

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

View File

@ -11,11 +11,10 @@ type Props = {
}
export const GrowingTextarea = (props: Props) => {
const [value, setValue] = createSignal('')
const [value, setValue] = createSignal<string>(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)}

View File

@ -24,4 +24,9 @@
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: linear;
.small & {
width: 32px;
height: 32px;
}
}

View File

@ -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 (
<div class={styles.container}>
<div
class={clsx(styles.container, {
[styles.small]: props.size === 'small'
})}
>
<div class={styles.icon} />
</div>
)

View File

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

View File

@ -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<Swiper>()
const [swiperThumbs, setSwiperThumbs] = createSignal<Swiper>()
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 (
<div class="floor floor--important">
<div class={clsx('floor', 'floor--important', props.variant)}>
<div class="wide-container">
<div class="row">
<h2 class="col-24">{props.title}</h2>
<Show when={props.title}>
<h2 class="col-24">{props.title}</h2>
</Show>
<div class="sliders-container">
<div
class={clsx('swiper', props.class)}
class={clsx('swiper')}
classList={{
'cards-with-cover': isCardsWithCover,
'swiper--page-gallery': props.isPageGallery
@ -113,13 +121,15 @@ export default (props: SliderProps) => {
ref={el}
>
<div class="swiper-wrapper">{props.children}</div>
<div class="slider-arrow-next" ref={nextEl} onClick={() => swiper()?.slideNext()}>
<Icon name="slider-arrow" class={'icon'} />
</div>
<div class="slider-arrow-prev" ref={prevEl} onClick={() => swiper()?.slidePrev()}>
<Icon name="slider-arrow" class={'icon'} />
</div>
<div class="swiper-pagination" ref={pagEl} />
<Show when={!(props.variant === 'uploadPreview')}>
<div class="slider-arrow-next" ref={nextEl} onClick={() => swiper()?.slideNext()}>
<Icon name="slider-arrow" class={'icon'} />
</div>
<div class="slider-arrow-prev" ref={prevEl} onClick={() => swiper()?.slidePrev()}>
<Icon name="slider-arrow" class={'icon'} />
</div>
</Show>
{/*<div class="swiper-pagination" ref={pagEl} />*/}
</div>
<Show when={props.hasThumbs}>
@ -132,6 +142,14 @@ export default (props: SliderProps) => {
</div>
</div>
</div>
<Show when={props.variant === 'uploadPreview'}>
<div class="slider-arrow-next" ref={nextEl} onClick={() => swiper()?.slideNext()}>
<Icon name="slider-arrow" class={'icon'} />
</div>
<div class="slider-arrow-prev" ref={prevEl} onClick={() => swiper()?.slidePrev()}>
<Icon name="slider-arrow" class={'icon'} />
</div>
</Show>
</div>
)
}

View File

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

View File

@ -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 (
<div class={clsx(styles.Swiper, props.editorMode ? styles.editorMode : styles.articleMode)}>
<div class={styles.container}>
<Show when={props.editorMode && props.images.length === 0}>
<DropArea
ref={(el) => (dropAreaRef.current = el)}
fileType="image"
isMultiply={true}
placeholder={t('Add images')}
onUpload={handleDropAreaUpload}
description={
<div>
{t('You can upload up to 100 images in .jpg, .png format.')}
<br />
{t('Each image must be no larger than 5 MB.')}
</div>
}
/>
</Show>
<Show when={props.images.length > 0}>
<div class={styles.holder}>
<swiper-container
ref={(el) => (mainSwipeRef.current = el)}
slides-per-view={1}
thumbs-swiper={'.thumbSwiper'}
observer={true}
onSlideChange={handleSlideChange}
>
<For each={props.images}>
{(slide, index) => (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<swiper-slide lazy="true" virtual-index={index()}>
<div class={styles.image}>
<img src={imageProxy(slide.url)} alt={slide.title} />
<Show when={props.editorMode}>
<Popover content={t('Delete')}>
{(triggerRef: (el) => void) => (
<div
ref={triggerRef}
onClick={() => handleDelete(index())}
class={styles.action}
>
<Icon class={styles.icon} name="delete-white" />
</div>
)}
</Popover>
</Show>
</div>
<Switch>
<Match when={props.editorMode}>
<div class={styles.description}>
<input
type="text"
class={clsx(styles.input, styles.title)}
placeholder={t('Enter image title')}
value={slide.title}
onChange={(event) =>
handleSlideDescriptionChange(index(), 'title', event.target.value)
}
/>
<input
type="text"
class={styles.input}
placeholder={t('Specify the source and the name of the author')}
value={slide.source}
onChange={(event) =>
handleSlideDescriptionChange(index(), 'source', event.target.value)
}
/>
<GrowingTextarea
class={styles.descriptionText}
placeholder={t('Enter image description')}
initialValue={slide.body}
value={(value) => handleSlideDescriptionChange(index(), 'body', value)}
/>
</div>
</Match>
<Match when={!props.editorMode}>
<div class={styles.slideDescription}>
<Show when={slide?.title}>
<div class={styles.articleTitle}>{slide.title}</div>
</Show>
<Show when={slide?.source}>
<div class={styles.source}>{slide.source}</div>
</Show>
<Show when={slide?.body}>
<div class={styles.body}>
<MD body={slide.body} />
</div>
</Show>
</div>
</Match>
</Switch>
</swiper-slide>
)}
</For>
</swiper-container>
<div
class={clsx(styles.navigation, styles.prev, {
[styles.disabled]: slideIndex() === 0
})}
onClick={() => mainSwipeRef.current.swiper.slidePrev()}
>
<Icon name="swiper-l-arr" class={styles.icon} />
</div>
<div
class={clsx(styles.navigation, styles.next, {
[styles.disabled]: slideIndex() + 1 === props.images.length
})}
onClick={() => mainSwipeRef.current.swiper.slideNext()}
>
<Icon name="swiper-r-arr" class={styles.icon} />
</div>
<div class={styles.counter}>
{slideIndex() + 1} / {props.images.length}
</div>
</div>
<div class={clsx(styles.holder, styles.thumbsHolder)}>
<div class={styles.thumbs}>
<swiper-container
class={'thumbSwiper'}
ref={(el) => (thumbSwipeRef.current = el)}
slides-per-view={'auto'}
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}
>
<For each={props.images}>
{(slide, index) => (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<swiper-slide virtual-index={index()} style={{ width: 'auto', height: 'auto' }}>
<div
class={clsx(styles.imageThumb)}
style={{ 'background-image': `url(${imageProxy(slide.url)})` }}
>
<Show when={props.editorMode}>
<div class={styles.thumbAction}>
<div class={clsx(styles.action)} onClick={() => handleDelete(index())}>
<Icon class={styles.icon} name="delete-white" />
</div>
<div
class={clsx(styles.action, {
[styles.hidden]: index() === 0
})}
onClick={() => handleChangeIndex('left', index())}
>
<Icon
class={styles.icon}
name="arrow-right-white"
style={{ transform: 'rotate(-180deg)' }}
/>
</div>
<div
class={clsx(styles.action, {
[styles.hidden]: index() + 1 === Number(props.images.length)
})}
onClick={() => handleChangeIndex('right', index())}
>
<Icon class={styles.icon} name="arrow-right-white" />
</div>
</div>
</Show>
</div>
</swiper-slide>
)}
</For>
<Show when={props.editorMode}>
<div class={styles.upload}>
<div class={styles.inner} onClick={handleUploadThumb}>
<Show when={!loading()} fallback={<Loading size="small" />}>
<Icon name="swiper-plus" />
</Show>
</div>
</div>
</Show>
</swiper-container>
<div
class={clsx(styles.navigation, styles.thumbsNav, styles.prev, {
[styles.disabled]: slideIndex() === 0
})}
onClick={() => thumbSwipeRef.current.swiper.slidePrev()}
>
<Icon iconClassName={styles.icon} name="swiper-l-arr" class={styles.icon} />
</div>
<div
class={clsx(styles.navigation, styles.thumbsNav, styles.next, {
[styles.disabled]: slideIndex() + 1 === props.images.length
})}
onClick={() => thumbSwipeRef.current.swiper.slideNext()}
>
<Icon name="swiper-r-arr" iconClassName={styles.icon} class={styles.icon} />
</div>
</div>
</div>
</Show>
</div>
</div>
)
}

View File

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

View File

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

View File

@ -0,0 +1,45 @@
import 'solid-js'
import { SwiperOptions } from 'swiper'
import { SwiperSlideProps } from 'swiper/react'
type Kebab<T extends string, A extends string = ''> = T extends `${infer F}${infer R}`
? Kebab<R, `${A}${F extends Lowercase<F> ? '' : '-'}${Lowercase<F>}`>
: 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<T> = {
// eslint-disable-next-line @typescript-eslint/ban-types
[key in keyof T as Kebab<key & string>]: T[key] extends Object ? KebabObjectKeys<T[key]> : 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<SwiperOptions> {
ref?: RefObject<SwiperRef>
children?: JSX.Element
onSlideChange?: () => void
class?: string
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface SwiperSlideAttributes extends KebabObjectKeys<SwiperSlideProps> {
style?: unknown
}
}
}

View File

@ -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 = () => {
</a>
</li>
<li>
<a href="#">
<div class={styles.link} onClick={() => handleCreate('image')}>
<Icon name="create-images" class={styles.icon} />
<div>{t('images')}</div>
</a>
</div>
</li>
<li>
<a href="#">

View File

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

View File

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

View File

@ -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<Map<LayoutType, Shout[]>>(new Map())
const addLayoutShouts = (layout: LayoutType, shouts: Shout[]) => {
setSortedLayoutShouts((prevSorted: Map<LayoutType, Shout[]>) => {
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
}
}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
export const isValidEmail = (email: string) => {
export const validateEmail = (email: string) => {
if (!email) {
return false
}

37
src/utils/validateFile.ts Normal file
View File

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