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-config-standard-scss": "9.0.0",
|
||||||
"stylelint-order": "6.0.3",
|
"stylelint-order": "6.0.3",
|
||||||
"stylelint-scss": "5.0.0",
|
"stylelint-scss": "5.0.0",
|
||||||
"swiper": "8.4.7",
|
"swiper": "9.4.1",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "5.0.4",
|
"typescript": "5.0.4",
|
||||||
"undici": "5.21.0",
|
"undici": "5.21.0",
|
||||||
|
@ -8792,15 +8792,6 @@
|
||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"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": {
|
"node_modules/domelementtype": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
@ -19178,9 +19169,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/swiper": {
|
"node_modules/swiper": {
|
||||||
"version": "8.4.7",
|
"version": "9.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/swiper/-/swiper-8.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/swiper/-/swiper-9.4.1.tgz",
|
||||||
"integrity": "sha512-VwO/KU3i9IV2Sf+W2NqyzwWob4yX9Qdedq6vBtS0rFqJ6Fa5iLUJwxQkuD4I38w0WDJwmFl8ojkdcRFPHWD+2g==",
|
"integrity": "sha512-1nT2T8EzUpZ0FagEqaN/YAhRj33F2x/lN6cyB0/xoYJDMf8KwTFT3hMOeoB8Tg4o3+P/CKqskP+WX0Df046fqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
@ -19192,9 +19183,7 @@
|
||||||
"url": "http://opencollective.com/swiper"
|
"url": "http://opencollective.com/swiper"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"hasInstallScript": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dom7": "^4.0.4",
|
|
||||||
"ssr-window": "^4.0.2"
|
"ssr-window": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"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": {
|
"domelementtype": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
@ -34870,12 +34850,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"swiper": {
|
"swiper": {
|
||||||
"version": "8.4.7",
|
"version": "9.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/swiper/-/swiper-8.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/swiper/-/swiper-9.4.1.tgz",
|
||||||
"integrity": "sha512-VwO/KU3i9IV2Sf+W2NqyzwWob4yX9Qdedq6vBtS0rFqJ6Fa5iLUJwxQkuD4I38w0WDJwmFl8ojkdcRFPHWD+2g==",
|
"integrity": "sha512-1nT2T8EzUpZ0FagEqaN/YAhRj33F2x/lN6cyB0/xoYJDMf8KwTFT3hMOeoB8Tg4o3+P/CKqskP+WX0Df046fqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"dom7": "^4.0.4",
|
|
||||||
"ssr-window": "^4.0.2"
|
"ssr-window": "^4.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -162,7 +162,7 @@
|
||||||
"stylelint-config-standard-scss": "9.0.0",
|
"stylelint-config-standard-scss": "9.0.0",
|
||||||
"stylelint-order": "6.0.3",
|
"stylelint-order": "6.0.3",
|
||||||
"stylelint-scss": "5.0.0",
|
"stylelint-scss": "5.0.0",
|
||||||
"swiper": "8.4.7",
|
"swiper": "9.4.1",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "5.0.4",
|
"typescript": "5.0.4",
|
||||||
"undici": "5.21.0",
|
"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 another image": "Add another image",
|
||||||
"Add comment": "Comment",
|
"Add comment": "Comment",
|
||||||
"Add image": "Add image",
|
"Add image": "Add image",
|
||||||
|
"Add images": "Add images",
|
||||||
"Add link": "Add link",
|
"Add link": "Add link",
|
||||||
"Add signature": "Add signature",
|
"Add signature": "Add signature",
|
||||||
"Add url": "Add url",
|
"Add url": "Add url",
|
||||||
|
@ -52,7 +53,6 @@
|
||||||
"Cooperate": "Cooperate",
|
"Cooperate": "Cooperate",
|
||||||
"Copy": "Copy",
|
"Copy": "Copy",
|
||||||
"Copy link": "Copy link",
|
"Copy link": "Copy link",
|
||||||
"Link copied": "Link copied",
|
|
||||||
"Corrections history": "Corrections history",
|
"Corrections history": "Corrections history",
|
||||||
"Create Chat": "Create Chat",
|
"Create Chat": "Create Chat",
|
||||||
"Create Group": "Create a group",
|
"Create Group": "Create a group",
|
||||||
|
@ -74,18 +74,16 @@
|
||||||
"Dogma": "Dogma",
|
"Dogma": "Dogma",
|
||||||
"Drafts": "Drafts",
|
"Drafts": "Drafts",
|
||||||
"Drag the image to this area": "Drag the image to this area",
|
"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",
|
"Edit": "Edit",
|
||||||
"Editing": "Editing",
|
"Editing": "Editing",
|
||||||
"Email": "Mail",
|
"Email": "Mail",
|
||||||
"Enter": "Enter",
|
"Enter": "Enter",
|
||||||
"Enter URL address": "Enter URL address",
|
"Enter URL address": "Enter URL address",
|
||||||
|
"Enter image description": "Enter image description",
|
||||||
|
"Enter image title": "Enter image title",
|
||||||
"Enter text": "Enter text",
|
"Enter text": "Enter text",
|
||||||
"Enter the Discours": "Enter the Discours",
|
"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 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",
|
"Enter your new password": "Enter your new password",
|
||||||
"Error": "Error",
|
"Error": "Error",
|
||||||
|
@ -147,6 +145,7 @@
|
||||||
"Knowledge base": "Knowledge base",
|
"Knowledge base": "Knowledge base",
|
||||||
"Last rev.": "Посл. изм.",
|
"Last rev.": "Посл. изм.",
|
||||||
"Let's log in": "Let's log in",
|
"Let's log in": "Let's log in",
|
||||||
|
"Link copied": "Link copied",
|
||||||
"Link sent, check your email": "Link sent, check your email",
|
"Link sent, check your email": "Link sent, check your email",
|
||||||
"Lists": "Lists",
|
"Lists": "Lists",
|
||||||
"Literature": "Literature",
|
"Literature": "Literature",
|
||||||
|
@ -232,6 +231,7 @@
|
||||||
"Something went wrong, please try again": "Something went wrong, please try again",
|
"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",
|
"Sorry, this address is already taken, please choose another one.": "Sorry, this address is already taken, please choose another one",
|
||||||
"Special projects": "Special projects",
|
"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",
|
"Start conversation": "Start a conversation",
|
||||||
"Subsccriptions": "Subscriptions",
|
"Subsccriptions": "Subscriptions",
|
||||||
"Subscribe": "Subscribe",
|
"Subscribe": "Subscribe",
|
||||||
|
@ -246,7 +246,6 @@
|
||||||
"Terms of use": "Site rules",
|
"Terms of use": "Site rules",
|
||||||
"Text checking": "Text checking",
|
"Text checking": "Text checking",
|
||||||
"Thank you": "Thank you",
|
"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 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 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.",
|
"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 myself": "О себе",
|
||||||
"About the project": "О проекте",
|
"About the project": "О проекте",
|
||||||
"Accomplices": "Соучастники",
|
"Accomplices": "Соучастники",
|
||||||
|
"Accomplices": "Соучастники",
|
||||||
"Add another image": "Добавить другое изображение",
|
"Add another image": "Добавить другое изображение",
|
||||||
"Add comment": "Комментировать",
|
"Add comment": "Комментировать",
|
||||||
"Add image": "Добавить изображение",
|
"Add image": "Добавить изображение",
|
||||||
|
"Add images": "Добавить изображения",
|
||||||
"Add link": "Добавить ссылку",
|
"Add link": "Добавить ссылку",
|
||||||
"Add signature": "Добавить подпись",
|
"Add signature": "Добавить подпись",
|
||||||
"Add to bookmarks": "Добавить в закладки",
|
"Add to bookmarks": "Добавить в закладки",
|
||||||
|
@ -55,7 +57,6 @@
|
||||||
"Cooperate": "Соучаствовать",
|
"Cooperate": "Соучаствовать",
|
||||||
"Copy": "Скопировать",
|
"Copy": "Скопировать",
|
||||||
"Copy link": "Скопировать ссылку",
|
"Copy link": "Скопировать ссылку",
|
||||||
"Link copied": "Ссылка скопирована",
|
|
||||||
"Corrections history": "История правок",
|
"Corrections history": "История правок",
|
||||||
"Create Chat": "Создать чат",
|
"Create Chat": "Создать чат",
|
||||||
"Create Group": "Создать группу",
|
"Create Group": "Создать группу",
|
||||||
|
@ -77,19 +78,17 @@
|
||||||
"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.": "Каждое изображение должно быть размером не больше 5 мб.",
|
||||||
"Edit": "Редактировать",
|
"Edit": "Редактировать",
|
||||||
"Edited": "Отредактирован",
|
"Edited": "Отредактирован",
|
||||||
"Editing": "Редактирование",
|
"Editing": "Редактирование",
|
||||||
"Email": "Почта",
|
"Email": "Почта",
|
||||||
"Enter": "Войти",
|
"Enter": "Войти",
|
||||||
"Enter URL address": "Введите адрес ссылки",
|
"Enter URL address": "Введите адрес ссылки",
|
||||||
|
"Enter image description": "Введите описание изображения",
|
||||||
|
"Enter image title": "Введите название изображения",
|
||||||
"Enter text": "Введите текст",
|
"Enter text": "Введите текст",
|
||||||
"Enter the Discours": "Войти в Дискурс",
|
"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 the code or click the link from email to confirm": "Введите код из письма или пройдите по ссылке в письме для подтверждения регистрации",
|
||||||
"Enter your new password": "Введите новый пароль",
|
"Enter your new password": "Введите новый пароль",
|
||||||
"Error": "Ошибка",
|
"Error": "Ошибка",
|
||||||
|
@ -155,6 +154,7 @@
|
||||||
"Knowledge base": "База знаний",
|
"Knowledge base": "База знаний",
|
||||||
"Last rev.": "Посл. изм.",
|
"Last rev.": "Посл. изм.",
|
||||||
"Let's log in": "Давайте авторизуемся",
|
"Let's log in": "Давайте авторизуемся",
|
||||||
|
"Link copied": "Ссылка скопирована",
|
||||||
"Link sent, check your email": "Ссылка отправлена, проверьте почту",
|
"Link sent, check your email": "Ссылка отправлена, проверьте почту",
|
||||||
"Lists": "Списки",
|
"Lists": "Списки",
|
||||||
"Literature": "Литература",
|
"Literature": "Литература",
|
||||||
|
@ -245,6 +245,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": "Укажите источник и имя автора",
|
||||||
"Start conversation": "Начать беседу",
|
"Start conversation": "Начать беседу",
|
||||||
"Subheader": "Подзаголовок",
|
"Subheader": "Подзаголовок",
|
||||||
"Subscribe": "Подписаться",
|
"Subscribe": "Подписаться",
|
||||||
|
@ -260,10 +261,9 @@
|
||||||
"Terms of use": "Правила сайта",
|
"Terms of use": "Правила сайта",
|
||||||
"Text checking": "Проверка текста",
|
"Text checking": "Проверка текста",
|
||||||
"Thank you": "Благодарности",
|
"Thank you": "Благодарности",
|
||||||
"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": "Такой email уже зарегистрирован. Если это вы",
|
"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": "Эту публикацию еще пока никто не оценил",
|
"This post has not been rated yet": "Эту публикацию еще пока никто не оценил",
|
||||||
"To leave a comment please": "Чтобы оставить комментарий, необходимо",
|
"To leave a comment please": "Чтобы оставить комментарий, необходимо",
|
||||||
"To write a comment, you must": "Чтобы написать комментарий, необходимо",
|
"To write a comment, you must": "Чтобы написать комментарий, необходимо",
|
||||||
|
|
|
@ -80,7 +80,7 @@ export default (props: { media: MediaItem[]; articleSlug: string; body: string }
|
||||||
setTracks(
|
setTracks(
|
||||||
tracks().map((track) => ({
|
tracks().map((track) => ({
|
||||||
...track,
|
...track,
|
||||||
isCurrent: track.id === m.id ? true : false,
|
isCurrent: track.id === m.id,
|
||||||
isPlaying: track.id === m.id ? !track.isPlaying : false
|
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 { Show } from 'solid-js'
|
||||||
|
import { Icon } from '../_shared/Icon'
|
||||||
import type { Reaction } from '../../graphql/types.gen'
|
import type { Reaction } from '../../graphql/types.gen'
|
||||||
import { formatDate } from '../../utils'
|
import { formatDate } from '../../utils'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
import styles from './CommentDate.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
comment: Reaction
|
comment: Reaction
|
||||||
|
@ -15,7 +15,6 @@ type Props = {
|
||||||
export const CommentDate = (props: Props) => {
|
export const CommentDate = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
|
||||||
|
|
||||||
const formattedDate = (date) => {
|
const formattedDate = (date) => {
|
||||||
const formatDateOptions: Intl.DateTimeFormatOptions = props.isShort
|
const formatDateOptions: Intl.DateTimeFormatOptions = props.isShort
|
||||||
? { month: 'long', day: 'numeric', year: 'numeric' }
|
? { 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 { capitalize, formatDate } from '../../utils'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { AuthorCard } from '../Author/AuthorCard'
|
import { AuthorCard } from '../Author/AuthorCard'
|
||||||
|
@ -13,7 +11,6 @@ import { clsx } from 'clsx'
|
||||||
import { CommentsTree } from './CommentsTree'
|
import { CommentsTree } from './CommentsTree'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { VideoPlayer } from '../_shared/VideoPlayer'
|
import { VideoPlayer } from '../_shared/VideoPlayer'
|
||||||
import Slider from '../_shared/Slider'
|
|
||||||
import { getPagePath } from '@nanostores/router'
|
import { getPagePath } from '@nanostores/router'
|
||||||
import { router, useRouter } from '../../stores/router'
|
import { router, useRouter } from '../../stores/router'
|
||||||
import { useReactions } from '../../context/reactions'
|
import { useReactions } from '../../context/reactions'
|
||||||
|
@ -23,6 +20,9 @@ import stylesHeader from '../Nav/Header.module.scss'
|
||||||
import styles from './Article.module.scss'
|
import styles from './Article.module.scss'
|
||||||
import { imageProxy } from '../../utils/imageProxy'
|
import { imageProxy } from '../../utils/imageProxy'
|
||||||
import { Popover } from '../_shared/Popover'
|
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 {
|
interface ArticleProps {
|
||||||
article: Shout
|
article: Shout
|
||||||
|
@ -35,33 +35,6 @@ interface MediaItem {
|
||||||
body?: string
|
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) => {
|
export const FullArticle = (props: ArticleProps) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const {
|
const {
|
||||||
|
@ -95,8 +68,10 @@ export const FullArticle = (props: ArticleProps) => {
|
||||||
}, 'bookmark')
|
}, '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 commentsRef: { current: HTMLDivElement } = { current: null }
|
||||||
const scrollToComments = () => {
|
const scrollToComments = () => {
|
||||||
|
@ -141,7 +116,8 @@ export const FullArticle = (props: ArticleProps) => {
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<article class="col-md-16 col-lg-14 col-xl-12 offset-md-5">
|
<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()}>
|
<Show when={mainTopic()}>
|
||||||
<div class={styles.shoutTopic}>
|
<div class={styles.shoutTopic}>
|
||||||
<a
|
<a
|
||||||
|
@ -153,10 +129,11 @@ export const FullArticle = (props: ArticleProps) => {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class={styles.shoutHeader}>
|
|
||||||
<div>
|
|
||||||
<h1>{props.article.title}</h1>
|
<h1>{props.article.title}</h1>
|
||||||
<div>
|
<Show when={props.article.subtitle}>
|
||||||
|
<h4>{capitalize(props.article.subtitle, false)}</h4>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class={styles.shoutAuthor}>
|
<div class={styles.shoutAuthor}>
|
||||||
<For each={props.article.authors}>
|
<For each={props.article.authors}>
|
||||||
{(a: Author, index) => (
|
{(a: Author, index) => (
|
||||||
|
@ -167,21 +144,35 @@ export const FullArticle = (props: ArticleProps) => {
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* @@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'}>
|
<Show when={props.article.cover && props.article.layout !== 'video'}>
|
||||||
<div class={styles.shoutCover}>
|
<div
|
||||||
<img src={imageProxy(props.article.cover)} alt="Article cover" />
|
class={styles.shoutCover}
|
||||||
</div>
|
style={{ 'background-image': `url('${imageProxy(props.article.cover)}')` }}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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()}>
|
<Show when={body()}>
|
||||||
<div class={styles.shoutBody}>
|
<div class={styles.shoutBody}>
|
||||||
<Show when={!body().startsWith('<')} fallback={<div innerHTML={body()} />}>
|
<Show when={!body().startsWith('<')} fallback={<div innerHTML={body()} />}>
|
||||||
|
@ -189,33 +180,10 @@ export const FullArticle = (props: ArticleProps) => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={media().length > 0 && props.article.layout !== 'image'}>
|
|
||||||
<div class="media-items">
|
|
||||||
<AudioPlayer media={media()} articleSlug={props.article.slug} body={body()} />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</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="wide-container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-16 offset-md-5">
|
<div class="col-md-16 offset-md-5">
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { createSignal, JSX, Show } from 'solid-js'
|
import { createSignal, JSX, Show } from 'solid-js'
|
||||||
|
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { isValidEmail } from '../../utils/validators'
|
import { validateEmail } from '../../utils/validateEmail'
|
||||||
import { Button } from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
|
|
||||||
import styles from './Subscribe.module.scss'
|
import styles from './Subscribe.module.scss'
|
||||||
|
@ -23,7 +23,7 @@ export default () => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidEmail(email())) {
|
if (!validateEmail(email())) {
|
||||||
setEmailError(t('Please check your email address'))
|
setEmailError(t('Please check your email address'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,32 +6,27 @@ import { createEffect, createSignal, Show } from 'solid-js'
|
||||||
import { useSnackbar } from '../../../context/snackbar'
|
import { useSnackbar } from '../../../context/snackbar'
|
||||||
import { validateUrl } from '../../../utils/validateUrl'
|
import { validateUrl } from '../../../utils/validateUrl'
|
||||||
import { VideoPlayer } from '../../_shared/VideoPlayer'
|
import { VideoPlayer } from '../../_shared/VideoPlayer'
|
||||||
|
import type { MediaItem } from '../../../pages/types'
|
||||||
// import { handleFileUpload } from '../../../utils/handleFileUpload'
|
// import { handleFileUpload } from '../../../utils/handleFileUpload'
|
||||||
|
|
||||||
type VideoItem = {
|
|
||||||
url: string
|
|
||||||
title: string
|
|
||||||
body: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
class?: string
|
class?: string
|
||||||
data: (value: VideoItem) => void
|
data: (value: MediaItem[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VideoUploader = (props: Props) => {
|
export const VideoUploader = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [dragActive, setDragActive] = createSignal(false)
|
const [dragActive, setDragActive] = createSignal(false)
|
||||||
const [dragError, setDragError] = createSignal<string | undefined>()
|
const [dragError, setDragError] = createSignal<string>()
|
||||||
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
|
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
|
||||||
const [data, setData] = createSignal<VideoItem>()
|
const [data, setData] = createSignal<MediaItem>()
|
||||||
|
|
||||||
const updateData = (key, value) => {
|
const updateData = (key, value) => {
|
||||||
setData((prev) => ({ ...prev, [key]: value }))
|
setData((prev) => ({ ...prev, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.data(data())
|
props.data([data()])
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
|
@ -48,6 +48,7 @@ export default Node.create({
|
||||||
return {
|
return {
|
||||||
toggleArticle:
|
toggleArticle:
|
||||||
() =>
|
() =>
|
||||||
|
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||||
({ commands }) => {
|
({ commands }) => {
|
||||||
return commands.toggleWrap('article')
|
return commands.toggleWrap('article')
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { createMemo, createSignal, For, Show } from 'solid-js'
|
import { createMemo, createSignal, For, Show } from 'solid-js'
|
||||||
import type { Shout } from '../../graphql/types.gen'
|
import type { Shout } from '../../graphql/types.gen'
|
||||||
import { capitalize, formatDate } from '../../utils'
|
import { capitalize, formatDate } from '../../utils'
|
||||||
import { translit } from '../../utils/ru2en'
|
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import styles from './ArticleCard.module.scss'
|
import styles from './ArticleCard.module.scss'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
@ -221,7 +220,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
<div class={clsx(styles.shoutCardDetailsItem, styles.shoutCardComments)}>
|
<div class={clsx(styles.shoutCardDetailsItem, styles.shoutCardComments)}>
|
||||||
<a href="#" onClick={(event) => scrollToComments(event)}>
|
<a href="#" onClick={(event) => scrollToComments(event)}>
|
||||||
<Icon name="comment" class={clsx(styles.icon, styles.feedControlIcon)} />
|
<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>
|
<span class={styles.shoutCardLinkContainer}>{stat?.commented || t('Add comment')}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -233,7 +235,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
||||||
<a href={getPagePath(router, 'edit', { shoutId: id.toString() })}>
|
<a href={getPagePath(router, 'edit', { shoutId: id.toString() })}>
|
||||||
<Icon name="pencil-outline" class={clsx(styles.icon, styles.feedControlIcon)} />
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -244,7 +249,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
<div class={styles.shoutCardDetailsItem} ref={triggerRef}>
|
||||||
<button>
|
<button>
|
||||||
<Icon name="bookmark" class={clsx(styles.icon, styles.feedControlIcon)} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -263,7 +271,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
trigger={
|
trigger={
|
||||||
<button>
|
<button>
|
||||||
<Icon name="share-outline" class={clsx(styles.icon, styles.feedControlIcon)} />
|
<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>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -282,7 +293,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
trigger={
|
trigger={
|
||||||
<button>
|
<button>
|
||||||
<Icon name="ellipsis" class={clsx(styles.icon, styles.feedControlIcon)} />
|
<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>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -7,7 +7,7 @@ import type { AuthModalSearchParams } from './types'
|
||||||
import { ApiError } from '../../../utils/apiClient'
|
import { ApiError } from '../../../utils/apiClient'
|
||||||
import { signSendLink } from '../../../stores/auth'
|
import { signSendLink } from '../../../stores/auth'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { isValidEmail } from '../../../utils/validators'
|
import { validateEmail } from '../../../utils/validateEmail'
|
||||||
|
|
||||||
type FormFields = {
|
type FormFields = {
|
||||||
email: string
|
email: string
|
||||||
|
@ -38,7 +38,7 @@ export const ForgotPasswordForm = () => {
|
||||||
|
|
||||||
if (!email()) {
|
if (!email()) {
|
||||||
newValidationErrors.email = t('Please enter email')
|
newValidationErrors.email = t('Please enter email')
|
||||||
} else if (!isValidEmail(email())) {
|
} else if (!validateEmail(email())) {
|
||||||
newValidationErrors.email = t('Invalid email')
|
newValidationErrors.email = t('Invalid email')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import type { AuthModalSearchParams } from './types'
|
||||||
import { hideModal } from '../../../stores/ui'
|
import { hideModal } from '../../../stores/ui'
|
||||||
import { useSession } from '../../../context/session'
|
import { useSession } from '../../../context/session'
|
||||||
import { signSendLink } from '../../../stores/auth'
|
import { signSendLink } from '../../../stores/auth'
|
||||||
import { isValidEmail } from '../../../utils/validators'
|
import { validateEmail } from '../../../utils/validateEmail'
|
||||||
import { generateModalTitleFromSource } from '../../../utils/custom-i18n'
|
import { generateModalTitleFromSource } from '../../../utils/custom-i18n'
|
||||||
|
|
||||||
import { useSnackbar } from '../../../context/snackbar'
|
import { useSnackbar } from '../../../context/snackbar'
|
||||||
|
@ -76,7 +76,7 @@ export const LoginForm = () => {
|
||||||
|
|
||||||
if (!email()) {
|
if (!email()) {
|
||||||
newValidationErrors.email = t('Please enter email')
|
newValidationErrors.email = t('Please enter email')
|
||||||
} else if (!isValidEmail(email())) {
|
} else if (!validateEmail(email())) {
|
||||||
newValidationErrors.email = t('Invalid email')
|
newValidationErrors.email = t('Invalid email')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { hideModal } from '../../../stores/ui'
|
||||||
import { checkEmail, useEmailChecks } from '../../../stores/emailChecks'
|
import { checkEmail, useEmailChecks } from '../../../stores/emailChecks'
|
||||||
import { register } from '../../../stores/auth'
|
import { register } from '../../../stores/auth'
|
||||||
import { useLocalize } from '../../../context/localize'
|
import { useLocalize } from '../../../context/localize'
|
||||||
import { isValidEmail } from '../../../utils/validators'
|
import { validateEmail } from '../../../utils/validateEmail'
|
||||||
import { generateModalTitleFromSource } from '../../../utils/custom-i18n'
|
import { generateModalTitleFromSource } from '../../../utils/custom-i18n'
|
||||||
|
|
||||||
type FormFields = {
|
type FormFields = {
|
||||||
|
@ -40,7 +40,7 @@ export const RegisterForm = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEmailBlur = () => {
|
const handleEmailBlur = () => {
|
||||||
if (isValidEmail(email())) {
|
if (validateEmail(email())) {
|
||||||
checkEmail(email())
|
checkEmail(email())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -93,7 +93,7 @@ export const RegisterForm = () => {
|
||||||
|
|
||||||
if (!cleanEmail) {
|
if (!cleanEmail) {
|
||||||
newValidationErrors.email = t('Please enter email')
|
newValidationErrors.email = t('Please enter email')
|
||||||
} else if (!isValidEmail(email())) {
|
} else if (!validateEmail(email())) {
|
||||||
newValidationErrors.email = t('Invalid email')
|
newValidationErrors.email = t('Invalid email')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -195,7 +195,11 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
{/*FIXME: replace with route*/}
|
{/*FIXME: replace with route*/}
|
||||||
<div classList={{ entered: page().path === '/inbox' }}>
|
<div classList={{ entered: page().path === '/inbox' }}>
|
||||||
<Icon name="inbox-white" counter={session()?.news?.unread || 0} class={styles.icon} />
|
<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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 { useLocalize } from '../../context/localize'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Title } from '@solidjs/meta'
|
import { Title } from '@solidjs/meta'
|
||||||
|
@ -18,6 +18,7 @@ import { GrowingTextarea } from '../_shared/GrowingTextarea'
|
||||||
import { VideoUploader } from '../Editor/VideoUploader'
|
import { VideoUploader } from '../Editor/VideoUploader'
|
||||||
import { VideoPlayer } from '../_shared/VideoPlayer'
|
import { VideoPlayer } from '../_shared/VideoPlayer'
|
||||||
import { slugify } from '../../utils/slugify'
|
import { slugify } from '../../utils/slugify'
|
||||||
|
import { SolidSwiper } from '../_shared/SolidSwiper'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
shout: Shout
|
shout: Shout
|
||||||
|
@ -42,7 +43,7 @@ export const EditView = (props: Props) => {
|
||||||
const [isScrolled, setIsScrolled] = createSignal(false)
|
const [isScrolled, setIsScrolled] = createSignal(false)
|
||||||
const [topics, setTopics] = createSignal<Topic[]>(null)
|
const [topics, setTopics] = createSignal<Topic[]>(null)
|
||||||
const [coverImage, setCoverImage] = createSignal<string>(null)
|
const [coverImage, setCoverImage] = createSignal<string>(null)
|
||||||
const [media, setMedia] = createSignal<string>(props.shout.media)
|
|
||||||
const { page } = useRouter()
|
const { page } = useRouter()
|
||||||
const {
|
const {
|
||||||
form,
|
form,
|
||||||
|
@ -61,10 +62,14 @@ export const EditView = (props: Props) => {
|
||||||
mainTopic: shoutTopics.find((topic) => topic.slug === props.shout.mainTopic) || EMPTY_TOPIC,
|
mainTopic: shoutTopics.find((topic) => topic.slug === props.shout.mainTopic) || EMPTY_TOPIC,
|
||||||
body: props.shout.body,
|
body: props.shout.body,
|
||||||
coverImageUrl: props.shout.cover,
|
coverImageUrl: props.shout.cover,
|
||||||
media: media(),
|
media: props.shout.media,
|
||||||
layout: props.shout.layout
|
layout: props.shout.layout
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mediaItems = createMemo(() => {
|
||||||
|
return JSON.parse(form.media || '[]')
|
||||||
|
})
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const allTopics = await apiClient.getAllTopics()
|
const allTopics = await apiClient.getAllTopics()
|
||||||
setTopics(allTopics)
|
setTopics(allTopics)
|
||||||
|
@ -120,8 +125,23 @@ export const EditView = (props: Props) => {
|
||||||
setForm('selectedTopics', newSelectedTopics)
|
setForm('selectedTopics', newSelectedTopics)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddMedia = (data) => {
|
const handleAddImages = (data) => {
|
||||||
setForm('media', JSON.stringify([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 (
|
return (
|
||||||
|
@ -167,25 +187,36 @@ export const EditView = (props: Props) => {
|
||||||
maxLength={100}
|
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={props.shout.layout === 'video'}>
|
||||||
<Show
|
<Show
|
||||||
when={media()}
|
when={form.media}
|
||||||
fallback={
|
fallback={
|
||||||
<VideoUploader
|
<VideoUploader
|
||||||
data={(data) => {
|
data={(data) => {
|
||||||
handleAddMedia(data)
|
handleAddImages(data)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={JSON.parse(media())}>
|
<For each={mediaItems()}>
|
||||||
{(mediaItem) => (
|
{(mediaItem) => (
|
||||||
<>
|
<>
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
videoUrl={mediaItem?.url}
|
videoUrl={mediaItem?.url}
|
||||||
title={mediaItem?.title}
|
title={mediaItem?.title}
|
||||||
description={mediaItem?.body}
|
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 stylesTopic from '../Feed/CardTopic.module.scss'
|
||||||
import stylesBeside from '../../components/Feed/Beside.module.scss'
|
import stylesBeside from '../../components/Feed/Beside.module.scss'
|
||||||
import { CommentDate } from '../Article/CommentDate'
|
import { CommentDate } from '../Article/CommentDate'
|
||||||
import {Beside} from "../Feed/Beside";
|
|
||||||
|
|
||||||
export const FEED_PAGE_SIZE = 20
|
export const FEED_PAGE_SIZE = 20
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { Row1 } from '../Feed/Row1'
|
||||||
import Hero from '../Discours/Hero'
|
import Hero from '../Discours/Hero'
|
||||||
import { Beside } from '../Feed/Beside'
|
import { Beside } from '../Feed/Beside'
|
||||||
import RowShort from '../Feed/RowShort'
|
import RowShort from '../Feed/RowShort'
|
||||||
import Slider from '../_shared/Slider'
|
import { Slider } from '../_shared/Slider'
|
||||||
import Group from '../Feed/Group'
|
import Group from '../Feed/Group'
|
||||||
import type { Shout, Topic } from '../../graphql/types.gen'
|
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 { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
|
||||||
import { splitToPages } from '../../utils/splitToPages'
|
import { splitToPages } from '../../utils/splitToPages'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import Slider from '../_shared/Slider'
|
import { Slider } from '../_shared/Slider'
|
||||||
import { Row1 } from '../Feed/Row1'
|
import { Row1 } from '../Feed/Row1'
|
||||||
import { ArticleCard } from '../Feed/ArticleCard'
|
import { ArticleCard } from '../Feed/ArticleCard'
|
||||||
import { useLocalize } from '../../context/localize'
|
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) => {
|
export const GrowingTextarea = (props: Props) => {
|
||||||
const [value, setValue] = createSignal('')
|
const [value, setValue] = createSignal<string>(props.initialValue ?? '')
|
||||||
const [isFocused, setIsFocused] = createSignal(false)
|
const [isFocused, setIsFocused] = createSignal(false)
|
||||||
const handleChangeValue = (event) => {
|
const handleChangeValue = (event) => {
|
||||||
setValue(event.target.value)
|
setValue(event.target.value)
|
||||||
props.value(event.target.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = async (event) => {
|
const handleKeyDown = async (event) => {
|
||||||
|
@ -39,6 +38,7 @@ export const GrowingTextarea = (props: Props) => {
|
||||||
value={props.initialValue}
|
value={props.initialValue}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onInput={(event) => handleChangeValue(event)}
|
onInput={(event) => handleChangeValue(event)}
|
||||||
|
onChange={(event) => props.value(event.target.value)}
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
|
|
|
@ -24,4 +24,9 @@
|
||||||
animation-duration: 2s;
|
animation-duration: 2s;
|
||||||
animation-iteration-count: infinite;
|
animation-iteration-count: infinite;
|
||||||
animation-timing-function: linear;
|
animation-timing-function: linear;
|
||||||
|
|
||||||
|
.small & {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,16 @@
|
||||||
import styles from './Loading.module.scss'
|
import styles from './Loading.module.scss'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
export const Loading = () => {
|
type Props = {
|
||||||
|
size?: 'small'
|
||||||
|
}
|
||||||
|
export const Loading = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<div class={styles.container}>
|
<div
|
||||||
|
class={clsx(styles.container, {
|
||||||
|
[styles.small]: props.size === 'small'
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div class={styles.icon} />
|
<div class={styles.icon} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -211,3 +211,43 @@
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
width: auto;
|
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 type { SwiperOptions } from 'swiper'
|
||||||
import 'swiper/scss'
|
import 'swiper/scss'
|
||||||
import 'swiper/scss/navigation'
|
import 'swiper/scss/navigation'
|
||||||
import 'swiper/scss/pagination'
|
import 'swiper/scss/pagination'
|
||||||
import 'swiper/scss/lazy'
|
|
||||||
import 'swiper/scss/thumbs'
|
import 'swiper/scss/thumbs'
|
||||||
import './Slider.scss'
|
import './Slider.scss'
|
||||||
import { createEffect, createSignal, JSX, Show } from 'solid-js'
|
import { createEffect, createSignal, JSX, Show } from 'solid-js'
|
||||||
import { Icon } from './Icon'
|
import { Icon } from '../Icon'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
interface SliderProps {
|
interface Props {
|
||||||
title?: string
|
title?: string
|
||||||
slidesPerView?: number
|
slidesPerView?: number
|
||||||
isCardsWithCover?: boolean
|
isCardsWithCover?: boolean
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
class?: string
|
|
||||||
isPageGallery?: boolean
|
isPageGallery?: boolean
|
||||||
hasThumbs?: boolean
|
hasThumbs?: boolean
|
||||||
|
variant?: 'uploadPreview'
|
||||||
|
slideIndex?: (value: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (props: SliderProps) => {
|
export const Slider = (props: Props) => {
|
||||||
let el: HTMLDivElement | undefined
|
let el: HTMLDivElement | undefined
|
||||||
let thumbsEl: HTMLDivElement | undefined
|
let thumbsEl: HTMLDivElement | undefined
|
||||||
let pagEl: HTMLDivElement | undefined
|
let pagEl: HTMLDivElement | undefined
|
||||||
|
@ -31,15 +33,20 @@ export default (props: SliderProps) => {
|
||||||
|
|
||||||
const [swiper, setSwiper] = createSignal<Swiper>()
|
const [swiper, setSwiper] = createSignal<Swiper>()
|
||||||
const [swiperThumbs, setSwiperThumbs] = createSignal<Swiper>()
|
const [swiperThumbs, setSwiperThumbs] = createSignal<Swiper>()
|
||||||
|
|
||||||
const opts: SwiperOptions = {
|
const opts: SwiperOptions = {
|
||||||
lazy: true,
|
|
||||||
roundLengths: true,
|
roundLengths: true,
|
||||||
loop: true,
|
loop: true,
|
||||||
centeredSlides: true,
|
centeredSlides: true,
|
||||||
slidesPerView: 1,
|
slidesPerView: 1,
|
||||||
modules: [Navigation, Pagination, Lazy, Thumbs],
|
modules: [Navigation, Pagination, Thumbs],
|
||||||
speed: 500,
|
speed: 500,
|
||||||
|
on: {
|
||||||
|
slideChange: () => {
|
||||||
|
if (swiper()) {
|
||||||
|
props.slideIndex(swiper().realIndex || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
navigation: { nextEl, prevEl },
|
navigation: { nextEl, prevEl },
|
||||||
breakpoints: {
|
breakpoints: {
|
||||||
768: {
|
768: {
|
||||||
|
@ -62,8 +69,7 @@ export default (props: SliderProps) => {
|
||||||
setSwiperThumbs(
|
setSwiperThumbs(
|
||||||
new Swiper(thumbsEl, {
|
new Swiper(thumbsEl, {
|
||||||
slidesPerView: 'auto',
|
slidesPerView: 'auto',
|
||||||
modules: [Lazy, Thumbs],
|
modules: [Thumbs],
|
||||||
lazy: true,
|
|
||||||
roundLengths: true,
|
roundLengths: true,
|
||||||
spaceBetween: 20,
|
spaceBetween: 20,
|
||||||
freeMode: true,
|
freeMode: true,
|
||||||
|
@ -98,14 +104,16 @@ export default (props: SliderProps) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="floor floor--important">
|
<div class={clsx('floor', 'floor--important', props.variant)}>
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<Show when={props.title}>
|
||||||
<h2 class="col-24">{props.title}</h2>
|
<h2 class="col-24">{props.title}</h2>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="sliders-container">
|
<div class="sliders-container">
|
||||||
<div
|
<div
|
||||||
class={clsx('swiper', props.class)}
|
class={clsx('swiper')}
|
||||||
classList={{
|
classList={{
|
||||||
'cards-with-cover': isCardsWithCover,
|
'cards-with-cover': isCardsWithCover,
|
||||||
'swiper--page-gallery': props.isPageGallery
|
'swiper--page-gallery': props.isPageGallery
|
||||||
|
@ -113,13 +121,15 @@ export default (props: SliderProps) => {
|
||||||
ref={el}
|
ref={el}
|
||||||
>
|
>
|
||||||
<div class="swiper-wrapper">{props.children}</div>
|
<div class="swiper-wrapper">{props.children}</div>
|
||||||
|
<Show when={!(props.variant === 'uploadPreview')}>
|
||||||
<div class="slider-arrow-next" ref={nextEl} onClick={() => swiper()?.slideNext()}>
|
<div class="slider-arrow-next" ref={nextEl} onClick={() => swiper()?.slideNext()}>
|
||||||
<Icon name="slider-arrow" class={'icon'} />
|
<Icon name="slider-arrow" class={'icon'} />
|
||||||
</div>
|
</div>
|
||||||
<div class="slider-arrow-prev" ref={prevEl} onClick={() => swiper()?.slidePrev()}>
|
<div class="slider-arrow-prev" ref={prevEl} onClick={() => swiper()?.slidePrev()}>
|
||||||
<Icon name="slider-arrow" class={'icon'} />
|
<Icon name="slider-arrow" class={'icon'} />
|
||||||
</div>
|
</div>
|
||||||
<div class="swiper-pagination" ref={pagEl} />
|
</Show>
|
||||||
|
{/*<div class="swiper-pagination" ref={pagEl} />*/}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={props.hasThumbs}>
|
<Show when={props.hasThumbs}>
|
||||||
|
@ -132,6 +142,14 @@ export default (props: SliderProps) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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 { apiClient } from '../utils/apiClient'
|
||||||
import { redirectPage } from '@nanostores/router'
|
import { redirectPage } from '@nanostores/router'
|
||||||
import { router } from '../stores/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 } })
|
const shout = await apiClient.createArticle({ article: { layout: layout } })
|
||||||
redirectPage(router, 'edit', {
|
redirectPage(router, 'edit', {
|
||||||
shoutId: shout.id.toString()
|
shoutId: shout.id.toString()
|
||||||
|
@ -35,10 +36,10 @@ export const CreatePage = () => {
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">
|
<div class={styles.link} onClick={() => handleCreate('image')}>
|
||||||
<Icon name="create-images" class={styles.icon} />
|
<Icon name="create-images" class={styles.icon} />
|
||||||
<div>{t('images')}</div>
|
<div>{t('images')}</div>
|
||||||
</a>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">
|
<a href="#">
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { clsx } from 'clsx'
|
||||||
import { Row3 } from '../components/Feed/Row3'
|
import { Row3 } from '../components/Feed/Row3'
|
||||||
import { Row2 } from '../components/Feed/Row2'
|
import { Row2 } from '../components/Feed/Row2'
|
||||||
import { Beside } from '../components/Feed/Beside'
|
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 { Row1 } from '../components/Feed/Row1'
|
||||||
import styles from '../styles/Topic.module.scss'
|
import styles from '../styles/Topic.module.scss'
|
||||||
import { ArticleCard } from '../components/Feed/ArticleCard'
|
import { ArticleCard } from '../components/Feed/ArticleCard'
|
||||||
|
|
|
@ -14,7 +14,7 @@ export type PageProps = {
|
||||||
topic?: Topic
|
topic?: Topic
|
||||||
allTopics?: Topic[]
|
allTopics?: Topic[]
|
||||||
searchQuery?: string
|
searchQuery?: string
|
||||||
layout?: string // LayoutType
|
layout?: LayoutType
|
||||||
// other types?
|
// other types?
|
||||||
searchResults?: Shout[]
|
searchResults?: Shout[]
|
||||||
chats?: Chat[]
|
chats?: Chat[]
|
||||||
|
@ -33,3 +33,12 @@ export type UploadFile = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LayoutType = 'article' | 'audio' | 'video' | 'image' | 'literature'
|
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 {
|
:root {
|
||||||
--background-color: #fff;
|
--background-color: #fff;
|
||||||
--default-color: #121416;
|
--default-color: #121416;
|
||||||
|
--background-color-invert: #000;
|
||||||
|
--default-color-invert: #fff;
|
||||||
--link-color: #000;
|
--link-color: #000;
|
||||||
--link-hover-color: #fff;
|
--link-hover-color: #fff;
|
||||||
--link-hover-background: #000;
|
--link-hover-background: #000;
|
||||||
--secondary-color: #85878a;
|
--secondary-color: #85878a;
|
||||||
|
--placeholder-color: #9fa1a7;
|
||||||
|
--placeholder-color-semi: rgba(159, 169, 167, 0.2);
|
||||||
--danger-color: #fc6847;
|
--danger-color: #fc6847;
|
||||||
--lightgray-color: rgb(84 16 17 / 6%);
|
--lightgray-color: rgb(84 16 17 / 6%);
|
||||||
--font: -apple-system, blinkmacsystemfont, 'Segoe UI', roboto, oxygen, ubuntu, cantarell, 'Open Sans',
|
--font: -apple-system, blinkmacsystemfont, 'Segoe UI', roboto, oxygen, ubuntu, cantarell, 'Open Sans',
|
||||||
|
@ -28,6 +32,8 @@
|
||||||
[data-editor-dark-mode='true'] {
|
[data-editor-dark-mode='true'] {
|
||||||
--background-color: #121416;
|
--background-color: #121416;
|
||||||
--default-color: #fff;
|
--default-color: #fff;
|
||||||
|
--background-color-invert: #fff;
|
||||||
|
--default-color-invert: #121416;
|
||||||
--link-color: #fff;
|
--link-color: #fff;
|
||||||
--link-hover-color: #000;
|
--link-hover-color: #000;
|
||||||
--link-hover-background: #fff;
|
--link-hover-background: #fff;
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { UploadFile } from '@solid-primitives/upload'
|
||||||
import { isDev } from './config'
|
import { isDev } from './config'
|
||||||
|
|
||||||
const api = isDev ? 'https://new.discours.io/api/upload' : '/api/upload'
|
const api = isDev ? 'https://new.discours.io/api/upload' : '/api/upload'
|
||||||
|
|
||||||
export const handleFileUpload = async (uploadFile: UploadFile) => {
|
export const handleFileUpload = async (uploadFile: UploadFile) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', uploadFile.file, uploadFile.name)
|
formData.append('file', uploadFile.file, uploadFile.name)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export const isValidEmail = (email: string) => {
|
export const validateEmail = (email: string) => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return false
|
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