webapp/src/components/Views/EditView/EditView.tsx

476 lines
17 KiB
TypeScript
Raw Normal View History

import { clsx } from 'clsx'
import deepEqual from 'fast-deep-equal'
2024-06-24 17:50:27 +00:00
import {
Accessor,
Show,
createEffect,
createMemo,
createSignal,
lazy,
on,
onCleanup,
2024-06-26 08:22:05 +00:00
onMount
2024-06-24 17:50:27 +00:00
} from 'solid-js'
import { createStore } from 'solid-js/store'
2024-05-18 23:22:19 +00:00
import { debounce } from 'throttle-debounce'
import { DropArea } from '~/components/_shared/DropArea'
import { Icon } from '~/components/_shared/Icon'
import { InviteMembers } from '~/components/_shared/InviteMembers'
import { Loading } from '~/components/_shared/Loading'
import { Popover } from '~/components/_shared/Popover'
import { EditorSwiper } from '~/components/_shared/SolidSwiper'
import { ShoutForm, useEditorContext } from '~/context/editor'
2024-06-24 17:50:27 +00:00
import { useGraphQL } from '~/context/graphql'
import { useLocalize } from '~/context/localize'
2024-06-24 17:50:27 +00:00
import getMyShoutQuery from '~/graphql/query/core/article-my'
import type { Shout, Topic } from '~/graphql/schema/core.gen'
2024-06-24 17:50:27 +00:00
import { LayoutType } from '~/types/common'
import { MediaItem } from '~/types/mediaitem'
import { clone } from '~/utils/clone'
import { getImageUrl } from '~/utils/getImageUrl'
import { isDesktop } from '~/utils/media-query'
import { slugify } from '~/utils/slugify'
2024-02-13 13:09:44 +00:00
import { Editor, Panel } from '../../Editor'
import { AudioUploader } from '../../Editor/AudioUploader'
import { AutoSaveNotice } from '../../Editor/AutoSaveNotice'
import { VideoUploader } from '../../Editor/VideoUploader'
import { Modal } from '../../Nav/Modal'
import { TableOfContents } from '../../TableOfContents'
2024-05-30 18:35:51 +00:00
import styles from './EditView.module.scss'
2024-02-13 13:09:44 +00:00
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
const GrowingTextarea = lazy(() => import('~/components/_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,
2024-06-26 08:22:05 +00:00
slug: ''
}
2024-05-18 23:22:19 +00:00
const AUTO_SAVE_DELAY = 3000
2024-06-24 17:50:27 +00:00
const handleScrollTopButtonClick = (ev: MouseEvent | TouchEvent) => {
ev.preventDefault()
2023-05-09 05:05:06 +00:00
window.scrollTo({
top: 0,
2024-06-26 08:22:05 +00:00
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)
2024-06-24 17:50:27 +00:00
const { query } = useGraphQL()
const {
form,
2023-05-05 20:05:50 +00:00
formErrors,
2024-02-04 17:40:15 +00:00
setForm,
setFormErrors,
saveDraft,
saveDraftToLocalStorage,
2024-06-26 08:22:05 +00:00
getDraftFromLocalStorage
} = useEditorContext()
2024-06-24 17:50:27 +00:00
const [shoutTopics, setShoutTopics] = createSignal<Topic[]>([])
const [draft, setDraft] = createSignal()
let subtitleInput: HTMLTextAreaElement | 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))
2024-06-24 17:50:27 +00:00
const mediaItems: Accessor<MediaItem[]> = createMemo(() => JSON.parse(form.media || '[]'))
createEffect(
on(
() => props.shout,
(shout) => {
if (shout) {
console.debug(`[EditView] shout is loaded: ${shout}`)
setShoutTopics((shout.topics as Topic[]) || [])
const stored = getDraftFromLocalStorage(shout.id)
if (stored) {
console.info(`[EditView] got stored shout: ${stored}`)
setDraft(stored)
} else {
if (!shout.slug) {
console.warn(`[EditView] shout has no slug! ${shout}`)
}
const draftForm = {
slug: shout.slug || '',
shoutId: shout.id || 0,
title: shout.title || '',
lead: shout.lead || '',
description: shout.description || '',
subtitle: shout.subtitle || '',
selectedTopics: (shoutTopics() || []) as Topic[],
mainTopic: shoutTopics()[0] || '',
body: shout.body || '',
coverImageUrl: shout.cover || '',
media: shout.media || '',
2024-06-26 08:22:05 +00:00
layout: shout.layout
2024-06-24 17:50:27 +00:00
}
setForm((_) => draftForm)
console.debug('draft from props data: ', draftForm)
}
}
},
2024-06-26 08:22:05 +00:00
{ defer: true }
)
2024-06-24 17:50:27 +00:00
)
2023-03-23 17:15:50 +00:00
2024-06-24 17:50:27 +00:00
createEffect(
on(
draft,
(d) => {
if (d) {
const draftForm = Object.keys(d).length !== 0 ? d : { shoutId: props.shout.id }
setForm(draftForm)
console.debug('draft from localstorage: ', draftForm)
}
},
2024-06-26 08:22:05 +00:00
{ defer: true }
)
2024-06-24 17:50:27 +00:00
)
2024-06-24 17:50:27 +00:00
createEffect(
on(
() => props.shout?.id,
async (shoutId) => {
if (shoutId) {
const resp = await query(getMyShoutQuery, { shout_id: shoutId })
const result = resp?.data?.get_my_shout
if (result) {
console.debug('[EditView] getMyShout result: ', result)
const { shout: loadedShout, error } = result
setDraft(loadedShout)
console.debug('[EditView] loadedShout:', loadedShout)
console.log(error)
}
}
},
2024-06-26 08:22:05 +00:00
{ defer: true }
)
2024-06-24 17:50:27 +00:00
)
2024-05-18 23:22:19 +00:00
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)
})
2024-05-18 23:22:19 +00:00
2024-06-24 17:50:27 +00:00
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!deepEqual(prevForm, form)) {
event.returnValue = t(
2024-06-26 08:22:05 +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))
})
2024-02-13 13:09:44 +00:00
const handleTitleInputChange = (value: string) => {
2024-05-18 23:22:19 +00:00
handleInputChange('title', value)
handleInputChange('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
}
}
2024-06-24 17:50:27 +00:00
const handleAddMedia = (data: MediaItem[]) => {
const newMedia = [...mediaItems(), ...data]
2024-05-18 23:22:19 +00:00
handleInputChange('media', JSON.stringify(newMedia))
}
2024-06-24 17:50:27 +00:00
const handleSortedMedia = (data: MediaItem[]) => {
2024-05-18 23:22:19 +00:00
handleInputChange('media', JSON.stringify(data))
}
2024-06-24 17:50:27 +00:00
const handleMediaDelete = (index: number) => {
const copy = [...mediaItems()]
2024-06-02 10:37:54 +00:00
if (copy?.length > 0) copy.splice(index, 1)
2024-05-18 23:22:19 +00:00
handleInputChange('media', JSON.stringify(copy))
}
2024-06-24 17:50:27 +00:00
const handleMediaChange = (index: number, value: MediaItem) => {
const updated = mediaItems().map((item, idx) => (idx === index ? value : item))
2024-05-18 23:22:19 +00:00
handleInputChange('media', JSON.stringify(updated))
}
const [baseAudioFields, setBaseAudioFields] = createSignal({
artist: '',
date: '',
2024-06-26 08:22:05 +00:00
genre: ''
})
2024-06-24 17:50:27 +00:00
const handleBaseFieldsChange = (key: string, value: string) => {
if (mediaItems().length > 0) {
const updated = mediaItems().map((media) => ({ ...media, [key]: value }))
2024-05-18 23:22:19 +00:00
handleInputChange('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-06-24 17:50:27 +00:00
const [hasChanges, setHasChanges] = createSignal(false)
2024-05-01 14:33:37 +00:00
const autoSave = async () => {
2024-05-18 23:22:19 +00:00
console.log('autoSave called')
if (hasChanges()) {
2024-05-03 13:38:12 +00:00
console.debug('saving draft', form)
2024-05-01 14:33:37 +00:00
setSaving(true)
2024-05-03 13:38:12 +00:00
saveDraftToLocalStorage(form)
await saveDraft(form)
2024-05-01 14:33:37 +00:00
setPrevForm(clone(form))
2024-05-18 23:22:19 +00:00
setSaving(false)
setHasChanges(false)
2024-05-01 14:33:37 +00:00
}
}
2024-05-18 23:22:19 +00:00
const debouncedAutoSave = debounce(AUTO_SAVE_DELAY, autoSave)
2024-05-01 14:33:37 +00:00
2024-06-24 17:50:27 +00:00
const handleInputChange = (key: keyof ShoutForm, value: string) => {
2024-05-18 23:22:19 +00:00
console.log(`[handleInputChange] ${key}: ${value}`)
setForm(key, value)
setHasChanges(true)
debouncedAutoSave()
}
onMount(() => {
2024-05-18 23:22:19 +00:00
onCleanup(() => {
debouncedAutoSave.cancel()
})
})
const showSubtitleInput = () => {
setIsSubtitleVisible(true)
2024-06-24 17:50:27 +00:00
subtitleInput?.focus()
}
2024-05-01 14:33:37 +00:00
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, {
2024-06-26 08:22:05 +00:00
[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">
2024-07-03 21:25:03 +00:00
<Show when={props.shout}>
2023-08-31 13:41:34 +00:00
<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
2024-06-24 17:50:27 +00:00
textAreaRef={(el) => (subtitleInput = el)}
2023-08-31 13:41:34 +00:00
allowEnterKey={false}
2024-05-18 23:22:19 +00:00
value={(value) => handleInputChange('subtitle', value || '')}
2023-08-31 13:41:34 +00:00
class={styles.subtitleInput}
placeholder={t('Subheader')}
2024-02-17 15:13:54 +00:00
initialValue={form.subtitle || ''}
2023-08-31 13:41:34 +00:00
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}
2024-05-18 23:22:19 +00:00
onChange={(value) => handleInputChange('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'}
2024-05-18 23:22:19 +00:00
onUpload={(val) => handleInputChange('coverImageUrl', val[0].url)}
2023-08-31 13:41:34 +00:00
/>
}
>
<div
class={styles.cover}
style={{
2024-06-24 17:50:27 +00:00
'background-image': `url(${getImageUrl(form.coverImageUrl || '', {
2024-06-26 08:22:05 +00:00
width: 1600
})})`
}}
2023-10-30 11:29:15 +00:00
>
<Popover content={t('Delete cover')}>
2024-06-24 17:50:27 +00:00
{(triggerRef: (_el: HTMLElement | null) => void) => (
2023-10-30 11:29:15 +00:00
<div
ref={triggerRef}
class={styles.delete}
2024-06-24 17:50:27 +00:00
onClick={() => handleInputChange('coverImageUrl', '')}
2023-10-30 11:29:15 +00:00
>
<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)}
2024-06-24 17:50:27 +00:00
onImagesAdd={(value: MediaItem[]) => handleAddMedia(value)}
2023-08-31 13:41:34 +00:00
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>
2024-07-03 21:25:03 +00:00
<Show when={form?.shoutId} fallback={<Loading />}>
2023-08-31 13:41:34 +00:00
<Editor
shoutId={form.shoutId}
initialContent={form.body}
2024-05-18 23:22:19 +00:00
onChange={(body) => handleInputChange('body', body)}
2023-08-31 13:41:34 +00:00
/>
</Show>
</div>
2023-04-06 21:40:34 +00:00
</form>
</div>
2024-06-24 17:50:27 +00:00
<Show when={props.shout}>
<Panel shoutId={props.shout.id} />
</Show>
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