Feature/video upload (#107)

* Add Video Player and Video Uploader
* Remove old video component
This commit is contained in:
Ilya Y 2023-06-10 17:10:05 +03:00 committed by GitHub
parent 5b824d8e2f
commit be53a5dce8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 450 additions and 90 deletions

View File

@ -132,6 +132,7 @@
"Just start typing...": "Just start typing...", "Just start typing...": "Just start typing...",
"Knowledge base": "Knowledge base", "Knowledge base": "Knowledge base",
"Last rev.": "Посл. изм.", "Last rev.": "Посл. изм.",
"Let's log in": "Let's log in",
"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",
@ -231,6 +232,7 @@
"Thank you": "Thank you", "Thank you": "Thank you",
"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 post has not been rated yet": "This post has not been rated yet", "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 leave a comment please": "To leave a comment please",
"To write a comment, you must": "To write a comment, you must", "To write a comment, you must": "To write a comment, you must",
@ -250,9 +252,11 @@
"Unfollow the topic": "Unfollow the topic", "Unfollow the topic": "Unfollow the topic",
"Unnamed draft": "Unnamed draft", "Unnamed draft": "Unnamed draft",
"Upload": "Upload", "Upload": "Upload",
"Upload video": "Upload video",
"Username": "Username", "Username": "Username",
"Userpic": "Userpic", "Userpic": "Userpic",
"Video": "Video", "Video": "Video",
"Video format not supported": "Video format not supported",
"Views": "Views", "Views": "Views",
"We are convinced that one voice is good, but many is better": "We are convinced that one voice is good, but many is better", "We are convinced that one voice is good, but many is better": "We are convinced that one voice is good, but many is better",
"We can't find you, check email or": "We can't find you, check email or", "We can't find you, check email or": "We can't find you, check email or",
@ -321,5 +325,7 @@
"user already exist": "user already exists", "user already exist": "user already exists",
"video": "video", "video": "video",
"view": "view", "view": "view",
"zine": "zine" "zine": "zine",
"Insert video link": "Insert video link",
"Looks like you forgot to upload the video": "Looks like you forgot to upload the video"
} }

View File

@ -139,6 +139,7 @@
"Karma": "Карма", "Karma": "Карма",
"Knowledge base": "База знаний", "Knowledge base": "База знаний",
"Last rev.": "Посл. изм.", "Last rev.": "Посл. изм.",
"Let's log in": "Давайте авторизуемся",
"Link sent, check your email": "Ссылка отправлена, проверьте почту", "Link sent, check your email": "Ссылка отправлена, проверьте почту",
"Lists": "Списки", "Lists": "Списки",
"Literature": "Литература", "Literature": "Литература",
@ -244,6 +245,7 @@
"Thank you": "Благодарности", "Thank you": "Благодарности",
"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 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": "Чтобы написать комментарий, необходимо",
@ -263,9 +265,11 @@
"Unfollow the topic": "Отписаться от темы", "Unfollow the topic": "Отписаться от темы",
"Unnamed draft": "Unnamed draft", "Unnamed draft": "Unnamed draft",
"Upload": "Загрузить", "Upload": "Загрузить",
"Upload video": "Загрузить видео",
"Username": "Имя пользователя", "Username": "Имя пользователя",
"Userpic": "Аватар", "Userpic": "Аватар",
"Video": "Видео", "Video": "Видео",
"Video format not supported": "Тип видео не поддерживается",
"Views": "Просмотры", "Views": "Просмотры",
"We are convinced that one voice is good, but many is better": "Мы убеждены, один голос хорошо, а много — лучше", "We are convinced that one voice is good, but many is better": "Мы убеждены, один голос хорошо, а много — лучше",
"We can't find you, check email or": "Не можем вас найти, проверьте адрес электронной почты или", "We can't find you, check email or": "Не можем вас найти, проверьте адрес электронной почты или",
@ -343,5 +347,7 @@
"user already exist": "пользователь уже существует", "user already exist": "пользователь уже существует",
"video": "видео", "video": "видео",
"view": "просмотр", "view": "просмотр",
"zine": "журнал" "zine": "журнал",
"Insert video link": "Вставить ссылку на видео",
"Looks like you forgot to upload the video": "Похоже, что вы забыли загрузить видео"
} }

View File

@ -10,7 +10,7 @@ import { ShoutRatingControl } from './ShoutRatingControl'
import { clsx } from 'clsx' 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 './VideoPlayer' import { VideoPlayer } from '../_shared/VideoPlayer'
import Slider from '../_shared/Slider' 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'
@ -29,7 +29,6 @@ interface ArticleProps {
interface MediaItem { interface MediaItem {
url?: string url?: string
pic?: string
title?: string title?: string
body?: string body?: string
} }
@ -40,17 +39,12 @@ const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => {
return ( return (
<> <>
<Switch fallback={<a href={props.media.url}>{t('Cannot show this media type')}</a>}> <Switch fallback={<a href={props.media.url}>{t('Cannot show this media type')}</a>}>
<Match when={props.kind === 'audio'}>
<div>
<h5>{props.media.title}</h5>
<audio controls>
<source src={props.media.url} />
</audio>
<hr />
</div>
</Match>
<Match when={props.kind === 'video'}> <Match when={props.kind === 'video'}>
<VideoPlayer url={props.media.url} /> <VideoPlayer
videoUrl={props.media.url}
title={props.media.title}
description={props.media.body}
/>
</Match> </Match>
</Switch> </Switch>
</> </>
@ -169,15 +163,12 @@ export const FullArticle = (props: ArticleProps) => {
</Show> </Show>
</div> </div>
<Show when={media() && props.article.layout !== 'image'}> <Show when={media()}>
<div class="media-items"> <div class="media-items">
<For each={media() || []}> <For each={media() || []}>
{(m: MediaItem) => ( {(m: MediaItem) => (
<div class={styles.shoutMediaBody}> <div class={styles.shoutMediaBody}>
<MediaView media={m} kind={props.article.layout} /> <MediaView media={m} kind={props.article.layout} />
<Show when={m?.body}>
<div innerHTML={m.body} />
</Show>
</div> </div>
)} )}
</For> </For>

View File

@ -1,12 +0,0 @@
.videoContainer {
aspect-ratio: 16/9;
@include media-breakpoint-up(md) {
margin: 0 0 1em -16.6666%;
}
iframe {
height: 100%;
width: 100%;
}
}

View File

@ -1,25 +0,0 @@
import { Show } from 'solid-js'
import styles from './VideoPlayer.module.scss'
export default (props: { url: string }) => (
<div class={styles.videoContainer}>
<Show when={props.url.includes('youtube.com')}>
<iframe
id="ytplayer"
width="640"
height="360"
src={`https://www.youtube.com/embed/${props.url.split('watch=').pop()}`}
allowfullscreen
/>
</Show>
<Show when={props.url.includes('vimeo.com')}>
<iframe
src={'https://player.vimeo.com/video/' + props.url.split('video/').pop()}
width="420"
height="345"
allow="autoplay; fullscreen"
allowfullscreen
/>
</Show>
</div>
)

View File

@ -19,7 +19,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => { onClick={() => {
props.editor.chain().focus().setBlockQuoteFloat('left').run() props.editor.chain().focus().setBlockQuoteFloat('left').run()
}} }}
@ -33,7 +33,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setBlockQuoteFloat(null).run()} onClick={() => props.editor.chain().focus().setBlockQuoteFloat(null).run()}
> >
<Icon name="editor-image-align-center" /> <Icon name="editor-image-align-center" />
@ -45,7 +45,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setBlockQuoteFloat('right').run()} onClick={() => props.editor.chain().focus().setBlockQuoteFloat('right').run()}
> >
<Icon name="editor-image-align-right" /> <Icon name="editor-image-align-right" />

View File

@ -19,7 +19,7 @@ export const FigureBubbleMenu = (props: Props) => {
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setImageFloat('left').run()} onClick={() => props.editor.chain().focus().setImageFloat('left').run()}
> >
<Icon name="editor-image-align-left" /> <Icon name="editor-image-align-left" />
@ -31,7 +31,7 @@ export const FigureBubbleMenu = (props: Props) => {
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setImageFloat(null).run()} onClick={() => props.editor.chain().focus().setImageFloat(null).run()}
> >
<Icon name="editor-image-align-center" /> <Icon name="editor-image-align-center" />
@ -43,7 +43,7 @@ export const FigureBubbleMenu = (props: Props) => {
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setImageFloat('right').run()} onClick={() => props.editor.chain().focus().setImageFloat('right').run()}
> >
<Icon name="editor-image-align-right" /> <Icon name="editor-image-align-right" />
@ -53,7 +53,7 @@ export const FigureBubbleMenu = (props: Props) => {
<div class={styles.delimiter} /> <div class={styles.delimiter} />
<button <button
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => { onClick={() => {
props.editor.chain().focus().imageToFigure().run() props.editor.chain().focus().imageToFigure().run()
}} }}
@ -63,7 +63,7 @@ export const FigureBubbleMenu = (props: Props) => {
<div class={styles.delimiter} /> <div class={styles.delimiter} />
<Popover content={t('Add image')}> <Popover content={t('Add image')}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el) => void) => (
<button type="button" ref={triggerRef} class={clsx(styles.bubbleMenuButton)}> <button type="button" ref={triggerRef} class={styles.bubbleMenuButton}>
<Icon name="editor-image-add" /> <Icon name="editor-image-add" />
</button> </button>
)} )}

View File

@ -19,21 +19,21 @@ export const IncutBubbleMenu = (props: Props) => {
<div ref={props.ref} class={styles.BubbleMenu}> <div ref={props.ref} class={styles.BubbleMenu}>
<button <button
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setArticleFloat('left').run()} onClick={() => props.editor.chain().focus().setArticleFloat('left').run()}
> >
<Icon name="editor-image-align-left" /> <Icon name="editor-image-align-left" />
</button> </button>
<button <button
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setArticleFloat('half-left').run()} onClick={() => props.editor.chain().focus().setArticleFloat('half-left').run()}
> >
<Icon name="editor-image-half-align-left" /> <Icon name="editor-image-half-align-left" />
</button> </button>
<button <button
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setArticleFloat(null).run()} onClick={() => props.editor.chain().focus().setArticleFloat(null).run()}
> >
<Icon name="editor-image-align-center" /> <Icon name="editor-image-align-center" />
@ -41,7 +41,7 @@ export const IncutBubbleMenu = (props: Props) => {
<button <button
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setArticleFloat('half-right').run()} onClick={() => props.editor.chain().focus().setArticleFloat('half-right').run()}
> >
<Icon name="editor-image-half-align-right" /> <Icon name="editor-image-half-align-right" />
@ -49,7 +49,7 @@ export const IncutBubbleMenu = (props: Props) => {
<button <button
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => props.editor.chain().focus().setArticleFloat('right').run()} onClick={() => props.editor.chain().focus().setArticleFloat('right').run()}
> >
<Icon name="editor-image-align-right" /> <Icon name="editor-image-align-right" />
@ -59,7 +59,7 @@ export const IncutBubbleMenu = (props: Props) => {
<div class={styles.dropDownHolder}> <div class={styles.dropDownHolder}>
<button <button
type="button" type="button"
class={clsx(styles.bubbleMenuButton)} class={styles.bubbleMenuButton}
onClick={() => setSubstratBubbleOpen(!substratBubbleOpen())} onClick={() => setSubstratBubbleOpen(!substratBubbleOpen())}
> >
<span style={{ color: 'white' }}>{t('Substrate')}</span> <span style={{ color: 'white' }}>{t('Substrate')}</span>

View File

@ -58,7 +58,6 @@ export const Editor = (props: EditorProps) => {
const { t } = useLocalize() const { t } = useLocalize()
const { user } = useSession() const { user } = useSession()
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false) const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
const [floatMenuRef, setFloatMenuRef] = createSignal<'blockquote' | 'image' | 'incut'>()
const docName = `shout-${props.shoutId}` const docName = `shout-${props.shoutId}`

View File

@ -0,0 +1,89 @@
.VideoUploader {
margin: 2rem 0;
.dropArea {
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;
&.active {
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%
);
}
}
}
.error {
color: var(--danger-color);
text-align: center;
padding: 1rem;
}
.inputHolder {
display: flex;
align-items: center;
justify-content: center;
margin: 1rem 0;
.urlInput {
display: block;
width: unset;
margin: auto;
padding: 1rem 0;
border: none;
background: unset;
font-size: 18px;
min-width: 20em;
text-align: center;
border-bottom: 1px solid transparent;
transition: all 0.35s ease-in-out;
border-radius: 0;
&::placeholder {
color: #2638d9;
font-weight: 500;
}
&:focus,
&:active,
&.hasError {
outline: none;
width: 100%;
text-align: left;
border-bottom-color: var(--default-color);
}
}
}
}
@keyframes slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}

View File

@ -0,0 +1,133 @@
import { clsx } from 'clsx'
import styles from './VideoUploader.module.scss'
import { useLocalize } from '../../../context/localize'
import { createDropzone } from '@solid-primitives/upload'
import { createEffect, createSignal, Show } from 'solid-js'
import { useSnackbar } from '../../../context/snackbar'
import { validateUrl } from '../../../utils/validateUrl'
import { VideoPlayer } from '../../_shared/VideoPlayer'
// import { handleFileUpload } from '../../../utils/handleFileUpload'
type VideoItem = {
url: string
title: string
body: string
}
type Props = {
class?: string
data: (value: VideoItem) => void
}
export const VideoUploader = (props: Props) => {
const { t } = useLocalize()
const [dragActive, setDragActive] = createSignal(false)
const [dragError, setDragError] = createSignal<string | undefined>()
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
const [data, setData] = createSignal<VideoItem>()
const updateData = (key, value) => {
setData((prev) => ({ ...prev, [key]: value }))
}
createEffect(() => {
props.data(data())
})
const {
actions: { showSnackbar }
} = useSnackbar()
const urlInput: {
current: HTMLInputElement
} = {
current: null
}
// const [videoUrl, setVideoUrl] = createSignal<string | undefined>()
// const runUpload = async (file) => {
// try {
// const fileUrl = await handleFileUpload(file)
// setVideoUrl(fileUrl)
// } catch (error) {
// console.error('[runUpload]', error)
// }
// }
const { setRef: dropzoneRef, files: droppedFiles } = createDropzone({
onDrop: async () => {
setDragActive(false)
if (droppedFiles().length > 1) {
setDragError(t('Many files, choose only one'))
} else if (droppedFiles()[0].file.type.startsWith('video/')) {
await showSnackbar({
body: t(
'This functionality is currently not available, we would like to work on this issue. Use the download link.'
)
})
// await runUpload(droppedFiles()[0])
} else {
setDragError(t('Video format not supported'))
}
}
})
const handleDrag = (event) => {
if (event.type === 'dragenter' || event.type === 'dragover') {
setDragActive(true)
setDragError()
} else if (event.type === 'dragleave') {
setDragActive(false)
}
}
const handleUrlInput = async (value: string) => {
if (validateUrl(value)) {
updateData('url', value)
} else {
setIncorrectUrl(true)
}
}
return (
<div class={clsx(styles.VideoUploader, props.class)}>
<Show
when={data() && data().url}
fallback={
<>
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
ref={dropzoneRef}
class={clsx(styles.dropArea, { [styles.active]: dragActive() })}
>
{t('Upload video')}
</div>
<Show when={dragError()}>
<div class={styles.error}>{dragError()}</div>
</Show>
<div class={styles.inputHolder}>
<input
class={clsx(styles.urlInput, { [styles.hasError]: incorrectUrl() })}
ref={(el) => (urlInput.current = el)}
type="text"
placeholder={t('Insert video link')}
onChange={(event) => handleUrlInput(event.currentTarget.value)}
/>
</div>
<Show when={incorrectUrl()}>
<div class={styles.error}>{t('It does not look like url')}</div>
</Show>
</>
}
>
<VideoPlayer
deleteAction={() => setData()}
videoUrl={data().url}
title={data().title}
description={data().body}
/>
</Show>
</div>
)
}

View File

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

View File

@ -218,6 +218,7 @@
.validationError { .validationError {
position: absolute; position: absolute;
z-index: 1;
top: 100%; top: 100%;
font-size: small; font-size: small;
color: #f00; color: #f00;

View File

@ -1,4 +1,4 @@
import { createSignal, 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'
@ -15,8 +15,11 @@ import { Modal } from '../Nav/Modal'
import { hideModal, showModal } from '../../stores/ui' import { hideModal, showModal } from '../../stores/ui'
import { imageProxy } from '../../utils/imageProxy' import { imageProxy } from '../../utils/imageProxy'
import { GrowingTextarea } from '../_shared/GrowingTextarea' import { GrowingTextarea } from '../_shared/GrowingTextarea'
import { VideoUploader } from '../Editor/VideoUploader'
import { VideoPlayer } from '../_shared/VideoPlayer'
import { slugify } from '../../utils/slugify'
type EditViewProps = { type Props = {
shout: Shout shout: Shout
} }
@ -33,12 +36,13 @@ const EMPTY_TOPIC: Topic = {
slug: '' slug: ''
} }
export const EditView = (props: EditViewProps) => { export const EditView = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { user } = useSession() const { user } = useSession()
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,
@ -56,7 +60,9 @@ export const EditView = (props: EditViewProps) => {
selectedTopics: shoutTopics, selectedTopics: shoutTopics,
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(),
layout: props.shout.layout
}) })
onMount(async () => { onMount(async () => {
@ -77,7 +83,7 @@ export const EditView = (props: EditViewProps) => {
const handleTitleInputChange = (value) => { const handleTitleInputChange = (value) => {
setForm('title', value) setForm('title', value)
setForm('slug', slugify(value))
if (value) { if (value) {
setFormErrors('title', '') setFormErrors('title', '')
} }
@ -111,10 +117,13 @@ export const EditView = (props: EditViewProps) => {
if (newSelectedTopics.length > 0) { if (newSelectedTopics.length > 0) {
setFormErrors('selectedTopics', '') setFormErrors('selectedTopics', '')
} }
setForm('selectedTopics', newSelectedTopics) setForm('selectedTopics', newSelectedTopics)
} }
const handleAddMedia = (data) => {
setForm('media', JSON.stringify([data]))
}
return ( return (
<> <>
<div class={styles.container}> <div class={styles.container}>
@ -157,6 +166,33 @@ export const EditView = (props: EditViewProps) => {
initialValue={form.subtitle} initialValue={form.subtitle}
maxLength={100} maxLength={100}
/> />
<Show when={props.shout.layout === 'video'}>
<Show
when={media()}
fallback={
<VideoUploader
data={(data) => {
handleAddMedia(data)
}}
/>
}
>
<For each={JSON.parse(media())}>
{(mediaItem) => (
<>
<VideoPlayer
videoUrl={mediaItem?.url}
title={mediaItem?.title}
description={mediaItem?.body}
deleteAction={() => setMedia(null)}
/>
</>
)}
</For>
</Show>
</Show>
<Editor <Editor
shoutId={props.shout.id} shoutId={props.shout.id}
initialContent={props.shout.body} initialContent={props.shout.body}

View File

@ -0,0 +1,39 @@
.VideoPlayer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 1rem 0;
position: relative;
.controls {
display: flex;
flex-direction: row;
margin-top: auto;
}
.deleteAction {
position: absolute;
top: -15px;
right: -15px;
padding: 0;
min-width: 30px;
}
.deleteIcon {
filter: invert(1);
}
.videoContainer {
width: 100%;
aspect-ratio: 16/9;
@include media-breakpoint-up(md) {
margin: 0 0 1em -16.6666%;
}
iframe {
height: 100%;
width: 100%;
}
}
}

View File

@ -0,0 +1,79 @@
import { createEffect, createSignal, Match, Switch, Show } from 'solid-js'
import { Button } from '../Button'
import { Icon } from '../Icon'
import { Popover } from '../Popover'
import { clsx } from 'clsx'
import styles from './VideoPlayer.module.scss'
import { useLocalize } from '../../../context/localize'
type Props = {
videoUrl: string
title?: string
description?: string
class?: string
deleteAction?: () => void
}
export const VideoPlayer = (props: Props) => {
const { t } = useLocalize()
const [videoId, setVideoId] = createSignal<string | undefined>()
const [isVimeo, setIsVimeo] = createSignal(false)
createEffect(() => {
const isYoutube = props.videoUrl.includes('youtube.com') || props.videoUrl.includes('youtu.be')
setIsVimeo(!isYoutube)
if (isYoutube) {
if (props.videoUrl.includes('youtube.com')) {
const videoIdMatch = props.videoUrl.match(/v=(\w+)/)
setVideoId(videoIdMatch && videoIdMatch[1])
} else {
const videoIdMatch = props.videoUrl.match(/youtu.be\/(\w+)/)
setVideoId(videoIdMatch && videoIdMatch[1])
}
} else {
const videoIdMatch = props.videoUrl.match(/vimeo.com\/(\d+)/)
setVideoId(videoIdMatch && videoIdMatch[1])
}
})
return (
<div class={clsx(styles.VideoPlayer, props.class)}>
<Show when={props.deleteAction}>
<Popover content={t('Delete')}>
{(triggerRef: (el) => void) => (
<Button
ref={triggerRef}
size="S"
class={styles.deleteAction}
onClick={props.deleteAction}
value={<Icon class={styles.deleteIcon} name="delete" />}
/>
)}
</Popover>
</Show>
<Switch>
<Match when={isVimeo()}>
<div class={styles.videoContainer}>
<iframe
src={`https://player.vimeo.com/video/${videoId()}`}
width="640"
height="360"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
/>
</div>
</Match>
<Match when={!isVimeo()}>
<div class={styles.videoContainer}>
<iframe
width="560"
height="315"
src={`https://www.youtube.com/embed/${videoId()}`}
allowfullscreen
/>
</div>
</Match>
</Switch>
</div>
)
}

View File

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

View File

@ -16,6 +16,7 @@ type WordCounter = {
} }
type ShoutForm = { type ShoutForm = {
layout?: string
shoutId: number shoutId: number
slug: string slug: string
title: string title: string
@ -24,6 +25,7 @@ type ShoutForm = {
mainTopic?: Topic mainTopic?: Topic
body: string body: string
coverImageUrl: string coverImageUrl: string
media?: string
} }
type EditorContextType = { type EditorContextType = {
@ -89,6 +91,12 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
return false return false
} }
const parsedMedia = JSON.parse(form.media)
if (form.layout === 'video' && !parsedMedia[0]) {
showSnackbar({ type: 'error', body: t('Looks like you forgot to upload the video') })
return false
}
return true return true
} }
@ -113,7 +121,8 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
slug: form.slug, slug: form.slug,
subtitle: form.subtitle, subtitle: form.subtitle,
title: form.title, title: form.title,
cover: form.coverImageUrl cover: form.coverImageUrl,
media: form.media
}, },
publish publish
}) })

View File

@ -11,6 +11,7 @@ export default gql`
cover cover
# community # community
mainTopic mainTopic
media
topics { topics {
id id
title title

View File

@ -582,7 +582,9 @@ export type ShoutInput = {
body?: InputMaybe<Scalars['String']> body?: InputMaybe<Scalars['String']>
community?: InputMaybe<Scalars['Int']> community?: InputMaybe<Scalars['Int']>
cover?: InputMaybe<Scalars['String']> cover?: InputMaybe<Scalars['String']>
layout?: InputMaybe<Scalars['String']>
mainTopic?: InputMaybe<TopicInput> mainTopic?: InputMaybe<TopicInput>
media?: InputMaybe<Scalars['String']>
slug?: InputMaybe<Scalars['String']> slug?: InputMaybe<Scalars['String']>
subtitle?: InputMaybe<Scalars['String']> subtitle?: InputMaybe<Scalars['String']>
title?: InputMaybe<Scalars['String']> title?: InputMaybe<Scalars['String']>

View File

@ -8,9 +8,11 @@ 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'
const handleCreateArticle = async () => { const handleCreate = async (layout: 'article' | 'video') => {
const shout = await apiClient.createArticle({ article: {} }) const shout = await apiClient.createArticle({ article: { layout: layout } })
redirectPage(router, 'edit', { shoutId: shout.id.toString() }) redirectPage(router, 'edit', {
shoutId: shout.id.toString()
})
} }
export const CreatePage = () => { export const CreatePage = () => {
@ -21,7 +23,7 @@ export const CreatePage = () => {
<h1>{t('Choose a post type')}</h1> <h1>{t('Choose a post type')}</h1>
<ul class={clsx('nodash', styles.list)}> <ul class={clsx('nodash', styles.list)}>
<li> <li>
<div class={styles.link} onClick={handleCreateArticle}> <div class={styles.link} onClick={() => handleCreate('article')}>
<Icon name="create-article" class={styles.icon} /> <Icon name="create-article" class={styles.icon} />
<div>{t('article')}</div> <div>{t('article')}</div>
</div> </div>
@ -45,10 +47,10 @@ export const CreatePage = () => {
</a> </a>
</li> </li>
<li> <li>
<a href="#"> <div class={styles.link} onClick={() => handleCreate('video')}>
<Icon name="create-video" class={styles.icon} /> <Icon name="create-video" class={styles.icon} />
<div>{t('music')}</div> <div>{t('video')}</div>
</a> </div>
</li> </li>
</ul> </ul>
<Button value={t('Back')} onClick={() => window.history.back()} /> <Button value={t('Back')} onClick={() => window.history.back()} />

View File

@ -1,14 +1,16 @@
import { createMemo, createSignal, lazy, onMount, Show, Suspense } from 'solid-js' import { createEffect, createMemo, createSignal, lazy, onMount, Show, Suspense } from 'solid-js'
import { PageLayout } from '../components/_shared/PageLayout' import { PageLayout } from '../components/_shared/PageLayout'
import { Loading } from '../components/_shared/Loading' import { Loading } from '../components/_shared/Loading'
import { useSession } from '../context/session' import { useSession } from '../context/session'
import { Shout } from '../graphql/types.gen' import { Shout } from '../graphql/types.gen'
import { useRouter } from '../stores/router' import { useRouter } from '../stores/router'
import { apiClient } from '../utils/apiClient' import { apiClient } from '../utils/apiClient'
import { useLocalize } from '../context/localize'
const EditView = lazy(() => import('../components/Views/Edit')) const Edit = lazy(() => import('../components/Views/Edit'))
export const EditPage = () => { export const EditPage = () => {
const { t } = useLocalize()
const { isAuthenticated, isSessionLoaded } = useSession() const { isAuthenticated, isSessionLoaded } = useSession()
const { page } = useRouter() const { page } = useRouter()
@ -30,14 +32,14 @@ export const EditPage = () => {
fallback={ fallback={
<div class="wide-container"> <div class="wide-container">
<div class="row"> <div class="row">
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">Давайте авторизуемся</div> <div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">{t("Let's log in")}</div>
</div> </div>
</div> </div>
} }
> >
<Show when={shout()}> <Show when={shout()}>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<EditView shout={shout()} /> <Edit shout={shout()} />
</Suspense> </Suspense>
</Show> </Show>
</Show> </Show>

View File

@ -36,7 +36,7 @@
} }
.icon { .icon {
margin: auto 21px auto; margin: auto auto 21px;
img { img {
height: 54px; height: 54px;

View File

@ -245,7 +245,7 @@ export const apiClient = {
}, },
createArticle: async ({ article }: { article: ShoutInput }): Promise<Shout> => { createArticle: async ({ article }: { article: ShoutInput }): Promise<Shout> => {
const response = await privateGraphQLClient.mutation(createArticle, { shout: article }).toPromise() const response = await privateGraphQLClient.mutation(createArticle, { shout: article }).toPromise()
console.debug('[createArticle]:', response.data) console.log('!!! [createArticle]:', response.data)
return response.data.createShout.shout return response.data.createShout.shout
}, },
updateArticle: async ({ updateArticle: async ({