shout save

This commit is contained in:
bniwredyc 2023-05-05 22:05:50 +02:00
parent 20dde76aab
commit a84bc48910
18 changed files with 201 additions and 167 deletions

View File

@ -270,5 +270,6 @@
"topics": "topics",
"user already exist": "user already exists",
"view": "view",
"zine": "zine"
"zine": "zine",
"Required": "Required"
}

View File

@ -291,5 +291,6 @@
"topics": "темы",
"user already exist": "пользователь уже существует",
"view": "просмотр",
"zine": "журнал"
"zine": "журнал",
"Required": "Поле обязательно для заполнения"
}

View File

@ -96,13 +96,13 @@ export const App = (props: PageProps) => {
return (
<LocalizeProvider>
<EditorProvider>
<SnackbarProvider>
<SessionProvider>
<SnackbarProvider>
<SessionProvider>
<EditorProvider>
<Dynamic component={pageComponent()} {...props} />
</SessionProvider>
</SnackbarProvider>
</EditorProvider>
</EditorProvider>
</SessionProvider>
</SnackbarProvider>
</LocalizeProvider>
)
}

View File

@ -25,7 +25,6 @@ import { Text } from '@tiptap/extension-text'
import { Image } from '@tiptap/extension-image'
import { Paragraph } from '@tiptap/extension-paragraph'
import Focus from '@tiptap/extension-focus'
import { TrailingNode } from './extensions/TrailingNode'
import * as Y from 'yjs'
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
import { Collaboration } from '@tiptap/extension-collaboration'
@ -40,6 +39,7 @@ import { ImageBubbleMenu } from './ImageBubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu'
import { useEditorContext } from '../../context/editor'
import { isTextSelection } from '@tiptap/core'
import type { Doc } from 'yjs/dist/src/utils/Doc'
type EditorProps = {
shoutId: number
@ -47,7 +47,7 @@ type EditorProps = {
onChange: (text: string) => void
}
const yDoc = new Y.Doc()
const yDocs: Record<string, Doc> = {}
const persisters: Record<string, IndexeddbPersistence> = {}
const providers: Record<string, HocuspocusProvider> = {}
@ -57,17 +57,20 @@ export const Editor = (props: EditorProps) => {
const docName = `shout-${props.shoutId}`
if (!yDocs[docName]) {
yDocs[docName] = new Y.Doc()
}
if (!providers[docName]) {
providers[docName] = new HocuspocusProvider({
url: 'wss://hocuspocus.discours.io',
// url: 'ws://localhost:4242',
name: docName,
document: yDoc
document: yDocs[docName]
})
}
if (!persisters[docName]) {
persisters[docName] = new IndexeddbPersistence(docName, yDoc)
persisters[docName] = new IndexeddbPersistence(docName, yDocs[docName])
}
const editorElRef: {
@ -121,7 +124,7 @@ export const Editor = (props: EditorProps) => {
OrderedList,
ListItem,
Collaboration.configure({
document: yDoc
document: yDocs[docName]
}),
CollaborationCursor.configure({
provider: providers[docName],
@ -142,9 +145,7 @@ export const Editor = (props: EditorProps) => {
class: 'uploadedImage'
}
}),
TrailingNode,
Embed,
TrailingNode,
CharacterCount,
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
@ -155,7 +156,7 @@ export const Editor = (props: EditorProps) => {
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
return !(!view.hasFocus() || empty || isEmptyTextBlock || e.isActive('image'))
return view.hasFocus() && !empty && !isEmptyTextBlock && !e.isActive('image')
}
}),
BubbleMenu.configure({

View File

@ -15,9 +15,9 @@
.menuHolder {
background: #fff;
left: 40px;
left: 24px;
position: absolute;
top: -0.4rem;
top: -2px;
min-width: 64vw;
}
}

View File

@ -10,6 +10,7 @@ import { Menu } from './Menu'
import type { MenuItem } from './Menu/Menu'
import { showModal } from '../../../stores/ui'
import { UploadModalContent } from '../UploadModal'
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
type FloatingMenuProps = {
editor: Editor
@ -57,6 +58,17 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
setMenuOpen(false)
}
const menuRef: { current: HTMLDivElement } = { current: null }
useOutsideClickHandler({
containerRef: menuRef,
handler: () => {
if (menuOpen()) {
setMenuOpen(false)
}
}
})
return (
<>
<div ref={props.ref} class={styles.editorFloatingMenu}>
@ -69,7 +81,7 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
<Icon name="editor-plus" />
</button>
<Show when={menuOpen()}>
<div class={styles.menuHolder}>
<div class={styles.menuHolder} ref={(el) => (menuRef.current = el)}>
<Show when={!selectedMenuItem()}>
<Menu selectedItem={(value: MenuItem) => setSelectedMenuItem(value)} />
</Show>

View File

@ -1,69 +0,0 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
function nodeEqualsType({ types, node }) {
return (Array.isArray(types) && types.includes(node.type)) || node.type === types
}
/**
* Extension based on:
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
*/
export interface TrailingNodeOptions {
node: string
notAfter: string[]
}
export const TrailingNode = Extension.create<TrailingNodeOptions>({
name: 'trailingNode',
addOptions() {
return {
node: 'paragraph',
notAfter: ['paragraph']
}
},
addProseMirrorPlugins() {
const plugin = new PluginKey(this.name)
const disabledNodes = Object.entries(this.editor.schema.nodes)
.map(([, value]) => value)
.filter((node) => this.options.notAfter.includes(node.name))
return [
new Plugin({
key: plugin,
appendTransaction: (_, __, state) => {
const { doc, tr, schema } = state
const shouldInsertNodeAtEnd = plugin.getState(state)
const endPosition = doc.content.size
const type = schema.nodes[this.options.node]
if (!shouldInsertNodeAtEnd) {
return
}
return tr.insert(endPosition, type.create())
},
state: {
init: (_, state) => {
const lastNode = state.tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
},
apply: (tr, value) => {
if (!tr.docChanged) {
return value
}
const lastNode = tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
}
}
})
]
}
})

View File

@ -11,9 +11,10 @@ import { showModal, useWarningsStore } from '../../stores/ui'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { useSession } from '../../context/session'
import { useLocalize } from '../../context/localize'
import { getPagePath } from '@nanostores/router'
import { getPagePath, openPage } from '@nanostores/router'
import { Button } from '../_shared/Button'
import { useEditorContext } from '../../context/editor'
import { apiClient } from '../../utils/apiClient'
type HeaderAuthProps = {
setIsProfilePopupVisible: (value: boolean) => void
@ -28,7 +29,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
const { session, isSessionLoaded, isAuthenticated } = useSession()
const {
actions: { toggleEditorPanel }
actions: { toggleEditorPanel, saveShout, publishShout }
} = useEditorContext()
const toggleWarnings = () => setVisibleWarnings(!visibleWarnings())
@ -50,12 +51,19 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
const showNotifications = createMemo(() => isAuthenticated() && !isEditorPage())
const showSaveButton = createMemo(() => isAuthenticated() && isEditorPage())
const showCreatePostButton = createMemo(() => isAuthenticated() && !isEditorPage())
const showAuthenticatedControls = createMemo(() => isAuthenticated() && isEditorPage())
const showAuthenticatedControls = createMemo(() => isAuthenticated())
const handleBurgerButtonClick = () => {
toggleEditorPanel()
}
const handleSaveButtonClick = async () => {
const result = await saveShout()
if (result) {
openPage(router, 'drafts')
}
}
return (
<ShowOnlyOnClient>
<Show when={isSessionLoaded()} keyed={true}>
@ -90,6 +98,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
</>
}
variant={'outline'}
onClick={handleSaveButtonClick}
/>
</div>

View File

@ -23,7 +23,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
<a href={getPagePath(router, 'author', { slug: user().slug })}>{t('Profile')}</a>
</li>
<li>
<a href="#">{t('Drafts')}</a>
<a href={getPagePath(router, 'drafts')}>{t('Drafts')}</a>
</li>
<li>
<a href="#">{t('Subscriptions')}</a>

View File

@ -26,7 +26,8 @@ export const EditView = (props: EditViewProps) => {
const {
form,
actions: { setForm }
formErrors,
actions: { setForm, setFormErrors }
} = useEditorContext()
const [isSlugChanged, setIsSlugChanged] = createSignal(false)
@ -35,7 +36,7 @@ export const EditView = (props: EditViewProps) => {
slug: props.shout.slug,
title: props.shout.title,
subtitle: props.shout.subtitle,
selectedTopics: props.shout.topics,
selectedTopics: props.shout.topics || [],
mainTopic: props.shout.mainTopic,
body: props.shout.body,
coverImageUrl: props.shout.cover
@ -46,22 +47,18 @@ export const EditView = (props: EditViewProps) => {
setTopics(allTopics)
})
const handleFormSubmit = async (e) => {
e.preventDefault()
const article = await apiClient.publishDraft()
openPage(router, 'article', { slug: article.slug })
}
const handleTitleInputChange = (e) => {
const title = e.currentTarget.value
setForm('title', title)
if (!isSlugChanged()) {
const slug = translit(title).replaceAll(' ', '-')
setForm('slug', slug)
if (title) {
setFormErrors('title', '')
}
// if (!isSlugChanged()) {
// const slug = translit(title).replaceAll(' ', '-')
// setForm('slug', slug)
// }
}
const handleSlugInputChange = (e) => {
@ -77,8 +74,7 @@ export const EditView = (props: EditViewProps) => {
<>
<div class={styles.container}>
<Title>{t('Write an article')}</Title>
<form onSubmit={handleFormSubmit}>
<form>
<div class="wide-container">
<div class="row">
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
@ -93,15 +89,18 @@ export const EditView = (props: EditViewProps) => {
name="title"
id="title"
placeholder="Заголовок"
autocomplete="off"
value={form.title}
onChange={handleTitleInputChange}
onInput={handleTitleInputChange}
/>
<Show when={formErrors.title}>{formErrors.title}</Show>
<input
class={styles.subtitleInput}
type="text"
name="subtitle"
id="subtitle"
autocomplete="off"
placeholder="Подзаголовок"
value={form.subtitle}
onChange={(e) => setForm('subtitle', e.currentTarget.value)}

View File

@ -1,7 +1,10 @@
import type { JSX } from 'solid-js'
import { Accessor, createContext, createSignal, useContext } from 'solid-js'
import { Accessor, createContext, createEffect, createSignal, useContext } from 'solid-js'
import { createStore, SetStoreFunction } from 'solid-js/store'
import { Topic } from '../graphql/types.gen'
import { InputMaybe, Scalars, Shout, Topic } from '../graphql/types.gen'
import { apiClient } from '../utils/apiClient'
import { useLocalize } from './localize'
import { useSnackbar } from './snackbar'
type WordCounter = {
characters: number
@ -22,10 +25,14 @@ type EditorContextType = {
isEditorPanelVisible: Accessor<boolean>
wordCounter: Accessor<WordCounter>
form: ShoutForm
formErrors: Partial<ShoutForm>
actions: {
saveShout: () => Promise<boolean>
publishShout: () => Promise<boolean>
toggleEditorPanel: () => void
countWords: (value: WordCounter) => void
setForm: SetStoreFunction<ShoutForm>
setFormErrors: SetStoreFunction<Partial<ShoutForm>>
}
}
@ -36,23 +43,84 @@ export function useEditorContext() {
}
export const EditorProvider = (props: { children: JSX.Element }) => {
const { t } = useLocalize()
const {
actions: { showSnackbar }
} = useSnackbar()
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
const [form, setForm] = createStore<ShoutForm>(null)
const [formErrors, setFormErrors] = createStore<Partial<ShoutForm>>(null)
const [wordCounter, setWordCounter] = createSignal<WordCounter>({
characters: 0,
words: 0
})
const toggleEditorPanel = () => setIsEditorPanelVisible((value) => !value)
const countWords = (value) => setWordCounter(value)
const actions = {
toggleEditorPanel,
countWords,
setForm
const saveShout = async () => {
if (!form.title) {
setFormErrors('title', t('Required'))
return false
}
try {
await apiClient.updateArticle({
slug: form.slug,
article: {
body: form.body,
topics: form.selectedTopics.map((topic) => topic.slug),
// authors?: InputMaybe<Array<InputMaybe<Scalars['String']>>>
// community?: InputMaybe<Scalars['Int']>
mainTopic: form.selectedTopics[0]?.slug || 'society',
slug: form.slug,
subtitle: form.subtitle,
title: form.title
}
})
return true
} catch (error) {
console.error(error)
showSnackbar({ type: 'error', body: t('Error') })
return false
}
}
const value: EditorContextType = { actions, form, isEditorPanelVisible, wordCounter }
const publishShout = async () => {
try {
await apiClient.publishShout({
slug: form.slug,
shoutInput: {
body: form.body,
topics: form.selectedTopics.map((topic) => topic.slug),
// authors?: InputMaybe<Array<InputMaybe<Scalars['String']>>>
// community?: InputMaybe<Scalars['Int']>
mainTopic: form.selectedTopics[0]?.slug || '',
slug: form.slug,
subtitle: form.subtitle,
title: form.title
}
})
return true
} catch {
showSnackbar({ type: 'error', body: t('Error') })
return false
}
}
const actions = {
saveShout,
publishShout,
toggleEditorPanel,
countWords,
setForm,
setFormErrors
}
const value: EditorContextType = { actions, form, formErrors, isEditorPanelVisible, wordCounter }
return <EditorContext.Provider value={value}>{props.children}</EditorContext.Provider>
}

View File

@ -0,0 +1,16 @@
import { gql } from '@urql/core'
export default gql`
mutation PublishShoutMutation($slug: String!, $shout: ShoutInput!) {
publishShout(slug: $slug, inp: $shout) {
error
shout {
id
slug
title
subtitle
body
}
}
}
`

View File

@ -12,7 +12,7 @@ export default gql`
# community
mainTopic
topics {
# id
id
title
body
slug

View File

@ -1,8 +1,8 @@
import { gql } from '@urql/core'
export default gql`
query LoadDraftsQuery($options: LoadShoutsOptions) {
loadDrafts(options: $options) {
query LoadDraftsQuery {
loadDrafts {
id
title
subtitle
@ -12,7 +12,7 @@ export default gql`
# community
mainTopic
topics {
# id
id
title
body
slug
@ -30,6 +30,12 @@ export default gql`
}
createdAt
publishedAt
stat {
viewed
reacted
rating
commented
}
}
}
`

View File

@ -340,7 +340,6 @@ export type Query = {
loadShouts: Array<Maybe<Shout>>
markdownBody: Scalars['String']
myFeed?: Maybe<Array<Maybe<Shout>>>
publishShout: Array<Maybe<Shout>>
searchMessages: Result
searchRecipients: Result
signIn: AuthResult
@ -377,10 +376,6 @@ export type QueryLoadChatsArgs = {
offset?: InputMaybe<Scalars['Int']>
}
export type QueryLoadDraftsArgs = {
options?: InputMaybe<LoadShoutsOptions>
}
export type QueryLoadMessagesByArgs = {
by: MessagesBy
limit?: InputMaybe<Scalars['Int']>
@ -414,10 +409,6 @@ export type QueryMyFeedArgs = {
options?: InputMaybe<LoadShoutsOptions>
}
export type QueryPublishShoutArgs = {
slug: Scalars['String']
}
export type QuerySearchMessagesArgs = {
by: MessagesBy
limit?: InputMaybe<Scalars['Int']>

View File

@ -4,10 +4,14 @@ import type { PageProps } from './types'
import { createSignal, onMount, Show } from 'solid-js'
import { loadAllAuthors } from '../stores/zine/authors'
import { Loading } from '../components/_shared/Loading'
import { Title } from '@solidjs/meta'
import { useLocalize } from '../context/localize'
export const AllAuthorsPage = (props: PageProps) => {
const [isLoaded, setIsLoaded] = createSignal<boolean>(Boolean(props.allAuthors))
const { t } = useLocalize()
onMount(async () => {
if (isLoaded()) {
return
@ -19,6 +23,7 @@ export const AllAuthorsPage = (props: PageProps) => {
return (
<PageLayout>
<Title>{t('Authors')}</Title>
<Show when={isLoaded()} fallback={<Loading />}>
<AllAuthorsView authors={props.allAuthors} />
</Show>

View File

@ -3,6 +3,8 @@ import { PageLayout } from '../components/_shared/PageLayout'
import { useSession } from '../context/session'
import { Shout } from '../graphql/types.gen'
import { apiClient } from '../utils/apiClient'
import { getPagePath } from '@nanostores/router'
import { router } from '../stores/router'
export const DraftsPage = () => {
const { isAuthenticated, isSessionLoaded, user } = useSession()
@ -10,22 +12,28 @@ export const DraftsPage = () => {
const [drafts, setDrafts] = createSignal<Shout[]>([])
onMount(async () => {
const loadedDrafts = await apiClient.getShouts({
filters: {
author: user().slug,
visibility: 'owner'
},
limit: 9999
})
const loadedDrafts = await apiClient.getDrafts()
setDrafts(loadedDrafts)
})
return (
<PageLayout>
<Show when={isSessionLoaded()}>
<Show when={isAuthenticated()} fallback="Давайте авторизуемся">
<For each={drafts()}>{(draft) => <div>{draft.title}</div>}</For>
</Show>
<div class="wide-container">
<div class="row">
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
<Show when={isAuthenticated()} fallback="Давайте авторизуемся">
<For each={drafts()}>
{(draft) => (
<div>
<a href={getPagePath(router, 'edit', { shoutSlug: draft.slug })}>{draft.id}</a>
</div>
)}
</For>
</Show>
</div>
</div>
</div>
</Show>
</PageLayout>
)

View File

@ -46,11 +46,13 @@ import createChat from '../graphql/mutation/create-chat'
import reactionsLoadBy from '../graphql/query/reactions-load-by'
import authorsLoadBy from '../graphql/query/authors-load-by'
import shoutsLoadBy from '../graphql/query/articles-load-by'
import draftsLoad from '../graphql/query/drafts-load'
import shoutLoad from '../graphql/query/article-load'
import loadRecipients from '../graphql/query/chat-recipients'
import createMessage from '../graphql/mutation/create-chat-message'
import updateProfile from '../graphql/mutation/update-profile'
import updateArticle from '../graphql/mutation/article-update'
import publishShout from '../graphql/mutation/shout-publish'
type ApiErrorCode =
| 'unknown'
@ -251,36 +253,20 @@ export const apiClient = {
.mutation(updateArticle, { slug, shout: article })
.toPromise()
console.debug('[updateArticle]:', response.data)
return response.data.updateArticle.shout
return response.data.updateShout.shout
},
publishDraft: async (): Promise<Shout> => {
console.log('publishDraft')
return {
authors: undefined,
body: '',
community: '',
cover: '',
createdAt: undefined,
deletedAt: undefined,
deletedBy: undefined,
id: 0,
lang: '',
layout: '',
mainTopic: '',
media: '',
publishedAt: undefined,
slug: '',
stat: undefined,
subtitle: '',
title: '',
topics: undefined,
updatedAt: undefined,
updatedBy: undefined,
versionOf: '',
visibility: ''
}
publishShout: async ({ slug, shoutInput }: { slug: string; shoutInput: ShoutInput }): Promise<Shout> => {
const response = await privateGraphQLClient
.mutation(publishShout, { slug, shout: shoutInput })
.toPromise()
console.debug('[publishShout]:', response)
return response.data.publishShout.shout
},
getDrafts: async (): Promise<Shout[]> => {
const response = await privateGraphQLClient.query(draftsLoad, {}).toPromise()
console.debug('[getDrafts]:', response)
return response.data.loadDrafts
},
createReaction: async (input: ReactionInput) => {
const response = await privateGraphQLClient.mutation(reactionCreate, { reaction: input }).toPromise()
console.debug('[createReaction]:', response)