parent
a18b6b9e6d
commit
9f7d5d04b6
35
package-lock.json
generated
35
package-lock.json
generated
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
3
public/icons/delete-white.svg
Normal file
3
public/icons/delete-white.svg
Normal 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 |
3
public/icons/swiper-l-arr.svg
Normal file
3
public/icons/swiper-l-arr.svg
Normal 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 |
4
public/icons/swiper-plus.svg
Normal file
4
public/icons/swiper-plus.svg
Normal 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 |
3
public/icons/swiper-r-arr.svg
Normal file
3
public/icons/swiper-r-arr.svg
Normal 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 |
|
@ -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.",
|
||||
|
|
|
@ -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": "Чтобы написать комментарий, необходимо",
|
||||
|
|
|
@ -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
|
||||
}))
|
||||
)
|
||||
|
|
|
@ -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' }
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -48,6 +48,7 @@ export default Node.create({
|
|||
return {
|
||||
toggleArticle:
|
||||
() =>
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
({ commands }) => {
|
||||
return commands.toggleWrap('article')
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
71
src/components/_shared/DropArea/DropArea.module.scss
Normal file
71
src/components/_shared/DropArea/DropArea.module.scss
Normal 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%);
|
||||
}
|
||||
}
|
103
src/components/_shared/DropArea/DropArea.tsx
Normal file
103
src/components/_shared/DropArea/DropArea.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
src/components/_shared/DropArea/index.ts
Normal file
1
src/components/_shared/DropArea/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { DropArea } from './DropArea'
|
|
@ -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)}
|
||||
|
|
|
@ -24,4 +24,9 @@
|
|||
animation-duration: 2s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: linear;
|
||||
|
||||
.small & {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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;
|
||||
//}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
1
src/components/_shared/Slider/index.ts
Normal file
1
src/components/_shared/Slider/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Slider } from './Slider'
|
352
src/components/_shared/SolidSwiper/SolidSwiper.tsx
Normal file
352
src/components/_shared/SolidSwiper/SolidSwiper.tsx
Normal 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>
|
||||
)
|
||||
}
|
323
src/components/_shared/SolidSwiper/Swiper.module.scss
Normal file
323
src/components/_shared/SolidSwiper/Swiper.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
1
src/components/_shared/SolidSwiper/index.ts
Normal file
1
src/components/_shared/SolidSwiper/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { SolidSwiper } from './SolidSwiper'
|
45
src/components/_shared/SolidSwiper/swiper.d.ts
vendored
Normal file
45
src/components/_shared/SolidSwiper/swiper.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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="#">
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
48
src/stores/zine/layouts.ts
Normal file
48
src/stores/zine/layouts.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
37
src/utils/validateFile.ts
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user