webapp/src/components/Views/Edit.tsx

422 lines
14 KiB
TypeScript
Raw Normal View History

import { Accessor, createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js'
2023-02-17 09:21:02 +00:00
import { useLocalize } from '../../context/localize'
import { clsx } from 'clsx'
2023-03-13 12:26:25 +00:00
import { Title } from '@solidjs/meta'
2023-04-11 13:57:48 +00:00
import type { Shout, Topic } from '../../graphql/types.gen'
import { useRouter } from '../../stores/router'
import { ShoutForm, useEditorContext } from '../../context/editor'
import { Editor, Panel } from '../Editor'
2023-05-06 15:04:50 +00:00
import { Icon } from '../_shared/Icon'
2023-05-08 18:23:51 +00:00
import styles from './Edit.module.scss'
import { imageProxy } from '../../utils/imageProxy'
2023-05-11 11:33:01 +00:00
import { GrowingTextarea } from '../_shared/GrowingTextarea'
import { VideoUploader } from '../Editor/VideoUploader'
import { AudioUploader } from '../Editor/AudioUploader'
import { slugify } from '../../utils/slugify'
import { SolidSwiper } from '../_shared/SolidSwiper'
import { DropArea } from '../_shared/DropArea'
import { LayoutType, MediaItem } from '../../pages/types'
import { clone } from '../../utils/clone'
import deepEqual from 'fast-deep-equal'
import { AutoSaveNotice } from '../Editor/AutoSaveNotice'
import { PublishSettings } from './PublishSettings'
import { createStore } from 'solid-js/store'
import SimplifiedEditor from '../Editor/SimplifiedEditor'
import { isDesktop } from '../../utils/media-query'
import { TableOfContents } from '../TableOfContents'
2023-03-23 17:15:50 +00:00
type Props = {
2023-04-11 13:57:48 +00:00
shout: Shout
}
export const MAX_HEADER_LIMIT = 100
export const EMPTY_TOPIC: Topic = {
id: -1,
slug: ''
}
const AUTO_SAVE_INTERVAL = 5000
const handleScrollTopButtonClick = (e) => {
e.preventDefault()
2023-05-09 05:05:06 +00:00
window.scrollTo({
top: 0,
behavior: 'smooth'
})
}
export const EditView = (props: Props) => {
2023-02-17 09:21:02 +00:00
const { t } = useLocalize()
2023-05-07 19:33:20 +00:00
const [isScrolled, setIsScrolled] = createSignal(false)
2023-03-26 18:31:34 +00:00
const { page } = useRouter()
const {
form,
2023-05-05 20:05:50 +00:00
formErrors,
actions: { setForm, setFormErrors, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage }
} = useEditorContext()
2023-03-23 17:15:50 +00:00
2023-05-10 20:20:53 +00:00
const shoutTopics = props.shout.topics || []
const draft = getDraftFromLocalStorage(props.shout.id)
if (draft) {
setForm(draft)
} else {
setForm({
slug: props.shout.slug,
shoutId: props.shout.id,
title: props.shout.title,
lead: props.shout.lead,
description: props.shout.description,
subtitle: props.shout.subtitle,
selectedTopics: shoutTopics,
mainTopic: shoutTopics.find((topic) => topic.slug === props.shout.mainTopic) || EMPTY_TOPIC,
body: props.shout.body,
coverImageUrl: props.shout.cover,
media: props.shout.media,
layout: props.shout.layout
})
}
const subtitleInput: { current: HTMLTextAreaElement } = { current: null }
const [prevForm, setPrevForm] = createStore<ShoutForm>(clone(form))
const [saving, setSaving] = createSignal(false)
const [isSubtitleVisible, setIsSubtitleVisible] = createSignal(Boolean(form.subtitle))
const [isLeadVisible, setIsLeadVisible] = createSignal(Boolean(form.lead))
2023-03-23 17:15:50 +00:00
const mediaItems: Accessor<MediaItem[]> = createMemo(() => {
return JSON.parse(form.media || '[]')
})
2023-05-07 19:33:20 +00:00
onMount(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 0)
}
window.addEventListener('scroll', handleScroll, { passive: true })
onCleanup(() => {
window.removeEventListener('scroll', handleScroll)
})
})
onMount(() => {
// eslint-disable-next-line unicorn/consistent-function-scoping
const handleBeforeUnload = (event) => {
if (!deepEqual(prevForm, form)) {
event.returnValue = t(
`There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?`
)
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
onCleanup(() => window.removeEventListener('beforeunload', handleBeforeUnload))
})
2023-05-11 11:33:01 +00:00
const handleTitleInputChange = (value) => {
setForm('title', value)
setForm('slug', slugify(value))
2023-05-11 11:33:01 +00:00
if (value) {
2023-05-05 20:05:50 +00:00
setFormErrors('title', '')
2023-03-26 18:31:34 +00:00
}
}
const handleAddMedia = (data) => {
const newMedia = [...mediaItems(), ...data]
setForm('media', JSON.stringify(newMedia))
}
const handleSortedMedia = (data) => {
setForm('media', JSON.stringify(data))
}
const handleMediaDelete = (index) => {
const copy = [...mediaItems()]
copy.splice(index, 1)
setForm('media', JSON.stringify(copy))
}
const handleMediaChange = (index, value) => {
const updated = mediaItems().map((item, idx) => (idx === index ? value : item))
setForm('media', JSON.stringify(updated))
}
const [baseAudioFields, setBaseAudioFields] = createSignal({
artist: '',
date: '',
genre: ''
})
const handleBaseFieldsChange = (key, value) => {
if (mediaItems().length > 0) {
const updated = mediaItems().map((media) => ({ ...media, [key]: value }))
setForm('media', JSON.stringify(updated))
} else {
setBaseAudioFields({ ...baseAudioFields(), [key]: value })
}
}
const articleTitle = () => {
switch (props.shout.layout as LayoutType) {
2023-10-10 15:38:02 +00:00
case 'music': {
return t('Album name')
}
case 'image': {
return t('Gallery name')
}
default: {
return t('Header')
}
}
}
const pageTitle = () => {
switch (props.shout.layout as LayoutType) {
2023-10-10 15:38:02 +00:00
case 'music': {
return t('Publish Album')
}
case 'image': {
return t('Create gallery')
}
case 'video': {
return t('Create video')
}
case 'literature': {
return t('New literary work')
}
default: {
return t('Write an article')
}
}
}
let autoSaveTimeOutId
const autoSaveRecursive = () => {
autoSaveTimeOutId = setTimeout(async () => {
const hasChanges = !deepEqual(form, prevForm)
if (hasChanges) {
setSaving(true)
if (props.shout.visibility === 'owner') {
await saveDraft(form)
} else {
saveDraftToLocalStorage(form)
}
setPrevForm(clone(form))
setTimeout(() => {
setSaving(false)
}, 2000)
}
autoSaveRecursive()
}, AUTO_SAVE_INTERVAL)
}
const stopAutoSave = () => {
clearTimeout(autoSaveTimeOutId)
}
onMount(() => {
autoSaveRecursive()
})
onCleanup(() => {
stopAutoSave()
})
const showSubtitleInput = () => {
setIsSubtitleVisible(true)
subtitleInput.current.focus()
}
const showLeadInput = () => {
setIsLeadVisible(true)
}
2022-09-09 11:53:35 +00:00
return (
2023-04-06 21:40:34 +00:00
<>
<div class={styles.container}>
<Title>{pageTitle()}</Title>
2023-05-05 20:05:50 +00:00
<form>
2023-04-06 21:40:34 +00:00
<div class="wide-container">
2023-05-11 11:52:56 +00:00
<button
class={clsx(styles.scrollTopButton, {
[styles.visible]: isScrolled()
})}
onClick={handleScrollTopButtonClick}
2023-05-11 11:52:56 +00:00
>
<Icon name="up-button" class={styles.icon} />
<span class={styles.scrollTopButtonLabel}>{t('Scroll up')}</span>
</button>
2023-08-23 22:31:39 +00:00
<AutoSaveNotice active={saving()} />
2023-08-31 13:41:34 +00:00
<div class={styles.wrapperTableOfContents}>
<Show when={isDesktop() && form.body}>
<TableOfContents variant="editor" parentSelector="#editorBody" body={form.body} />
</Show>
</div>
2023-08-31 13:41:34 +00:00
<div class="row">
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
<Show when={page().route === 'edit'}>
<div class={styles.headingActions}>
2023-10-10 15:38:02 +00:00
<Show when={!isSubtitleVisible() && props.shout.layout !== 'music'}>
2023-08-31 13:41:34 +00:00
<div class={styles.action} onClick={showSubtitleInput}>
{t('Add subtitle')}
</div>
</Show>
2023-10-10 15:38:02 +00:00
<Show when={!isLeadVisible() && props.shout.layout !== 'music'}>
2023-08-31 13:41:34 +00:00
<div class={styles.action} onClick={showLeadInput}>
{t('Add intro')}
</div>
</Show>
</div>
<>
2023-10-10 15:38:02 +00:00
<div class={clsx({ [styles.audioHeader]: props.shout.layout === 'music' })}>
2023-08-31 13:41:34 +00:00
<div class={styles.inputContainer}>
<GrowingTextarea
allowEnterKey={true}
value={(value) => handleTitleInputChange(value)}
class={styles.titleInput}
placeholder={articleTitle()}
initialValue={form.title}
maxLength={MAX_HEADER_LIMIT}
/>
2023-08-31 13:41:34 +00:00
<Show when={formErrors.title}>
<div class={styles.validationError}>{formErrors.title}</div>
</Show>
2023-10-10 15:38:02 +00:00
<Show when={props.shout.layout === 'music'}>
2023-08-31 13:41:34 +00:00
<div class={styles.additional}>
<input
type="text"
placeholder={t('Artist...')}
class={styles.additionalInput}
value={mediaItems()[0]?.artist || ''}
onChange={(event) => handleBaseFieldsChange('artist', event.target.value)}
/>
<input
type="number"
min="1900"
max={new Date().getFullYear()}
step="1"
class={styles.additionalInput}
placeholder={t('Release date...')}
value={mediaItems()[0]?.date || ''}
onChange={(event) => handleBaseFieldsChange('date', event.target.value)}
/>
<input
type="text"
placeholder={t('Genre...')}
class={styles.additionalInput}
value={mediaItems()[0]?.genre || ''}
onChange={(event) => handleBaseFieldsChange('genre', event.target.value)}
/>
</div>
</Show>
2023-10-10 15:38:02 +00:00
<Show when={props.shout.layout !== 'music'}>
2023-08-31 13:41:34 +00:00
<Show when={isSubtitleVisible()}>
<GrowingTextarea
textAreaRef={(el) => {
subtitleInput.current = el
}}
allowEnterKey={false}
value={(value) => setForm('subtitle', value)}
class={styles.subtitleInput}
placeholder={t('Subheader')}
initialValue={form.subtitle}
maxLength={MAX_HEADER_LIMIT}
/>
</Show>
<Show when={isLeadVisible()}>
<SimplifiedEditor
variant="minimal"
onlyBubbleControls={true}
smallHeight={true}
placeholder={t('A short introduction to keep the reader interested')}
initialContent={form.lead}
onChange={(value) => setForm('lead', value)}
/>
</Show>
</Show>
</div>
2023-10-10 15:38:02 +00:00
<Show when={props.shout.layout === 'music'}>
2023-08-31 13:41:34 +00:00
<Show
when={form.coverImageUrl}
fallback={
<DropArea
isSquare={true}
placeholder={t('Add cover')}
description={
<>
{t('min. 1400×1400 pix')}
<br />
{t('jpg, .png, max. 10 mb.')}
</>
}
isMultiply={false}
fileType={'image'}
onUpload={(val) => setForm('coverImageUrl', val[0].url)}
/>
}
>
<div
class={styles.cover}
style={{ 'background-image': `url(${imageProxy(form.coverImageUrl)})` }}
/>
</Show>
</Show>
2023-08-31 13:41:34 +00:00
</div>
<Show when={props.shout.layout === 'image'}>
<SolidSwiper
editorMode={true}
images={mediaItems()}
onImageChange={handleMediaChange}
onImageDelete={(index) => handleMediaDelete(index)}
onImagesAdd={(value) => handleAddMedia(value)}
onImagesSorted={(value) => handleSortedMedia(value)}
/>
</Show>
<Show when={props.shout.layout === 'video'}>
<VideoUploader
video={mediaItems()}
onVideoAdd={(data) => handleAddMedia(data)}
onVideoDelete={(index) => handleMediaDelete(index)}
/>
</Show>
2023-10-10 15:38:02 +00:00
<Show when={props.shout.layout === 'music'}>
2023-08-31 13:41:34 +00:00
<AudioUploader
audio={mediaItems()}
baseFields={baseAudioFields()}
onAudioAdd={(value) => handleAddMedia(value)}
onAudioChange={handleMediaChange}
onAudioSorted={(value) => handleSortedMedia(value)}
/>
</Show>
</>
</Show>
</div>
</div>
2023-08-31 13:41:34 +00:00
<Show when={page().route === 'edit'}>
<Editor
shoutId={form.shoutId}
initialContent={form.body}
onChange={(body) => setForm('body', body)}
/>
</Show>
</div>
2023-04-06 21:40:34 +00:00
</form>
</div>
2023-08-15 10:24:08 +00:00
<Show when={page().route === 'editSettings'}>
<PublishSettings shoutId={props.shout.id} form={form} />
</Show>
2023-05-08 17:21:06 +00:00
<Panel shoutId={props.shout.id} />
2023-04-06 21:40:34 +00:00
</>
2022-09-09 11:53:35 +00:00
)
}
2022-11-01 19:27:43 +00:00
2023-04-11 13:57:48 +00:00
export default EditView