Feature/video upload (#107)
* Add Video Player and Video Uploader * Remove old video component
This commit is contained in:
parent
5b824d8e2f
commit
be53a5dce8
|
@ -132,6 +132,7 @@
|
|||
"Just start typing...": "Just start typing...",
|
||||
"Knowledge base": "Knowledge base",
|
||||
"Last rev.": "Посл. изм.",
|
||||
"Let's log in": "Let's log in",
|
||||
"Link sent, check your email": "Link sent, check your email",
|
||||
"Lists": "Lists",
|
||||
"Literature": "Literature",
|
||||
|
@ -231,6 +232,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": "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",
|
||||
"To leave a comment please": "To leave a comment please",
|
||||
"To write a comment, you must": "To write a comment, you must",
|
||||
|
@ -250,9 +252,11 @@
|
|||
"Unfollow the topic": "Unfollow the topic",
|
||||
"Unnamed draft": "Unnamed draft",
|
||||
"Upload": "Upload",
|
||||
"Upload video": "Upload video",
|
||||
"Username": "Username",
|
||||
"Userpic": "Userpic",
|
||||
"Video": "Video",
|
||||
"Video format not supported": "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",
|
||||
|
@ -321,5 +325,7 @@
|
|||
"user already exist": "user already exists",
|
||||
"video": "video",
|
||||
"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"
|
||||
}
|
||||
|
|
|
@ -139,6 +139,7 @@
|
|||
"Karma": "Карма",
|
||||
"Knowledge base": "База знаний",
|
||||
"Last rev.": "Посл. изм.",
|
||||
"Let's log in": "Давайте авторизуемся",
|
||||
"Link sent, check your email": "Ссылка отправлена, проверьте почту",
|
||||
"Lists": "Списки",
|
||||
"Literature": "Литература",
|
||||
|
@ -244,6 +245,7 @@
|
|||
"Thank you": "Благодарности",
|
||||
"This comment has not yet been rated": "Этот комментарий еще пока никто не оценил",
|
||||
"This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы",
|
||||
"This functionality is currently not available, we would like to work on this issue. Use the download link.": "В данный момент этот функционал не доступен, бы работаем над этой проблемой. Воспользуйтесь загрузкой по ссылке.",
|
||||
"This post has not been rated yet": "Эту публикацию еще пока никто не оценил",
|
||||
"To leave a comment please": "Чтобы оставить комментарий, необходимо",
|
||||
"To write a comment, you must": "Чтобы написать комментарий, необходимо",
|
||||
|
@ -263,9 +265,11 @@
|
|||
"Unfollow the topic": "Отписаться от темы",
|
||||
"Unnamed draft": "Unnamed draft",
|
||||
"Upload": "Загрузить",
|
||||
"Upload video": "Загрузить видео",
|
||||
"Username": "Имя пользователя",
|
||||
"Userpic": "Аватар",
|
||||
"Video": "Видео",
|
||||
"Video format not supported": "Тип видео не поддерживается",
|
||||
"Views": "Просмотры",
|
||||
"We are convinced that one voice is good, but many is better": "Мы убеждены, один голос хорошо, а много — лучше",
|
||||
"We can't find you, check email or": "Не можем вас найти, проверьте адрес электронной почты или",
|
||||
|
@ -343,5 +347,7 @@
|
|||
"user already exist": "пользователь уже существует",
|
||||
"video": "видео",
|
||||
"view": "просмотр",
|
||||
"zine": "журнал"
|
||||
"zine": "журнал",
|
||||
"Insert video link": "Вставить ссылку на видео",
|
||||
"Looks like you forgot to upload the video": "Похоже, что вы забыли загрузить видео"
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { ShoutRatingControl } from './ShoutRatingControl'
|
|||
import { clsx } from 'clsx'
|
||||
import { CommentsTree } from './CommentsTree'
|
||||
import { useSession } from '../../context/session'
|
||||
import VideoPlayer from './VideoPlayer'
|
||||
import { VideoPlayer } from '../_shared/VideoPlayer'
|
||||
import Slider from '../_shared/Slider'
|
||||
import { getPagePath } from '@nanostores/router'
|
||||
import { router, useRouter } from '../../stores/router'
|
||||
|
@ -29,7 +29,6 @@ interface ArticleProps {
|
|||
|
||||
interface MediaItem {
|
||||
url?: string
|
||||
pic?: string
|
||||
title?: string
|
||||
body?: string
|
||||
}
|
||||
|
@ -40,17 +39,12 @@ const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => {
|
|||
return (
|
||||
<>
|
||||
<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'}>
|
||||
<VideoPlayer url={props.media.url} />
|
||||
<VideoPlayer
|
||||
videoUrl={props.media.url}
|
||||
title={props.media.title}
|
||||
description={props.media.body}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
|
@ -169,15 +163,12 @@ export const FullArticle = (props: ArticleProps) => {
|
|||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={media() && props.article.layout !== 'image'}>
|
||||
<Show when={media()}>
|
||||
<div class="media-items">
|
||||
<For each={media() || []}>
|
||||
{(m: MediaItem) => (
|
||||
<div class={styles.shoutMediaBody}>
|
||||
<MediaView media={m} kind={props.article.layout} />
|
||||
<Show when={m?.body}>
|
||||
<div innerHTML={m.body} />
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
|
@ -19,7 +19,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
|
|||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => {
|
||||
props.editor.chain().focus().setBlockQuoteFloat('left').run()
|
||||
}}
|
||||
|
@ -33,7 +33,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
|
|||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => props.editor.chain().focus().setBlockQuoteFloat(null).run()}
|
||||
>
|
||||
<Icon name="editor-image-align-center" />
|
||||
|
@ -45,7 +45,7 @@ export const BlockquoteBubbleMenu = (props: Props) => {
|
|||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => props.editor.chain().focus().setBlockQuoteFloat('right').run()}
|
||||
>
|
||||
<Icon name="editor-image-align-right" />
|
||||
|
|
|
@ -19,7 +19,7 @@ export const FigureBubbleMenu = (props: Props) => {
|
|||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => props.editor.chain().focus().setImageFloat('left').run()}
|
||||
>
|
||||
<Icon name="editor-image-align-left" />
|
||||
|
@ -31,7 +31,7 @@ export const FigureBubbleMenu = (props: Props) => {
|
|||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => props.editor.chain().focus().setImageFloat(null).run()}
|
||||
>
|
||||
<Icon name="editor-image-align-center" />
|
||||
|
@ -43,7 +43,7 @@ export const FigureBubbleMenu = (props: Props) => {
|
|||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => props.editor.chain().focus().setImageFloat('right').run()}
|
||||
>
|
||||
<Icon name="editor-image-align-right" />
|
||||
|
@ -53,7 +53,7 @@ export const FigureBubbleMenu = (props: Props) => {
|
|||
<div class={styles.delimiter} />
|
||||
<button
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => {
|
||||
props.editor.chain().focus().imageToFigure().run()
|
||||
}}
|
||||
|
@ -63,7 +63,7 @@ export const FigureBubbleMenu = (props: Props) => {
|
|||
<div class={styles.delimiter} />
|
||||
<Popover content={t('Add image')}>
|
||||
{(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" />
|
||||
</button>
|
||||
)}
|
||||
|
|
|
@ -19,21 +19,21 @@ export const IncutBubbleMenu = (props: Props) => {
|
|||
<div ref={props.ref} class={styles.BubbleMenu}>
|
||||
<button
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => props.editor.chain().focus().setArticleFloat('left').run()}
|
||||
>
|
||||
<Icon name="editor-image-align-left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => props.editor.chain().focus().setArticleFloat('half-left').run()}
|
||||
>
|
||||
<Icon name="editor-image-half-align-left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => props.editor.chain().focus().setArticleFloat(null).run()}
|
||||
>
|
||||
<Icon name="editor-image-align-center" />
|
||||
|
@ -41,7 +41,7 @@ export const IncutBubbleMenu = (props: Props) => {
|
|||
|
||||
<button
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => props.editor.chain().focus().setArticleFloat('half-right').run()}
|
||||
>
|
||||
<Icon name="editor-image-half-align-right" />
|
||||
|
@ -49,7 +49,7 @@ export const IncutBubbleMenu = (props: Props) => {
|
|||
|
||||
<button
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => props.editor.chain().focus().setArticleFloat('right').run()}
|
||||
>
|
||||
<Icon name="editor-image-align-right" />
|
||||
|
@ -59,7 +59,7 @@ export const IncutBubbleMenu = (props: Props) => {
|
|||
<div class={styles.dropDownHolder}>
|
||||
<button
|
||||
type="button"
|
||||
class={clsx(styles.bubbleMenuButton)}
|
||||
class={styles.bubbleMenuButton}
|
||||
onClick={() => setSubstratBubbleOpen(!substratBubbleOpen())}
|
||||
>
|
||||
<span style={{ color: 'white' }}>{t('Substrate')}</span>
|
||||
|
|
|
@ -58,7 +58,6 @@ export const Editor = (props: EditorProps) => {
|
|||
const { t } = useLocalize()
|
||||
const { user } = useSession()
|
||||
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
|
||||
const [floatMenuRef, setFloatMenuRef] = createSignal<'blockquote' | 'image' | 'incut'>()
|
||||
|
||||
const docName = `shout-${props.shoutId}`
|
||||
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
}
|
133
src/components/Editor/VideoUploader/VideoUploader.tsx
Normal file
133
src/components/Editor/VideoUploader/VideoUploader.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
src/components/Editor/VideoUploader/index.ts
Normal file
1
src/components/Editor/VideoUploader/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { VideoUploader } from './VideoUploader'
|
|
@ -218,6 +218,7 @@
|
|||
|
||||
.validationError {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 100%;
|
||||
font-size: small;
|
||||
color: #f00;
|
||||
|
|
|
@ -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 { clsx } from 'clsx'
|
||||
import { Title } from '@solidjs/meta'
|
||||
|
@ -15,8 +15,11 @@ import { Modal } from '../Nav/Modal'
|
|||
import { hideModal, showModal } from '../../stores/ui'
|
||||
import { imageProxy } from '../../utils/imageProxy'
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -33,12 +36,13 @@ const EMPTY_TOPIC: Topic = {
|
|||
slug: ''
|
||||
}
|
||||
|
||||
export const EditView = (props: EditViewProps) => {
|
||||
export const EditView = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const { user } = useSession()
|
||||
const [isScrolled, setIsScrolled] = createSignal(false)
|
||||
const [topics, setTopics] = createSignal<Topic[]>(null)
|
||||
const [coverImage, setCoverImage] = createSignal<string>(null)
|
||||
const [media, setMedia] = createSignal<string>(props.shout.media)
|
||||
const { page } = useRouter()
|
||||
const {
|
||||
form,
|
||||
|
@ -56,7 +60,9 @@ export const EditView = (props: EditViewProps) => {
|
|||
selectedTopics: shoutTopics,
|
||||
mainTopic: shoutTopics.find((topic) => topic.slug === props.shout.mainTopic) || EMPTY_TOPIC,
|
||||
body: props.shout.body,
|
||||
coverImageUrl: props.shout.cover
|
||||
coverImageUrl: props.shout.cover,
|
||||
media: media(),
|
||||
layout: props.shout.layout
|
||||
})
|
||||
|
||||
onMount(async () => {
|
||||
|
@ -77,7 +83,7 @@ export const EditView = (props: EditViewProps) => {
|
|||
|
||||
const handleTitleInputChange = (value) => {
|
||||
setForm('title', value)
|
||||
|
||||
setForm('slug', slugify(value))
|
||||
if (value) {
|
||||
setFormErrors('title', '')
|
||||
}
|
||||
|
@ -111,10 +117,13 @@ export const EditView = (props: EditViewProps) => {
|
|||
if (newSelectedTopics.length > 0) {
|
||||
setFormErrors('selectedTopics', '')
|
||||
}
|
||||
|
||||
setForm('selectedTopics', newSelectedTopics)
|
||||
}
|
||||
|
||||
const handleAddMedia = (data) => {
|
||||
setForm('media', JSON.stringify([data]))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class={styles.container}>
|
||||
|
@ -157,6 +166,33 @@ export const EditView = (props: EditViewProps) => {
|
|||
initialValue={form.subtitle}
|
||||
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
|
||||
shoutId={props.shout.id}
|
||||
initialContent={props.shout.body}
|
||||
|
|
39
src/components/_shared/VideoPlayer/VideoPlayer.module.scss
Normal file
39
src/components/_shared/VideoPlayer/VideoPlayer.module.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
79
src/components/_shared/VideoPlayer/VideoPlayer.tsx
Normal file
79
src/components/_shared/VideoPlayer/VideoPlayer.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
src/components/_shared/VideoPlayer/index.ts
Normal file
1
src/components/_shared/VideoPlayer/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { VideoPlayer } from './VideoPlayer'
|
|
@ -16,6 +16,7 @@ type WordCounter = {
|
|||
}
|
||||
|
||||
type ShoutForm = {
|
||||
layout?: string
|
||||
shoutId: number
|
||||
slug: string
|
||||
title: string
|
||||
|
@ -24,6 +25,7 @@ type ShoutForm = {
|
|||
mainTopic?: Topic
|
||||
body: string
|
||||
coverImageUrl: string
|
||||
media?: string
|
||||
}
|
||||
|
||||
type EditorContextType = {
|
||||
|
@ -89,6 +91,12 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
|||
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
|
||||
}
|
||||
|
||||
|
@ -113,7 +121,8 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
|||
slug: form.slug,
|
||||
subtitle: form.subtitle,
|
||||
title: form.title,
|
||||
cover: form.coverImageUrl
|
||||
cover: form.coverImageUrl,
|
||||
media: form.media
|
||||
},
|
||||
publish
|
||||
})
|
||||
|
|
|
@ -11,6 +11,7 @@ export default gql`
|
|||
cover
|
||||
# community
|
||||
mainTopic
|
||||
media
|
||||
topics {
|
||||
id
|
||||
title
|
||||
|
|
|
@ -582,7 +582,9 @@ export type ShoutInput = {
|
|||
body?: InputMaybe<Scalars['String']>
|
||||
community?: InputMaybe<Scalars['Int']>
|
||||
cover?: InputMaybe<Scalars['String']>
|
||||
layout?: InputMaybe<Scalars['String']>
|
||||
mainTopic?: InputMaybe<TopicInput>
|
||||
media?: InputMaybe<Scalars['String']>
|
||||
slug?: InputMaybe<Scalars['String']>
|
||||
subtitle?: InputMaybe<Scalars['String']>
|
||||
title?: InputMaybe<Scalars['String']>
|
||||
|
|
|
@ -8,9 +8,11 @@ import { apiClient } from '../utils/apiClient'
|
|||
import { redirectPage } from '@nanostores/router'
|
||||
import { router } from '../stores/router'
|
||||
|
||||
const handleCreateArticle = async () => {
|
||||
const shout = await apiClient.createArticle({ article: {} })
|
||||
redirectPage(router, 'edit', { shoutId: shout.id.toString() })
|
||||
const handleCreate = async (layout: 'article' | 'video') => {
|
||||
const shout = await apiClient.createArticle({ article: { layout: layout } })
|
||||
redirectPage(router, 'edit', {
|
||||
shoutId: shout.id.toString()
|
||||
})
|
||||
}
|
||||
|
||||
export const CreatePage = () => {
|
||||
|
@ -21,7 +23,7 @@ export const CreatePage = () => {
|
|||
<h1>{t('Choose a post type')}</h1>
|
||||
<ul class={clsx('nodash', styles.list)}>
|
||||
<li>
|
||||
<div class={styles.link} onClick={handleCreateArticle}>
|
||||
<div class={styles.link} onClick={() => handleCreate('article')}>
|
||||
<Icon name="create-article" class={styles.icon} />
|
||||
<div>{t('article')}</div>
|
||||
</div>
|
||||
|
@ -45,10 +47,10 @@ export const CreatePage = () => {
|
|||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">
|
||||
<div class={styles.link} onClick={() => handleCreate('video')}>
|
||||
<Icon name="create-video" class={styles.icon} />
|
||||
<div>{t('music')}</div>
|
||||
</a>
|
||||
<div>{t('video')}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<Button value={t('Back')} onClick={() => window.history.back()} />
|
||||
|
|
|
@ -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 { Loading } from '../components/_shared/Loading'
|
||||
import { useSession } from '../context/session'
|
||||
import { Shout } from '../graphql/types.gen'
|
||||
import { useRouter } from '../stores/router'
|
||||
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 = () => {
|
||||
const { t } = useLocalize()
|
||||
const { isAuthenticated, isSessionLoaded } = useSession()
|
||||
|
||||
const { page } = useRouter()
|
||||
|
@ -30,14 +32,14 @@ export const EditPage = () => {
|
|||
fallback={
|
||||
<div class="wide-container">
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<Show when={shout()}>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<EditView shout={shout()} />
|
||||
<Edit shout={shout()} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Show>
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
}
|
||||
|
||||
.icon {
|
||||
margin: auto 21px auto;
|
||||
margin: auto auto 21px;
|
||||
|
||||
img {
|
||||
height: 54px;
|
||||
|
|
|
@ -245,7 +245,7 @@ export const apiClient = {
|
|||
},
|
||||
createArticle: async ({ article }: { article: ShoutInput }): Promise<Shout> => {
|
||||
const response = await privateGraphQLClient.mutation(createArticle, { shout: article }).toPromise()
|
||||
console.debug('[createArticle]:', response.data)
|
||||
console.log('!!! [createArticle]:', response.data)
|
||||
return response.data.createShout.shout
|
||||
},
|
||||
updateArticle: async ({
|
||||
|
|
Loading…
Reference in New Issue
Block a user