Article Lead and Description with simple editor (#189)

* Article Lead and Description
This commit is contained in:
Ilya Y 2023-08-22 16:37:54 +03:00 committed by GitHub
parent f0bb04b33d
commit 328bd89d8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 221 additions and 102 deletions

View File

@ -577,3 +577,14 @@ a[data-toggle='tooltip'] {
border-color: var(--black-500) transparent transparent transparent; border-color: var(--black-500) transparent transparent transparent;
} }
} }
.lead {
@include font-size(1.8rem);
font-weight: 600;
b,
strong {
font-weight: 700;
}
}

View File

@ -233,6 +233,9 @@ export const FullArticle = (props: Props) => {
</Show> </Show>
</div> </div>
</Show> </Show>
<Show when={props.article.lead}>
<section class={styles.lead} innerHTML={props.article.lead} />
</Show>
<Show when={props.article.layout === 'audio'}> <Show when={props.article.layout === 'audio'}>
<AudioHeader <AudioHeader
title={props.article.title} title={props.article.title}

View File

@ -174,7 +174,7 @@ export const Editor = (props: Props) => {
Image, Image,
Figcaption, Figcaption,
Embed, Embed,
CharacterCount, CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
BubbleMenu.configure({ BubbleMenu.configure({
pluginKey: 'textBubbleMenu', pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef.current, element: textBubbleMenuRef.current,

View File

@ -5,6 +5,7 @@
background: var(--black-50); background: var(--black-50);
border-radius: 16px; border-radius: 16px;
padding: 16px 16px 8px; padding: 16px 16px 8px;
position: relative;
.simplifiedEditorField { .simplifiedEditorField {
@include font-size(1.4rem); @include font-size(1.4rem);
@ -92,4 +93,48 @@
bottom: 0; bottom: 0;
} }
} }
&.minimal {
background: unset;
padding: 0;
& div[contenteditable] {
font-size: 1.6rem;
font-weight: 500;
}
}
&.bordered {
box-sizing: border-box;
padding: 16px 12px 6px 12px;
border-radius: 2px;
border: 2px solid var(--black-100);
background: var(--white-500);
& div[contenteditable] {
font-size: 1.6rem;
font-weight: 500;
}
}
&.labelVisible {
padding-top: 22px;
}
.limit {
position: absolute;
right: 1rem;
bottom: 0.5rem;
font-weight: 500;
font-size: 1.2rem;
}
.label {
@include font-size(1.2rem);
position: absolute;
top: 6px;
left: 12px;
color: var(--black-400);
}
} }

View File

@ -1,4 +1,4 @@
import { createEffect, onCleanup, onMount, Show } from 'solid-js' import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
import { import {
createEditorTransaction, createEditorTransaction,
createTiptapEditor, createTiptapEditor,
@ -30,12 +30,19 @@ import { UploadedFile } from '../../pages/types'
import { Figure } from './extensions/Figure' import { Figure } from './extensions/Figure'
import { Image } from '@tiptap/extension-image' import { Image } from '@tiptap/extension-image'
import { Figcaption } from './extensions/Figcaption' import { Figcaption } from './extensions/Figcaption'
import { TextBubbleMenu } from './TextBubbleMenu'
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { CharacterCount } from '@tiptap/extension-character-count'
import { createStore } from 'solid-js/store'
type Props = { type Props = {
initialContent?: string initialContent?: string
label?: string
onSubmit?: (text: string) => void onSubmit?: (text: string) => void
onChange?: (text: string) => void onChange?: (text: string) => void
placeholder: string placeholder: string
variant?: 'minimal' | 'bordered'
maxLength?: number
submitButtonText?: string submitButtonText?: string
quoteEnabled?: boolean quoteEnabled?: boolean
imageEnabled?: boolean imageEnabled?: boolean
@ -43,10 +50,13 @@ type Props = {
smallHeight?: boolean smallHeight?: boolean
submitByEnter?: boolean submitByEnter?: boolean
submitByShiftEnter?: boolean submitByShiftEnter?: boolean
onlyBubbleControls?: boolean
} }
export const MAX_DESCRIPTION_LIMIT = 400
const SimplifiedEditor = (props: Props) => { const SimplifiedEditor = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const [counter, setCounter] = createSignal<number>()
const wrapperEditorElRef: { const wrapperEditorElRef: {
current: HTMLElement current: HTMLElement
@ -60,6 +70,12 @@ const SimplifiedEditor = (props: Props) => {
current: null current: null
} }
const textBubbleMenuRef: {
current: HTMLDivElement
} = {
current: null
}
const { const {
actions: { setEditor } actions: { setEditor }
} = useEditorContext() } = useEditorContext()
@ -69,6 +85,7 @@ const SimplifiedEditor = (props: Props) => {
content: 'figcaption image' content: 'figcaption image'
}) })
const content = props.initialContent
const editor = createTiptapEditor(() => ({ const editor = createTiptapEditor(() => ({
element: editorElRef.current, element: editorElRef.current,
editorProps: { editorProps: {
@ -85,11 +102,25 @@ const SimplifiedEditor = (props: Props) => {
Link.configure({ Link.configure({
openOnClick: false openOnClick: false
}), }),
CharacterCount.configure({
limit: MAX_DESCRIPTION_LIMIT
}),
Blockquote.configure({ Blockquote.configure({
HTMLAttributes: { HTMLAttributes: {
class: styles.blockQuote class: styles.blockQuote
} }
}), }),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef.current,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return
const { selection } = state
const { empty } = selection
return view.hasFocus() && !empty
}
}),
ImageFigure, ImageFigure,
Image, Image,
Figcaption, Figcaption,
@ -98,7 +129,7 @@ const SimplifiedEditor = (props: Props) => {
placeholder: props.placeholder placeholder: props.placeholder
}) })
], ],
content: props.initialContent ?? null content: content ?? null
})) }))
setEditor(editor) setEditor(editor)
@ -193,94 +224,110 @@ const SimplifiedEditor = (props: Props) => {
const handleInsertLink = () => !editor().state.selection.empty && showModal('editorInsertLink') const handleInsertLink = () => !editor().state.selection.empty && showModal('editorInsertLink')
createEffect(() => {
if (html()) {
setCounter(editor().storage.characterCount.characters())
}
})
return ( return (
<div <div
ref={(el) => (wrapperEditorElRef.current = el)} ref={(el) => (wrapperEditorElRef.current = el)}
class={clsx(styles.SimplifiedEditor, { class={clsx(styles.SimplifiedEditor, {
[styles.smallHeight]: props.smallHeight, [styles.smallHeight]: props.smallHeight,
[styles.isFocused]: isFocused() || !isEmpty() [styles.minimal]: props.variant === 'minimal',
[styles.bordered]: props.variant === 'bordered',
[styles.isFocused]: isFocused() || !isEmpty(),
[styles.labelVisible]: props.label && counter() > 0
})} })}
> >
<Show when={props.maxLength && editor()}>
<div class={styles.limit}>{MAX_DESCRIPTION_LIMIT - counter()}</div>
</Show>
<Show when={props.label && counter() > 0}>
<div class={styles.label}>{props.label}</div>
</Show>
<div ref={(el) => (editorElRef.current = el)} /> <div ref={(el) => (editorElRef.current = el)} />
<div class={styles.controls}> <Show when={!props.onlyBubbleControls}>
<div class={styles.actions}> <div class={styles.controls}>
<Popover content={t('Bold')}> <div class={styles.actions}>
{(triggerRef: (el) => void) => ( <Popover content={t('Bold')}>
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: isBold() })}
onClick={() => editor().chain().focus().toggleBold().run()}
>
<Icon name="editor-bold" />
</button>
)}
</Popover>
<Popover content={t('Italic')}>
{(triggerRef: (el) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: isItalic() })}
onClick={() => editor().chain().focus().toggleItalic().run()}
>
<Icon name="editor-italic" />
</button>
)}
</Popover>
<Popover content={t('Add url')}>
{(triggerRef: (el) => void) => (
<button
ref={triggerRef}
type="button"
onClick={handleInsertLink}
class={clsx(styles.actionButton, { [styles.active]: isLink() })}
>
<Icon name="editor-link" />
</button>
)}
</Popover>
<Show when={props.quoteEnabled}>
<Popover content={t('Add blockquote')}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el) => void) => (
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
onClick={() => editor().chain().focus().toggleBlockquote().run()} class={clsx(styles.actionButton, { [styles.active]: isBold() })}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })} onClick={() => editor().chain().focus().toggleBold().run()}
> >
<Icon name="editor-quote" /> <Icon name="editor-bold" />
</button> </button>
)} )}
</Popover> </Popover>
</Show> <Popover content={t('Italic')}>
<Show when={props.imageEnabled}>
<Popover content={t('Add image')}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el) => void) => (
<button <button
ref={triggerRef} ref={triggerRef}
type="button" type="button"
onClick={() => showModal('uploadImage')} class={clsx(styles.actionButton, { [styles.active]: isItalic() })}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })} onClick={() => editor().chain().focus().toggleItalic().run()}
> >
<Icon name="editor-image-dd-full" /> <Icon name="editor-italic" />
</button> </button>
)} )}
</Popover> </Popover>
<Popover content={t('Add url')}>
{(triggerRef: (el) => void) => (
<button
ref={triggerRef}
type="button"
onClick={handleInsertLink}
class={clsx(styles.actionButton, { [styles.active]: isLink() })}
>
<Icon name="editor-link" />
</button>
)}
</Popover>
<Show when={props.quoteEnabled}>
<Popover content={t('Add blockquote')}>
{(triggerRef: (el) => void) => (
<button
ref={triggerRef}
type="button"
onClick={() => editor().chain().focus().toggleBlockquote().run()}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
>
<Icon name="editor-quote" />
</button>
)}
</Popover>
</Show>
<Show when={props.imageEnabled}>
<Popover content={t('Add image')}>
{(triggerRef: (el) => void) => (
<button
ref={triggerRef}
type="button"
onClick={() => showModal('uploadImage')}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
>
<Icon name="editor-image-dd-full" />
</button>
)}
</Popover>
</Show>
</div>
<Show when={!props.onChange}>
<div class={styles.buttons}>
<Button value={t('Cancel')} variant="secondary" disabled={isEmpty()} onClick={handleClear} />
<Button
value={props.submitButtonText ?? t('Send')}
variant="primary"
disabled={isEmpty()}
onClick={() => props.onSubmit(html())}
/>
</div>
</Show> </Show>
</div> </div>
<Show when={!props.onChange}> </Show>
<div class={styles.buttons}>
<Button value={t('Cancel')} variant="secondary" disabled={isEmpty()} onClick={handleClear} />
<Button
value={props.submitButtonText ?? t('Send')}
variant="primary"
disabled={isEmpty()}
onClick={() => props.onSubmit(html())}
/>
</div>
</Show>
</div>
<Modal variant="narrow" name="editorInsertLink"> <Modal variant="narrow" name="editorInsertLink">
<InsertLinkForm editor={editor()} onClose={() => hideModal()} /> <InsertLinkForm editor={editor()} onClose={() => hideModal()} />
</Modal> </Modal>
@ -293,6 +340,13 @@ const SimplifiedEditor = (props: Props) => {
/> />
</Modal> </Modal>
</Show> </Show>
<Show when={props.onlyBubbleControls}>
<TextBubbleMenu
isCommonMarkup={true}
editor={editor()}
ref={(el) => (textBubbleMenuRef.current = el)}
/>
</Show>
</div> </div>
) )
} }

View File

@ -180,12 +180,10 @@
} }
} }
.shoutCardLead { .shoutCardDescription {
@include font-size(1.6rem); @include font-size(1.6rem);
color: var(--secondary-color); color: var(--default-color);
font-weight: 400;
line-height: 1.3;
margin-bottom: 1.4rem; margin-bottom: 1.4rem;
} }

View File

@ -164,10 +164,6 @@ export const ArticleCard = (props: ArticleCardProps) => {
</div> </div>
</Show> </Show>
</a> </a>
<Show when={props.article.lead}>
<div class={styles.shoutCardLead}>{props.article.lead}</div>
</Show>
</div> </div>
<Show when={!props.settings?.noauthor || !props.settings?.nodate}> <Show when={!props.settings?.noauthor || !props.settings?.nodate}>
@ -196,7 +192,9 @@ export const ArticleCard = (props: ArticleCardProps) => {
</Show> </Show>
</div> </div>
</Show> </Show>
<Show when={props.article.description}>
<section class={styles.shoutCardDescription} innerHTML={props.article.description} />
</Show>
<Show when={props.settings?.isFeedMode}> <Show when={props.settings?.isFeedMode}>
<Show when={!props.settings?.noimage && props.article.cover}> <Show when={!props.settings?.noimage && props.article.cover}>
<div class={styles.shoutCardCoverContainer}> <div class={styles.shoutCardCoverContainer}>

View File

@ -21,13 +21,13 @@ import deepEqual from 'fast-deep-equal'
import { AutoSaveNotice } from '../Editor/AutoSaveNotice' import { AutoSaveNotice } from '../Editor/AutoSaveNotice'
import { PublishSettings } from './PublishSettings' import { PublishSettings } from './PublishSettings'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import SimplifiedEditor from '../Editor/SimplifiedEditor'
type Props = { type Props = {
shout: Shout shout: Shout
} }
export const MAX_HEADER_LIMIT = 100 export const MAX_HEADER_LIMIT = 100
export const MAX_LEAD_LIMIT = 400
export const EMPTY_TOPIC: Topic = { export const EMPTY_TOPIC: Topic = {
id: -1, id: -1,
slug: '' slug: ''
@ -64,6 +64,8 @@ export const EditView = (props: Props) => {
slug: props.shout.slug, slug: props.shout.slug,
shoutId: props.shout.id, shoutId: props.shout.id,
title: props.shout.title, title: props.shout.title,
lead: props.shout.lead,
description: props.shout.description,
subtitle: props.shout.subtitle, subtitle: props.shout.subtitle,
selectedTopics: shoutTopics, selectedTopics: shoutTopics,
mainTopic: shoutTopics.find((topic) => topic.slug === props.shout.mainTopic) || EMPTY_TOPIC, mainTopic: shoutTopics.find((topic) => topic.slug === props.shout.mainTopic) || EMPTY_TOPIC,
@ -75,7 +77,6 @@ export const EditView = (props: Props) => {
} }
const subtitleInput: { current: HTMLTextAreaElement } = { current: null } const subtitleInput: { current: HTMLTextAreaElement } = { current: null }
const leadInput: { current: HTMLTextAreaElement } = { current: null }
const [prevForm, setPrevForm] = createStore<ShoutForm>(clone(form)) const [prevForm, setPrevForm] = createStore<ShoutForm>(clone(form))
const [saving, setSaving] = createSignal(false) const [saving, setSaving] = createSignal(false)
@ -226,7 +227,6 @@ export const EditView = (props: Props) => {
} }
const showLeadInput = () => { const showLeadInput = () => {
setIsLeadVisible(true) setIsLeadVisible(true)
leadInput.current.focus()
} }
return ( return (
@ -320,16 +320,13 @@ export const EditView = (props: Props) => {
/> />
</Show> </Show>
<Show when={isLeadVisible()}> <Show when={isLeadVisible()}>
<GrowingTextarea <SimplifiedEditor
textAreaRef={(el) => { variant="minimal"
leadInput.current = el onlyBubbleControls={true}
}} smallHeight={true}
allowEnterKey={true} placeholder={t('A short introduction to keep the reader interested')}
value={(value) => setForm('lead', value)} initialContent={form.lead}
class={styles.leadInput} onChange={(value) => setForm('lead', value)}
placeholder={t('Description')}
initialValue={form.subtitle}
maxLength={MAX_LEAD_LIMIT}
/> />
</Show> </Show>
</Show> </Show>

View File

@ -10,7 +10,7 @@ import { useLocalize } from '../../../context/localize'
import { Modal } from '../../Nav/Modal' import { Modal } from '../../Nav/Modal'
import { Topic } from '../../../graphql/types.gen' import { Topic } from '../../../graphql/types.gen'
import { apiClient } from '../../../utils/apiClient' import { apiClient } from '../../../utils/apiClient'
import { EMPTY_TOPIC, MAX_LEAD_LIMIT } from '../Edit' import { EMPTY_TOPIC } from '../Edit'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import stylesBeside from '../../Feed/Beside.module.scss' import stylesBeside from '../../Feed/Beside.module.scss'
@ -19,6 +19,7 @@ import { router } from '../../../stores/router'
import { GrowingTextarea } from '../../_shared/GrowingTextarea' import { GrowingTextarea } from '../../_shared/GrowingTextarea'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { UploadedFile } from '../../../pages/types' import { UploadedFile } from '../../../pages/types'
import SimplifiedEditor, { MAX_DESCRIPTION_LIMIT } from '../../Editor/SimplifiedEditor'
type Props = { type Props = {
shoutId: number shoutId: number
@ -35,12 +36,12 @@ export const PublishSettings = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { user } = useSession() const { user } = useSession()
const composeLead = () => { const composeDescription = () => {
if (!props.form.lead) { if (!props.form.description) {
const leadText = props.form.body.replaceAll(/<\/?[^>]+(>|$)/gi, ' ') const leadText = props.form.body.replaceAll(/<\/?[^>]+(>|$)/gi, ' ')
return shorten(leadText, MAX_LEAD_LIMIT).trim() return shorten(leadText, MAX_DESCRIPTION_LIMIT).trim()
} }
return props.form.lead return props.form.description
} }
const initialData: Partial<ShoutForm> = { const initialData: Partial<ShoutForm> = {
@ -49,7 +50,7 @@ export const PublishSettings = (props: Props) => {
slug: props.form.slug, slug: props.form.slug,
title: props.form.title, title: props.form.title,
subtitle: props.form.subtitle, subtitle: props.form.subtitle,
lead: composeLead() description: composeDescription()
} }
const { const {
@ -183,15 +184,15 @@ export const PublishSettings = (props: Props) => {
allowEnterKey={false} allowEnterKey={false}
maxLength={100} maxLength={100}
/> />
<GrowingTextarea <SimplifiedEditor
class={styles.settingInput}
variant="bordered" variant="bordered"
fieldName={t('Description')} onlyBubbleControls={true}
smallHeight={true}
placeholder={t('Write a short introduction')} placeholder={t('Write a short introduction')}
initialValue={`${settingsForm.lead}`} label={t('Description')}
value={(value) => setSettingsForm('lead', value)} initialContent={composeDescription()}
allowEnterKey={false} onChange={(value) => setForm('description', value)}
maxLength={MAX_LEAD_LIMIT} maxLength={MAX_DESCRIPTION_LIMIT}
/> />
</div> </div>

View File

@ -9,7 +9,7 @@
padding: 16px 12px; padding: 16px 12px;
border-radius: 2px; border-radius: 2px;
border: 2px solid var(--black-100); border: 2px solid var(--black-100);
background: var(--white-500, #fff); background: var(--white-500);
} }
&.hasFieldName { &.hasFieldName {

View File

@ -21,12 +21,13 @@ export type ShoutForm = {
slug: string slug: string
title: string title: string
subtitle: string subtitle: string
lead?: string
description?: string
selectedTopics: Topic[] selectedTopics: Topic[]
mainTopic?: Topic mainTopic?: Topic
body: string body: string
coverImageUrl: string coverImageUrl: string
media?: string media?: string
lead?: string
} }
type EditorContextType = { type EditorContextType = {
@ -136,6 +137,8 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
slug: formToUpdate.slug, slug: formToUpdate.slug,
subtitle: formToUpdate.subtitle, subtitle: formToUpdate.subtitle,
title: formToUpdate.title, title: formToUpdate.title,
lead: formToUpdate.lead,
description: formToUpdate.description,
cover: formToUpdate.coverImageUrl, cover: formToUpdate.coverImageUrl,
media: formToUpdate.media media: formToUpdate.media
}, },

View File

@ -9,6 +9,8 @@ export default gql`
slug slug
title title
subtitle subtitle
lead
description
body body
visibility visibility
} }

View File

@ -5,6 +5,8 @@ export default gql`
loadShout(slug: $slug, shout_id: $shoutId) { loadShout(slug: $slug, shout_id: $shoutId) {
id id
title title
lead
description
visibility visibility
subtitle subtitle
slug slug

View File

@ -5,6 +5,8 @@ export default gql`
loadShouts(options: $options) { loadShouts(options: $options) {
id id
title title
lead
description
subtitle subtitle
slug slug
layout layout

View File

@ -554,6 +554,7 @@ export type Shout = {
createdAt: Scalars['DateTime'] createdAt: Scalars['DateTime']
deletedAt?: Maybe<Scalars['DateTime']> deletedAt?: Maybe<Scalars['DateTime']>
deletedBy?: Maybe<User> deletedBy?: Maybe<User>
description?: Maybe<Scalars['String']>
id: Scalars['Int'] id: Scalars['Int']
lang?: Maybe<Scalars['String']> lang?: Maybe<Scalars['String']>
layout?: Maybe<Scalars['String']> layout?: Maybe<Scalars['String']>
@ -577,7 +578,9 @@ export type ShoutInput = {
body?: InputMaybe<Scalars['String']> body?: InputMaybe<Scalars['String']>
community?: InputMaybe<Scalars['Int']> community?: InputMaybe<Scalars['Int']>
cover?: InputMaybe<Scalars['String']> cover?: InputMaybe<Scalars['String']>
description?: InputMaybe<Scalars['String']>
layout?: InputMaybe<Scalars['String']> layout?: InputMaybe<Scalars['String']>
lead?: InputMaybe<Scalars['String']>
mainTopic?: InputMaybe<TopicInput> mainTopic?: InputMaybe<TopicInput>
media?: InputMaybe<Scalars['String']> media?: InputMaybe<Scalars['String']>
slug?: InputMaybe<Scalars['String']> slug?: InputMaybe<Scalars['String']>