import { clsx } from 'clsx' import deepEqual from 'fast-deep-equal' import { Accessor, Show, createEffect, createMemo, createSignal, lazy, on, onCleanup, onMount } from 'solid-js' import { createStore } from 'solid-js/store' 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 { coreApiUrl } from '~/config' import { ShoutForm, useEditorContext } from '~/context/editor' import { useLocalize } from '~/context/localize' import { useSession } from '~/context/session' import { graphqlClientCreate } from '~/graphql/client' import getMyShoutQuery from '~/graphql/query/core/article-my' import type { Shout, Topic } from '~/graphql/schema/core.gen' import { slugify } from '~/intl/translit' import { getImageUrl } from '~/lib/getThumbUrl' import { isDesktop } from '~/lib/mediaQuery' import { LayoutType } from '~/types/common' import { MediaItem } from '~/types/mediaitem' import { clone } from '~/utils/clone' import { Editor, Panel } from '../../Editor' import { AudioUploader } from '../../Editor/AudioUploader' import { AutoSaveNotice } from '../../Editor/AutoSaveNotice' import { VideoUploader } from '../../Editor/VideoUploader' import { Modal } from '../../_shared/Modal' import { TableOfContents } from '../../_shared/TableOfContents' import styles from './EditView.module.scss' const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor')) const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea')) type Props = { shout: Shout } export const MAX_HEADER_LIMIT = 100 export const EMPTY_TOPIC: Topic = { id: -1, slug: '' } const AUTO_SAVE_DELAY = 3000 const handleScrollTopButtonClick = (ev: MouseEvent | TouchEvent) => { ev.preventDefault() window?.scrollTo({ top: 0, behavior: 'smooth' }) } export const EditView = (props: Props) => { const { t } = useLocalize() const [isScrolled, setIsScrolled] = createSignal(false) const { session } = useSession() const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token)) const { form, formErrors, setForm, setFormErrors, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage } = useEditorContext() const [shoutTopics, setShoutTopics] = createSignal([]) const [draft, setDraft] = createSignal() let subtitleInput: HTMLTextAreaElement | null const [prevForm, setPrevForm] = createStore(clone(form)) const [saving, setSaving] = createSignal(false) const [isSubtitleVisible, setIsSubtitleVisible] = createSignal(Boolean(form.subtitle)) const [isLeadVisible, setIsLeadVisible] = createSignal(Boolean(form.lead)) const mediaItems: Accessor = 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 || '', layout: shout.layout } setForm((_) => draftForm) console.debug('draft from props data: ', draftForm) } } }, { defer: true } ) ) 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) } }, { defer: true } ) ) createEffect( on( () => props.shout?.id, async (shoutId) => { if (shoutId) { const resp = await client()?.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) error && console.log(error) } } }, { defer: true } ) ) onMount(() => { const handleScroll = () => { setIsScrolled(window.scrollY > 0) } window.addEventListener('scroll', handleScroll, { passive: true }) onCleanup(() => { window.removeEventListener('scroll', handleScroll) }) const handleBeforeUnload = (event: BeforeUnloadEvent) => { 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)) }) const handleTitleInputChange = (value: string) => { handleInputChange('title', value) handleInputChange('slug', slugify(value)) if (value) { setFormErrors('title', '') } } const handleAddMedia = (data: MediaItem[]) => { const newMedia = [...mediaItems(), ...data] handleInputChange('media', JSON.stringify(newMedia)) } const handleSortedMedia = (data: MediaItem[]) => { handleInputChange('media', JSON.stringify(data)) } const handleMediaDelete = (index: number) => { const copy = [...mediaItems()] if (copy?.length > 0) copy.splice(index, 1) handleInputChange('media', JSON.stringify(copy)) } const handleMediaChange = (index: number, value: MediaItem) => { const updated = mediaItems().map((item, idx) => (idx === index ? value : item)) handleInputChange('media', JSON.stringify(updated)) } const [baseAudioFields, setBaseAudioFields] = createSignal({ artist: '', date: '', genre: '' }) const handleBaseFieldsChange = (key: string, value: string) => { if (mediaItems().length > 0) { const updated = mediaItems().map((media) => ({ ...media, [key]: value })) handleInputChange('media', JSON.stringify(updated)) } else { setBaseAudioFields({ ...baseAudioFields(), [key]: value }) } } const articleTitle = () => { switch (props.shout.layout as LayoutType) { case 'audio': { return t('Album name') } case 'image': { return t('Gallery name') } default: { return t('Header') } } } const [hasChanges, setHasChanges] = createSignal(false) const autoSave = async () => { console.log('autoSave called') if (hasChanges()) { console.debug('saving draft', form) setSaving(true) saveDraftToLocalStorage(form) await saveDraft(form) setPrevForm(clone(form)) setSaving(false) setHasChanges(false) } } const debouncedAutoSave = debounce(AUTO_SAVE_DELAY, autoSave) const handleInputChange = (key: keyof ShoutForm, value: string) => { console.log(`[handleInputChange] ${key}: ${value}`) setForm(key, value) setHasChanges(true) debouncedAutoSave() } onMount(() => { onCleanup(() => { debouncedAutoSave.cancel() }) }) const showSubtitleInput = () => { setIsSubtitleVisible(true) subtitleInput?.focus() } const showLeadInput = () => { setIsLeadVisible(true) } return ( <>
{t('Add subtitle')}
{t('Add intro')}
<>
handleTitleInputChange(value)} class={styles.titleInput} placeholder={articleTitle()} initialValue={form.title} maxLength={MAX_HEADER_LIMIT} />
{formErrors.title}
handleBaseFieldsChange('artist', event.target.value)} /> handleBaseFieldsChange('date', event.target.value)} /> handleBaseFieldsChange('genre', event.target.value)} />
(subtitleInput = el)} allowEnterKey={false} value={(value) => handleInputChange('subtitle', value || '')} class={styles.subtitleInput} placeholder={t('Subheader')} initialValue={form.subtitle || ''} maxLength={MAX_HEADER_LIMIT} /> handleInputChange('lead', value)} />
{t('min. 1400×1400 pix')}
{t('jpg, .png, max. 10 mb.')} } isMultiply={false} fileType={'image'} onUpload={(val) => handleInputChange('coverImageUrl', val[0].url)} /> } >
{(triggerRef: (_el: HTMLElement | null) => void) => (
handleInputChange('coverImageUrl', '')} >
)}
handleMediaDelete(index)} onImagesAdd={(value: MediaItem[]) => handleAddMedia(value)} onImagesSorted={(value) => handleSortedMedia(value)} /> handleAddMedia(data)} onVideoDelete={(index) => handleMediaDelete(index)} /> handleAddMedia(value)} onAudioChange={handleMediaChange} onAudioSorted={(value) => handleSortedMedia(value)} />
}> handleInputChange('body', body)} />
) } export default EditView