webapp/src/components/Views/Edit.tsx

427 lines
15 KiB
TypeScript
Raw Normal View History

import { clsx } from 'clsx'
import deepEqual from 'fast-deep-equal'
2024-02-04 11:25:21 +00:00
import { Accessor, Show, createMemo, createSignal, lazy, onCleanup, onMount } from 'solid-js'
import { createStore } from 'solid-js/store'
import { ShoutForm, useEditorContext } from '../../context/editor'
import { useLocalize } from '../../context/localize'
2024-02-05 15:04:23 +00:00
import type { Shout, Topic } from '../../graphql/schema/core.gen'
import { LayoutType, MediaItem } from '../../pages/types'
import { useRouter } from '../../stores/router'
import { clone } from '../../utils/clone'
import { getImageUrl } from '../../utils/getImageUrl'
import { isDesktop } from '../../utils/media-query'
import { slugify } from '../../utils/slugify'
import { Editor, Panel } from '../Editor'
import { AudioUploader } from '../Editor/AudioUploader'
import { AutoSaveNotice } from '../Editor/AutoSaveNotice'
import { VideoUploader } from '../Editor/VideoUploader'
2024-02-03 08:16:47 +00:00
import { Modal } from '../Nav/Modal'
import { TableOfContents } from '../TableOfContents'
2024-02-04 11:25:21 +00:00
import { DropArea } from '../_shared/DropArea'
import { Icon } from '../_shared/Icon'
import { InviteMembers } from '../_shared/InviteMembers'
import { Popover } from '../_shared/Popover'
import { EditorSwiper } from '../_shared/SolidSwiper'
import { PublishSettings } from './PublishSettings'
import styles from './Edit.module.scss'
2023-03-23 17:15:50 +00:00
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
const GrowingTextarea = lazy(() => import('../_shared/GrowingTextarea/GrowingTextarea'))
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',
2023-05-09 05:05:06 +00:00
})
}
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,
2024-02-04 17:40:15 +00:00
setForm,
setFormErrors,
saveDraft,
saveDraftToLocalStorage,
getDraftFromLocalStorage,
} = useEditorContext()
2023-05-10 20:20:53 +00:00
const shoutTopics = props.shout.topics || []
const draft = getDraftFromLocalStorage(props.shout.id)
2024-02-04 17:40:15 +00:00
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,
2023-11-28 13:18:25 +00:00
mainTopic: shoutTopics[0],
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(
2024-02-05 15:04:23 +00:00
'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-11-28 13:18:25 +00:00
case 'audio': {
return t('Album name')
}
case 'image': {
return t('Gallery name')
}
default: {
return t('Header')
}
}
}
2024-02-04 09:03:15 +00:00
let autoSaveTimeOutId: number | string | NodeJS.Timeout
const autoSaveRecursive = () => {
autoSaveTimeOutId = setTimeout(async () => {
const hasChanges = !deepEqual(form, prevForm)
if (hasChanges) {
setSaving(true)
if (props.shout?.published_at) {
saveDraftToLocalStorage(form)
} else {
await saveDraft(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}>
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(),
2023-05-11 11:52:56 +00:00
})}
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-11-28 13:18:25 +00:00
<Show when={!isSubtitleVisible() && props.shout.layout !== 'audio'}>
2023-08-31 13:41:34 +00:00
<div class={styles.action} onClick={showSubtitleInput}>
{t('Add subtitle')}
</div>
</Show>
2023-11-28 13:18:25 +00:00
<Show when={!isLeadVisible() && props.shout.layout !== 'audio'}>
2023-08-31 13:41:34 +00:00
<div class={styles.action} onClick={showLeadInput}>
{t('Add intro')}
</div>
</Show>
</div>
<>
2023-11-28 13:18:25 +00:00
<div class={clsx({ [styles.audioHeader]: props.shout.layout === 'audio' })}>
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-11-28 13:18:25 +00:00
<Show when={props.shout.layout === 'audio'}>
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-11-28 13:18:25 +00:00
<Show when={props.shout.layout !== 'audio'}>
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-11-28 13:18:25 +00:00
<Show when={props.shout.layout === 'audio'}>
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(${getImageUrl(form.coverImageUrl, {
width: 1600,
})})`,
}}
2023-10-30 11:29:15 +00:00
>
<Popover content={t('Delete cover')}>
{(triggerRef: (el) => void) => (
<div
ref={triggerRef}
class={styles.delete}
onClick={() => setForm('coverImageUrl', null)}
>
<Icon name="close-white" />
</div>
)}
</Popover>
</div>
2023-08-31 13:41:34 +00:00
</Show>
</Show>
2023-08-31 13:41:34 +00:00
</div>
<Show when={props.shout.layout === 'image'}>
<EditorSwiper
2023-08-31 13:41:34 +00:00
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-11-28 13:18:25 +00:00
<Show when={props.shout.layout === 'audio'}>
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'}>
2023-12-14 00:04:07 +00:00
<PublishSettings shoutId={props.shout.id} form={form} />
2023-08-15 10:24:08 +00:00
</Show>
2023-05-08 17:21:06 +00:00
<Panel shoutId={props.shout.id} />
2024-02-03 05:19:15 +00:00
<Modal variant="medium" name="inviteCoauthors">
<InviteMembers variant={'coauthors'} title={t('Invite experts')} />
</Modal>
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