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;
}
}
.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>
</div>
</Show>
<Show when={props.article.lead}>
<section class={styles.lead} innerHTML={props.article.lead} />
</Show>
<Show when={props.article.layout === 'audio'}>
<AudioHeader
title={props.article.title}

View File

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

View File

@ -5,6 +5,7 @@
background: var(--black-50);
border-radius: 16px;
padding: 16px 16px 8px;
position: relative;
.simplifiedEditorField {
@include font-size(1.4rem);
@ -92,4 +93,48 @@
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 {
createEditorTransaction,
createTiptapEditor,
@ -30,12 +30,19 @@ import { UploadedFile } from '../../pages/types'
import { Figure } from './extensions/Figure'
import { Image } from '@tiptap/extension-image'
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 = {
initialContent?: string
label?: string
onSubmit?: (text: string) => void
onChange?: (text: string) => void
placeholder: string
variant?: 'minimal' | 'bordered'
maxLength?: number
submitButtonText?: string
quoteEnabled?: boolean
imageEnabled?: boolean
@ -43,10 +50,13 @@ type Props = {
smallHeight?: boolean
submitByEnter?: boolean
submitByShiftEnter?: boolean
onlyBubbleControls?: boolean
}
export const MAX_DESCRIPTION_LIMIT = 400
const SimplifiedEditor = (props: Props) => {
const { t } = useLocalize()
const [counter, setCounter] = createSignal<number>()
const wrapperEditorElRef: {
current: HTMLElement
@ -60,6 +70,12 @@ const SimplifiedEditor = (props: Props) => {
current: null
}
const textBubbleMenuRef: {
current: HTMLDivElement
} = {
current: null
}
const {
actions: { setEditor }
} = useEditorContext()
@ -69,6 +85,7 @@ const SimplifiedEditor = (props: Props) => {
content: 'figcaption image'
})
const content = props.initialContent
const editor = createTiptapEditor(() => ({
element: editorElRef.current,
editorProps: {
@ -85,11 +102,25 @@ const SimplifiedEditor = (props: Props) => {
Link.configure({
openOnClick: false
}),
CharacterCount.configure({
limit: MAX_DESCRIPTION_LIMIT
}),
Blockquote.configure({
HTMLAttributes: {
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,
Image,
Figcaption,
@ -98,7 +129,7 @@ const SimplifiedEditor = (props: Props) => {
placeholder: props.placeholder
})
],
content: props.initialContent ?? null
content: content ?? null
}))
setEditor(editor)
@ -193,94 +224,110 @@ const SimplifiedEditor = (props: Props) => {
const handleInsertLink = () => !editor().state.selection.empty && showModal('editorInsertLink')
createEffect(() => {
if (html()) {
setCounter(editor().storage.characterCount.characters())
}
})
return (
<div
ref={(el) => (wrapperEditorElRef.current = el)}
class={clsx(styles.SimplifiedEditor, {
[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 class={styles.controls}>
<div class={styles.actions}>
<Popover content={t('Bold')}>
{(triggerRef: (el) => void) => (
<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')}>
<Show when={!props.onlyBubbleControls}>
<div class={styles.controls}>
<div class={styles.actions}>
<Popover content={t('Bold')}>
{(triggerRef: (el) => void) => (
<button
ref={triggerRef}
type="button"
onClick={() => editor().chain().focus().toggleBlockquote().run()}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
class={clsx(styles.actionButton, { [styles.active]: isBold() })}
onClick={() => editor().chain().focus().toggleBold().run()}
>
<Icon name="editor-quote" />
<Icon name="editor-bold" />
</button>
)}
</Popover>
</Show>
<Show when={props.imageEnabled}>
<Popover content={t('Add image')}>
<Popover content={t('Italic')}>
{(triggerRef: (el) => void) => (
<button
ref={triggerRef}
type="button"
onClick={() => showModal('uploadImage')}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
class={clsx(styles.actionButton, { [styles.active]: isItalic() })}
onClick={() => editor().chain().focus().toggleItalic().run()}
>
<Icon name="editor-image-dd-full" />
<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) => (
<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>
</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>
</div>
</Show>
<Modal variant="narrow" name="editorInsertLink">
<InsertLinkForm editor={editor()} onClose={() => hideModal()} />
</Modal>
@ -293,6 +340,13 @@ const SimplifiedEditor = (props: Props) => {
/>
</Modal>
</Show>
<Show when={props.onlyBubbleControls}>
<TextBubbleMenu
isCommonMarkup={true}
editor={editor()}
ref={(el) => (textBubbleMenuRef.current = el)}
/>
</Show>
</div>
)
}

View File

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

View File

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

View File

@ -21,13 +21,13 @@ 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'
type Props = {
shout: Shout
}
export const MAX_HEADER_LIMIT = 100
export const MAX_LEAD_LIMIT = 400
export const EMPTY_TOPIC: Topic = {
id: -1,
slug: ''
@ -64,6 +64,8 @@ export const EditView = (props: Props) => {
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,
@ -75,7 +77,6 @@ export const EditView = (props: Props) => {
}
const subtitleInput: { current: HTMLTextAreaElement } = { current: null }
const leadInput: { current: HTMLTextAreaElement } = { current: null }
const [prevForm, setPrevForm] = createStore<ShoutForm>(clone(form))
const [saving, setSaving] = createSignal(false)
@ -226,7 +227,6 @@ export const EditView = (props: Props) => {
}
const showLeadInput = () => {
setIsLeadVisible(true)
leadInput.current.focus()
}
return (
@ -320,16 +320,13 @@ export const EditView = (props: Props) => {
/>
</Show>
<Show when={isLeadVisible()}>
<GrowingTextarea
textAreaRef={(el) => {
leadInput.current = el
}}
allowEnterKey={true}
value={(value) => setForm('lead', value)}
class={styles.leadInput}
placeholder={t('Description')}
initialValue={form.subtitle}
maxLength={MAX_LEAD_LIMIT}
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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