Feature/article settings refactoring (#158)

* Settings screen
This commit is contained in:
Ilya Y 2023-08-12 10:48:43 +03:00 committed by GitHub
parent f9c30a99cf
commit f14b4a7049
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 579 additions and 361 deletions

View File

@ -0,0 +1,3 @@
<svg width="13" height="14" viewBox="0 0 13 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.64103 13L2 7M2 7L7.64102 1M2 7L13 7" stroke="#141414" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 188 B

View File

@ -2,6 +2,7 @@
"...subscribing": "...subscribing",
"About myself": "About myself",
"About the project": "About the project",
"Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title": "Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title",
"Add another image": "Add another image",
"Add audio": "Add audio",
"Add comment": "Comment",
@ -29,6 +30,7 @@
"Authors": "Authors",
"Autotypograph": "Autotypograph",
"Back": "Back",
"Back to editor": "Back to editor",
"Back to main page": "Back to main page",
"Become an author": "Become an author",
"Bold": "Bold",
@ -48,12 +50,15 @@
"By updates": "By updates",
"By views": "By views",
"Cancel": "Cancel",
"Cancel changes": "Cancel changes",
"Characters": "Знаков",
"Chat Title": "Chat Title",
"Choose a post type": "Choose a post type",
"Choose a title image for the article. You can immediately see how the publication card will look like.": "Choose a title image for the article. You can immediately see how the publication card will look like.",
"Choose who you want to write to": "Choose who you want to write to",
"Collaborate": "Help Edit",
"Come up with a subtitle for your story": "Come up with a subtitle for your story",
"Come up with a title for your story": "Come up with a title for your story",
"Comments": "Comments",
"Communities": "Communities",
"Confirm": "Confirm",
@ -75,6 +80,7 @@
"Date of Birth": "Date of Birth",
"Decline": "Decline",
"Delete": "Delete",
"Delete cover": "Delete cover",
"Description": "Description...",
"Discours": "Discours",
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects",
@ -251,6 +257,7 @@
"Short opening": "Short opening",
"Show": "Show",
"Show lyrics": "Текст песни",
"Slug": "Slug",
"Social networks": "Social networks",
"Something went wrong, check email and password": "Something went wrong. Check your email and password",
"Something went wrong, please try again": "Something went wrong, please try again",
@ -315,6 +322,7 @@
"Work with us": "Cooperate with Discourse",
"Write": "Write",
"Write a comment...": "Write a comment...",
"Write a short introduction": "Write a short introduction",
"Write about the topic": "Write about the topic",
"Write an article": "Write an article",
"Write comment": "Write comment",

View File

@ -4,6 +4,7 @@
"About myself": "О себе",
"About the project": "О проекте",
"Accomplices": "Соучастники",
"Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title": "Добавьте несколько тем, чтобы читатель знал, о чем ваш материал, и мог найти его на страницах интересных ему тем. Темы можно менять местами, первая тема становится заглавной",
"Add another image": "Добавить другое изображение",
"Add audio": "Добавить аудио",
"Add comment": "Комментировать",
@ -33,6 +34,7 @@
"Authors": "Авторы",
"Autotypograph": "Автотипограф",
"Back": "Назад",
"Back to editor": "Вернуться в редактор",
"Back to main page": "Вернуться на главную",
"Become an author": "Стать автором",
"Bold": "Жирный",
@ -52,12 +54,15 @@
"By updates": "По обновлениям",
"By views": "По просмотрам",
"Cancel": "Отмена",
"Cancel changes": "Отменить изменения",
"Characters": "Знаков",
"Chat Title": "Тема дискурса",
"Choose a post type": "Выберите тип публикации",
"Choose a title image for the article. You can immediately see how the publication card will look like.": "Выберите заглавное изображение для статьи. Тут же сразу можно увидеть как будет выглядеть карточка публикации.",
"Choose who you want to write to": "Выберите кому хотите написать",
"Collaborate": "Помочь редактировать",
"Come up with a subtitle for your story": "Придумайте подзаголовок вашей истории",
"Come up with a title for your story": "Придумайте заголовок вашей истории",
"Comments": "Комментарии",
"Communities": "Сообщества",
"Confirm": "Подтвердить",
@ -79,6 +84,7 @@
"Date of Birth": "Дата рождения",
"Decline": "Отмена",
"Delete": "Удалить",
"Delete cover": "Удалить обложку",
"Description": "Описание...",
"Discours": "Дискурс",
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Дискурс — это интеллектуальная среда, веб-пространство и инструменты, которые позволяют авторам сотрудничать с читателями и объединяться для совместного создания публикаций и медиапроектов",
@ -267,6 +273,7 @@
"Short opening": "Небольшое вступление, чтобы заинтересовать читателя",
"Show": "Показать",
"Show lyrics": "Текст песни",
"Slug": "Постоянная ссылка",
"Social networks": "Социальные сети",
"Something went wrong, check email and password": "Что-то пошло не так. Проверьте адрес электронной почты и пароль",
"Something went wrong, please try again": "Что-то пошло не так, попробуйте еще раз",
@ -333,6 +340,7 @@
"Work with us": "Сотрудничать с Дискурсом",
"Write": "Написать",
"Write a comment...": "Написать комментарий...",
"Write a short introduction": "Напишите краткое вступление",
"Write about the topic": "Написать в тему",
"Write an article": "Написать статью",
"Write comment": "Написать комментарий",

View File

@ -2,72 +2,6 @@
position: static;
}
.articlePreview {
border: 2px solid #e8e8e8;
padding: 1rem 1.2rem;
display: flex;
flex-direction: column;
min-height: 300px;
align-items: flex-start;
box-sizing: border-box;
.actions {
display: flex;
gap: 16px;
}
.shoutCardCoverContainer {
position: relative;
width: 100%;
.shoutCardCover {
height: 0;
overflow: hidden;
position: relative;
margin: 1.6rem 0;
padding-bottom: 56.2%;
img {
height: 100%;
object-fit: cover;
position: absolute;
transform-origin: 50% 50%;
transition: transform 1s ease-in-out;
width: 100%;
}
&:hover img {
transform: scale(1.1);
}
}
}
.shoutCardTitle {
@include font-size(2.2rem);
font-weight: 700;
line-height: 1.25;
margin: auto 0 0.8rem;
}
.shoutCardSubtitle {
@include font-size(1.7rem);
color: #696969;
font-weight: 400;
line-height: 1.3;
margin-bottom: 0.8rem;
transition: color 0.2s, background-color 0.2s, box-shadow 0.2s;
}
.shoutAuthor {
@include font-size(1.2rem);
margin-right: 1.6rem;
color: rgb(0 0 0 / 70%);
}
}
.formHolder {
padding: 0 4rem;
}
@ -117,17 +51,6 @@
}
}
// Grow input
.editSettings,
.edit {
display: none;
&.visible {
display: block;
}
}
.asidePanel {
background: #1f1f1f;
color: rgb(255 255 255 / 35%);
@ -272,7 +195,3 @@
background-repeat: no-repeat;
}
}
.topicSelectContainer {
height: 64px;
}

View File

@ -24,10 +24,17 @@ import { LayoutType, MediaItem } from '../../pages/types'
import { clone } from '../../utils/clone'
import deepEqual from 'fast-deep-equal'
import { AutoSaveNotice } from '../Editor/AutoSaveNotice'
import { PublishSettings } from './PublishSettings'
type Props = {
shout: Shout
}
export const EMPTY_TOPIC: Topic = {
id: -1,
slug: ''
}
const AUTO_SAVE_INTERVAL = 5000
const handleScrollTopButtonClick = (e) => {
e.preventDefault()
@ -37,17 +44,9 @@ const handleScrollTopButtonClick = (e) => {
})
}
const EMPTY_TOPIC: Topic = {
id: -1,
slug: ''
}
export const EditView = (props: Props) => {
const { t } = useLocalize()
const { user } = useSession()
const [isScrolled, setIsScrolled] = createSignal(false)
const [topics, setTopics] = createSignal<Topic[]>(null)
const [coverImage, setCoverImage] = createSignal<string>(null)
const { page } = useRouter()
@ -84,11 +83,6 @@ export const EditView = (props: Props) => {
return JSON.parse(form.media || '[]')
})
onMount(async () => {
const allTopics = await apiClient.getAllTopics()
setTopics(allTopics)
})
onMount(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 0)
@ -108,37 +102,6 @@ export const EditView = (props: Props) => {
}
}
const handleSlugInputChange = (e) => {
const slug = e.currentTarget.value
setForm('slug', slug)
}
const handleUploadModalContentCloseSetCover = (imgUrl: string) => {
hideModal()
setCoverImage(imageProxy(imgUrl))
setForm('coverImageUrl', imgUrl)
}
const handleDeleteCoverImage = () => {
setForm('coverImageUrl', '')
setCoverImage(null)
}
const handleTopicSelectChange = (newSelectedTopics) => {
if (newSelectedTopics.length === 0) {
setForm('mainTopic', EMPTY_TOPIC)
} else if (
form.selectedTopics.length === 0 ||
newSelectedTopics.every((topic) => topic.id !== form.mainTopic.id)
) {
setForm('mainTopic', newSelectedTopics[0])
}
if (newSelectedTopics.length > 0) {
setFormErrors('selectedTopics', '')
}
setForm('selectedTopics', newSelectedTopics)
}
const handleAddMedia = (data) => {
const newMedia = [...mediaItems(), ...data]
setForm('media', JSON.stringify(newMedia))
@ -259,247 +222,137 @@ export const EditView = (props: Props) => {
<div class="row">
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
<div
class={clsx(styles.edit, {
[styles.visible]: page().route === 'edit'
})}
>
<div class={clsx({ [styles.audioHeader]: props.shout.layout === 'audio' })}>
<div class={styles.inputContainer}>
<GrowingTextarea
allowEnterKey={true}
value={(value) => handleTitleInputChange(value)}
class={styles.titleInput}
placeholder={articleTitle()}
initialValue={form.title}
maxLength={100}
/>
<Show when={formErrors.title}>
<div class={styles.validationError}>{formErrors.title}</div>
</Show>
<Show when={props.shout.layout === 'audio'}>
<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>
<Show when={props.shout.layout !== 'audio'}>
<Show when={page().route === 'edit'}>
<>
<div class={clsx({ [styles.audioHeader]: props.shout.layout === 'audio' })}>
<div class={styles.inputContainer}>
<GrowingTextarea
allowEnterKey={false}
value={(value) => setForm('subtitle', value)}
class={styles.subtitleInput}
placeholder={t('Subheader')}
initialValue={form.subtitle}
allowEnterKey={true}
value={(value) => handleTitleInputChange(value)}
class={styles.titleInput}
placeholder={articleTitle()}
initialValue={form.title}
maxLength={100}
/>
</Show>
</div>
<Show when={props.shout.layout === 'audio'}>
<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)}
<Show when={formErrors.title}>
<div class={styles.validationError}>{formErrors.title}</div>
</Show>
<Show when={props.shout.layout === 'audio'}>
<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>
<Show when={props.shout.layout !== 'audio'}>
<GrowingTextarea
allowEnterKey={false}
value={(value) => setForm('subtitle', value)}
class={styles.subtitleInput}
placeholder={t('Subheader')}
initialValue={form.subtitle}
maxLength={100}
/>
}
>
<div
class={styles.cover}
style={{ 'background-image': `url(${imageProxy(form.coverImageUrl)})` }}
/>
</Show>
</Show>
</div>
<Show when={props.shout.layout === 'image'}>
<SolidSwiper
editorMode={true}
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>
<Show when={props.shout.layout === 'audio'}>
<AudioUploader
audio={mediaItems()}
baseFields={baseAudioFields()}
onAudioAdd={(value) => handleAddMedia(value)}
onAudioChange={handleMediaChange}
onAudioSorted={(value) => handleSortedMedia(value)}
/>
</Show>
</div>
<div
class={clsx(styles.editSettings, {
[styles.visible]: page().route === 'editSettings'
})}
>
<h1>{t('Publish Settings')}</h1>
<h4>Slug</h4>
<div class="pretty-form__item">
<input
type="text"
name="slug"
id="slug"
value={form.slug}
onChange={handleSlugInputChange}
/>
<label for="slug">Slug</label>
</div>
{/*<h4>Лид</h4>*/}
{/*<div class="pretty-form__item">*/}
{/* <textarea name="lead" id="lead" placeholder="Лид"></textarea>*/}
{/* <label for="lead">Лид</label>*/}
{/*</div>*/}
{/*<h4>Выбор сообщества</h4>*/}
{/*<p class="description">Сообщества можно перечислить через запятую</p>*/}
{/*<div class="pretty-form__item">*/}
{/* <input*/}
{/* type="text"*/}
{/* name="community"*/}
{/* id="community"*/}
{/* placeholder="Сообщества"*/}
{/* class="nolabel"*/}
{/* />*/}
{/*</div>*/}
<h4>{t('Topics')}</h4>
{/*<p class="description">*/}
{/* Добавьте несколько тем, чтобы читатель знал, о&nbsp;чем ваш материал, и&nbsp;мог найти*/}
{/* его на&nbsp;страницах интересных ему тем. Темы можно менять местами, первая тема*/}
{/* становится заглавной*/}
{/*</p>*/}
<div class={styles.inputContainer}>
<div class={clsx('pretty-form__item', styles.topicSelectContainer)}>
<Show when={topics()}>
<TopicSelect
topics={topics()}
onChange={handleTopicSelectChange}
selectedTopics={form.selectedTopics}
onMainTopicChange={(mainTopic) => setForm('mainTopic', mainTopic)}
mainTopic={form.mainTopic}
/>
</Show>
</div>
<Show when={formErrors.selectedTopics}>
<div class={styles.validationError}>{formErrors.selectedTopics}</div>
</Show>
</div>
{/*<h4>Соавторы</h4>*/}
{/*<p class="description">У каждого соавтора можно добавить роль</p>*/}
{/*<div class="pretty-form__item--with-button">*/}
{/* <div class="pretty-form__item">*/}
{/* <input type="text" name="authors" id="authors" placeholder="Введите имя или e-mail" />*/}
{/* <label for="authors">Введите имя или e-mail</label>*/}
{/* </div>*/}
{/* <button class="button button--submit">Добавить</button>*/}
{/*</div>*/}
{/*<div class="row">*/}
{/* <div class="col-md-6">Михаил Драбкин</div>*/}
{/* <div class="col-md-6">*/}
{/* <input type="text" name="coauthor" id="coauthor1" class="nolabel" />*/}
{/* </div>*/}
{/*</div>*/}
<h4>{t('Material card')}</h4>
<p class="description">
{t(
'Choose a title image for the article. You can immediately see how the publication card will look like.'
)}
</p>
<div class={styles.articlePreview}>
<div class={styles.actions}>
<Button
variant="primary"
onClick={() => showModal('uploadCoverImage')}
value={coverImage() || form.coverImageUrl ? t('Add another image') : t('Add image')}
/>
<Show when={coverImage() ?? form.coverImageUrl}>
<Button variant="secondary" onClick={handleDeleteCoverImage} value={t('Delete')} />
</Show>
</div>
<Show when={coverImage() ?? form.coverImageUrl}>
<div class={styles.shoutCardCoverContainer}>
<div class={styles.shoutCardCover}>
<img
src={coverImage() || imageProxy(form.coverImageUrl)}
alt={form.title}
loading="lazy"
/>
</div>
</Show>
</div>
<Show when={props.shout.layout === 'audio'}>
<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(${imageProxy(form.coverImageUrl)})` }}
/>
</Show>
</Show>
</div>
<Show when={props.shout.layout === 'image'}>
<SolidSwiper
editorMode={true}
images={mediaItems()}
onImageChange={handleMediaChange}
onImageDelete={(index) => handleMediaDelete(index)}
onImagesAdd={(value) => handleAddMedia(value)}
onImagesSorted={(value) => handleSortedMedia(value)}
/>
</Show>
<div class={styles.shoutCardTitle}>{form.title}</div>
<div class={styles.shoutCardSubtitle}>{form.subtitle}</div>
<div class={styles.shoutAuthor}>{user().name}</div>
</div>
</div>
<Show when={props.shout.layout === 'video'}>
<VideoUploader
video={mediaItems()}
onVideoAdd={(data) => handleAddMedia(data)}
onVideoDelete={(index) => handleMediaDelete(index)}
/>
</Show>
<Show when={props.shout.layout === 'audio'}>
<AudioUploader
audio={mediaItems()}
baseFields={baseAudioFields()}
onAudioAdd={(value) => handleAddMedia(value)}
onAudioChange={handleMediaChange}
onAudioSorted={(value) => handleSortedMedia(value)}
/>
</Show>
</>
</Show>
<Show when={page().route === 'editSettings'}>
<PublishSettings shoutId={props.shout.id} form={form} />
</Show>
</div>
</div>
<Editor
shoutId={form.shoutId}
initialContent={form.body}
onChange={(body) => setForm('body', body)}
/>
<Show when={page().route === 'edit'}>
<Editor
shoutId={form.shoutId}
initialContent={form.body}
onChange={(body) => setForm('body', body)}
/>
</Show>
</div>
</form>
</div>
<Modal variant="narrow" name="uploadCoverImage">
<UploadModalContent onClose={(value) => handleUploadModalContentCloseSetCover(value)} />
</Modal>
<Panel shoutId={props.shout.id} />
</>
)

View File

@ -0,0 +1,154 @@
.PublishSettings {
.goBack {
@include font-size(1.8rem);
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
line-height: 2.4rem;
}
.inputContainer {
position: relative;
flex: 1;
display: flex;
flex-flow: column;
.validationError {
position: absolute;
z-index: 1;
top: calc(100% + 4px);
font-size: small;
color: var(--danger-color);
}
}
.commonSettings {
display: flex;
flex-direction: column;
gap: 10px;
.settingInput {
&::after,
textarea {
@include font-size(1.6rem);
}
}
}
.articlePreview {
border: 2px solid #e8e8e8;
padding: 1rem 1.2rem;
display: flex;
flex-direction: column;
min-height: 300px;
align-items: flex-start;
box-sizing: border-box;
.actions {
display: flex;
gap: 16px;
}
.shoutCardCoverContainer {
position: relative;
width: 100%;
min-height: 300px;
&.hasImage {
&::before {
content: '';
height: 100%;
position: absolute;
width: 100%;
z-index: 1;
background: linear-gradient(to bottom, rgb(0 0 0 / 0%) 40%, rgb(0 0 0 / 70%) 100%);
}
}
.shoutCardCover {
height: 0;
overflow: hidden;
position: relative;
margin: 1.6rem 0 0;
padding-bottom: 56.2%;
img {
height: 100%;
object-fit: cover;
position: absolute;
transform-origin: 50% 50%;
transition: transform 1s ease-in-out;
width: 100%;
}
&:hover img {
transform: scale(1.1);
}
}
.text {
bottom: 16px;
left: 16px;
right: 16px;
position: absolute;
z-index: 2;
color: var(--default-color);
.mainTopic {
@include font-size(1.4rem);
margin: auto 0 1.6rem;
font-weight: 600;
text-transform: uppercase;
}
.shoutCardTitle {
@include font-size(2.4rem);
font-weight: 700;
line-height: 1.25;
margin-bottom: 0.8rem;
}
.shoutCardSubtitle {
@include font-size(2.2rem);
font-weight: 400;
line-height: 1.3;
margin-bottom: 0.8rem;
transition: color 0.2s, background-color 0.2s, box-shadow 0.2s;
}
.shoutAuthor {
@include font-size(1.6rem);
}
}
&.hasImage .text {
color: var(--default-color-invert);
}
}
}
.topicSelectContainer {
height: 64px;
}
.formActions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-direction: row;
padding: 1rem 0;
gap: 1rem;
margin-top: 80px;
border-top: 2px solid var(--black-100);
.cancel {
margin-right: auto;
}
}
}

View File

@ -0,0 +1,246 @@
import { clsx } from 'clsx'
import styles from './PublishSettings.module.scss'
import { createEffect, createSignal, onMount, Show } from 'solid-js'
import { TopicSelect, UploadModalContent } from '../../Editor'
import { Button } from '../../_shared/Button'
import { hideModal, showModal } from '../../../stores/ui'
import { imageProxy } from '../../../utils/imageProxy'
import { ShoutForm, useEditorContext } from '../../../context/editor'
import { useLocalize } from '../../../context/localize'
import { Modal } from '../../Nav/Modal'
import { Topic } from '../../../graphql/types.gen'
import { apiClient } from '../../../utils/apiClient'
import { EMPTY_TOPIC } from '../Edit'
import { useSession } from '../../../context/session'
import { Icon } from '../../_shared/Icon'
import stylesBeside from '../../Feed/Beside.module.scss'
import { redirectPage } from '@nanostores/router'
import { router } from '../../../stores/router'
import { GrowingTextarea } from '../../_shared/GrowingTextarea'
import { createStore } from 'solid-js/store'
type Props = {
shoutId: number
form: ShoutForm
}
const MAX_LEAD_LIMIT = 400
const shorten = (str: string, maxLen: number) => {
if (str.length <= maxLen) return str
return str.slice(0, Math.max(0, str.lastIndexOf(' ', maxLen))).trim()
}
export const PublishSettings = (props: Props) => {
const { t } = useLocalize()
const { user } = useSession()
const composeLead = () => {
if (!props.form.lead) {
const leadText = props.form.body.replaceAll(/<\/?[^>]+(>|$)/gi, ' ')
return shorten(leadText, MAX_LEAD_LIMIT).trim()
}
return props.form.lead
}
const initialData: Partial<ShoutForm> = {
coverImageUrl: props.form.coverImageUrl,
mainTopic: props.form.mainTopic || EMPTY_TOPIC,
slug: props.form.slug,
title: props.form.title,
subtitle: props.form.subtitle,
lead: composeLead()
}
const {
formErrors,
actions: { setForm, setFormErrors, saveShout, publishShout }
} = useEditorContext()
const [settingsForm, setSettingsForm] = createStore(initialData)
const [topics, setTopics] = createSignal<Topic[]>(null)
const handleUploadModalContentCloseSetCover = (imgUrl: string) => {
hideModal()
setSettingsForm('coverImageUrl', imgUrl)
}
const handleDeleteCoverImage = () => {
setSettingsForm('coverImageUrl', '')
}
const handleTopicSelectChange = (newSelectedTopics) => {
if (
props.form.selectedTopics.length === 0 ||
newSelectedTopics.every((topic) => topic.id !== props.form.mainTopic.id)
) {
setSettingsForm((prev) => {
return {
...prev,
mainTopic: newSelectedTopics[0]
}
})
}
if (newSelectedTopics.length > 0) {
setFormErrors('selectedTopics', '')
}
setForm('selectedTopics', newSelectedTopics)
}
onMount(async () => {
const allTopics = await apiClient.getAllTopics()
setTopics(allTopics)
})
const handleBackClick = () => {
redirectPage(router, 'edit', {
shoutId: props.shoutId.toString()
})
}
const handleCancelClick = () => {
setSettingsForm(initialData)
handleBackClick()
}
const handlePublishSubmit = () => {
publishShout({ ...props.form, ...settingsForm })
}
const handleSaveDraft = () => {
saveShout({ ...props.form, ...settingsForm })
}
return (
<div class={styles.PublishSettings}>
<div>
<button type="button" class={styles.goBack} onClick={handleBackClick}>
<Icon name="arrow-left" class={stylesBeside.icon} />
{t('Back to editor')}
</button>
</div>
<h1>{t('Publish Settings')}</h1>
<h4>{t('Material card')}</h4>
<div class={styles.articlePreview}>
<div class={styles.actions}>
<Button
variant="primary"
onClick={() => showModal('uploadCoverImage')}
value={settingsForm.coverImageUrl ? t('Add another image') : t('Add image')}
/>
<Show when={settingsForm.coverImageUrl}>
<Button variant="secondary" onClick={handleDeleteCoverImage} value={t('Delete cover')} />
</Show>
</div>
<div
class={clsx(styles.shoutCardCoverContainer, { [styles.hasImage]: settingsForm.coverImageUrl })}
>
<Show when={settingsForm.coverImageUrl ?? initialData.coverImageUrl}>
<div class={styles.shoutCardCover}>
<img src={imageProxy(settingsForm.coverImageUrl)} alt={initialData.title} loading="lazy" />
</div>
</Show>
<div class={styles.text}>
<Show when={settingsForm.mainTopic}>
<div class={styles.mainTopic}>{settingsForm.mainTopic.title}</div>
</Show>
<div class={styles.shoutCardTitle}>{settingsForm.title}</div>
<div class={styles.shoutCardSubtitle}>{settingsForm.subtitle}</div>
<div class={styles.shoutAuthor}>{user().name}</div>
</div>
</div>
</div>
<p class="description">
{t(
'Choose a title image for the article. You can immediately see how the publication card will look like.'
)}
</p>
<div class={styles.commonSettings}>
<GrowingTextarea
class={styles.settingInput}
variant="bordered"
placeholder={t('Come up with a title for your story')}
initialValue={settingsForm.title}
value={(value) => setSettingsForm('title', value)}
allowEnterKey={false}
maxLength={100}
/>
<GrowingTextarea
class={styles.settingInput}
variant="bordered"
placeholder={t('Come up with a subtitle for your story')}
initialValue={settingsForm.subtitle}
value={(value) => setSettingsForm('subtitle', value)}
allowEnterKey={false}
maxLength={100}
/>
<GrowingTextarea
class={styles.settingInput}
variant="bordered"
placeholder={t('Write a short introduction')}
initialValue={`${settingsForm.lead}${settingsForm.lead.length > MAX_LEAD_LIMIT - 1 && '...'}`}
value={(value) => setSettingsForm('lead', value)}
allowEnterKey={false}
maxLength={MAX_LEAD_LIMIT}
/>
</div>
<h4>{t('Slug')}</h4>
<div class="pretty-form__item">
<input type="text" name="slug" id="slug" value={settingsForm.slug} />
<label for="slug">{t('Slug')}</label>
</div>
<h4>{t('Topics')}</h4>
<p class="description">
{t(
'Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title'
)}
</p>
<div class={styles.inputContainer}>
<div class={clsx('pretty-form__item', styles.topicSelectContainer)}>
<Show when={topics()}>
<TopicSelect
topics={topics()}
onChange={handleTopicSelectChange}
selectedTopics={props.form.selectedTopics}
onMainTopicChange={(mainTopic) => setForm('mainTopic', mainTopic)}
mainTopic={props.form.mainTopic}
/>
</Show>
</div>
<Show when={formErrors.selectedTopics}>
<div class={styles.validationError}>{formErrors.selectedTopics}</div>
</Show>
</div>
{/*<h4>Соавторы</h4>*/}
{/*<p class="description">У каждого соавтора можно добавить роль</p>*/}
{/*<div class="pretty-form__item--with-button">*/}
{/* <div class="pretty-form__item">*/}
{/* <input type="text" name="authors" id="authors" placeholder="Введите имя или e-mail" />*/}
{/* <label for="authors">Введите имя или e-mail</label>*/}
{/* </div>*/}
{/* <button class="button button--submit">Добавить</button>*/}
{/*</div>*/}
{/*<div class="row">*/}
{/* <div class="col-md-6">Михаил Драбкин</div>*/}
{/* <div class="col-md-6">*/}
{/* <input type="text" name="coauthor" id="coauthor1" class="nolabel" />*/}
{/* </div>*/}
{/*</div>*/}
<div class={styles.formActions}>
<Button
variant="light"
value={t('Cancel changes')}
class={styles.cancel}
onClick={handleCancelClick}
/>
<Button variant="secondary" onClick={handleSaveDraft} value={t('Save draft')} />
<Button onClick={handlePublishSubmit} variant="primary" value={t('Publish')} />
</div>
<Modal variant="narrow" name="uploadCoverImage">
<UploadModalContent onClose={(value) => handleUploadModalContentCloseSetCover(value)} />
</Modal>
</div>
)
}

View File

@ -0,0 +1 @@
export { PublishSettings } from './PublishSettings'

View File

@ -3,6 +3,14 @@
width: 100%;
position: relative;
&.bordered {
box-sizing: border-box;
padding: 16px 12px;
border-radius: 2px;
border: 2px solid var(--black-100);
background: var(--white-500, #fff);
}
.growWrap {
display: grid;
width: 100%;
@ -40,14 +48,16 @@
white-space: pre-wrap;
word-wrap: break-word;
overflow: hidden;
line-height: 1.2;
grid-area: 1 / 1 / 2 / 2;
width: 100%;
padding: 0;
margin: 0;
}
}
.maxLength {
color: #000;
color: var(--default-color);
opacity: 1;
position: absolute;
z-index: 1;
@ -60,7 +70,7 @@
line-height: 1;
user-select: none;
transition: opacity 0.3s ease-in-out;
background: rgb(255 255 255 / 80%);
background: var(--white-500);
&.visible {
opacity: 1;
@ -71,6 +81,13 @@
animation-iteration-count: 2;
}
}
&.bordered .maxLength {
opacity: 1;
font-weight: 500;
border: unset;
bottom: 0;
}
}
@keyframes blink {

View File

@ -1,6 +1,7 @@
import { clsx } from 'clsx'
import styles from './GrowingTextarea.module.scss'
import { createSignal, Show } from 'solid-js'
import { createSignal, Show, Switch } from 'solid-js'
import { style } from 'solid-js/web'
type Props = {
class?: string
@ -9,6 +10,7 @@ type Props = {
value: (string) => void
maxLength?: number
allowEnterKey: boolean
variant?: 'bordered'
}
export const GrowingTextarea = (props: Props) => {
@ -29,7 +31,7 @@ export const GrowingTextarea = (props: Props) => {
}
return (
<div class={clsx(styles.GrowingTextarea)}>
<div class={clsx(styles.GrowingTextarea, { [styles.bordered]: props.variant === 'bordered' })}>
<div class={clsx(styles.growWrap, props.class)} data-replicated-value={value()}>
<textarea
rows={1}
@ -45,14 +47,16 @@ export const GrowingTextarea = (props: Props) => {
onBlur={() => setIsFocused(false)}
/>
</div>
<Show when={props.maxLength && value() && isFocused()}>
<Show when={(props.maxLength && value() && isFocused()) || props.variant === 'bordered'}>
<div
class={clsx(styles.maxLength, {
[styles.visible]: isFocused(),
[styles.limited]: value().length === props.maxLength
})}
>
{`${value().length} / ${props.maxLength}`}
<Show when={props.variant === 'bordered'} fallback={`${value().length} / ${props.maxLength}`}>
{`${props.maxLength - value().length}`}
</Show>
</div>
</Show>
</div>

View File

@ -26,6 +26,7 @@ export type ShoutForm = {
body: string
coverImageUrl: string
media?: string
lead?: string
}
type EditorContextType = {

View File

@ -30,8 +30,10 @@
--blue-link: #2638d9;
// names from figma
--black-50: #f7f7f8;
--black-100: #e9e9ee;
--black-500: #141414;
--black-400: #696969;
--white-500: #fff;
}
[data-editor-dark-mode='true'] {
@ -49,8 +51,10 @@
--editor-bubble-menu-background: #444;
// names from figma
--black-50: #080807;
--black-100: #161611;
--black-500: #ebebeb;
--black-400: #969696;
--white-500: #000;
}
* {