Merge branch 'drafts-2' into 'dev'
[WiP] save, publish See merge request discoursio/discoursio-webapp!67
This commit is contained in:
commit
0ba32d0608
|
@ -271,5 +271,6 @@
|
||||||
"topics": "topics",
|
"topics": "topics",
|
||||||
"user already exist": "user already exists",
|
"user already exist": "user already exists",
|
||||||
"view": "view",
|
"view": "view",
|
||||||
"zine": "zine"
|
"zine": "zine",
|
||||||
|
"Required": "Required"
|
||||||
}
|
}
|
||||||
|
|
|
@ -292,5 +292,6 @@
|
||||||
"topics": "темы",
|
"topics": "темы",
|
||||||
"user already exist": "пользователь уже существует",
|
"user already exist": "пользователь уже существует",
|
||||||
"view": "просмотр",
|
"view": "просмотр",
|
||||||
"zine": "журнал"
|
"zine": "журнал",
|
||||||
|
"Required": "Поле обязательно для заполнения"
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,13 +96,13 @@ export const App = (props: PageProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LocalizeProvider>
|
<LocalizeProvider>
|
||||||
<EditorProvider>
|
<SnackbarProvider>
|
||||||
<SnackbarProvider>
|
<SessionProvider>
|
||||||
<SessionProvider>
|
<EditorProvider>
|
||||||
<Dynamic component={pageComponent()} {...props} />
|
<Dynamic component={pageComponent()} {...props} />
|
||||||
</SessionProvider>
|
</EditorProvider>
|
||||||
</SnackbarProvider>
|
</SessionProvider>
|
||||||
</EditorProvider>
|
</SnackbarProvider>
|
||||||
</LocalizeProvider>
|
</LocalizeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,6 @@ import { CustomImage } from './extensions/CustomImage'
|
||||||
import { Figure } from './extensions/Figure'
|
import { Figure } from './extensions/Figure'
|
||||||
import { Paragraph } from '@tiptap/extension-paragraph'
|
import { Paragraph } from '@tiptap/extension-paragraph'
|
||||||
import Focus from '@tiptap/extension-focus'
|
import Focus from '@tiptap/extension-focus'
|
||||||
import { TrailingNode } from './extensions/TrailingNode'
|
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
|
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
|
||||||
import { Collaboration } from '@tiptap/extension-collaboration'
|
import { Collaboration } from '@tiptap/extension-collaboration'
|
||||||
|
@ -41,6 +40,7 @@ import { ImageBubbleMenu } from './ImageBubbleMenu'
|
||||||
import { EditorFloatingMenu } from './EditorFloatingMenu'
|
import { EditorFloatingMenu } from './EditorFloatingMenu'
|
||||||
import { useEditorContext } from '../../context/editor'
|
import { useEditorContext } from '../../context/editor'
|
||||||
import { isTextSelection } from '@tiptap/core'
|
import { isTextSelection } from '@tiptap/core'
|
||||||
|
import type { Doc } from 'yjs/dist/src/utils/Doc'
|
||||||
import './Prosemirror.scss'
|
import './Prosemirror.scss'
|
||||||
|
|
||||||
type EditorProps = {
|
type EditorProps = {
|
||||||
|
@ -49,7 +49,7 @@ type EditorProps = {
|
||||||
onChange: (text: string) => void
|
onChange: (text: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const yDoc = new Y.Doc()
|
const yDocs: Record<string, Doc> = {}
|
||||||
const persisters: Record<string, IndexeddbPersistence> = {}
|
const persisters: Record<string, IndexeddbPersistence> = {}
|
||||||
const providers: Record<string, HocuspocusProvider> = {}
|
const providers: Record<string, HocuspocusProvider> = {}
|
||||||
|
|
||||||
|
@ -59,17 +59,20 @@ export const Editor = (props: EditorProps) => {
|
||||||
|
|
||||||
const docName = `shout-${props.shoutId}`
|
const docName = `shout-${props.shoutId}`
|
||||||
|
|
||||||
|
if (!yDocs[docName]) {
|
||||||
|
yDocs[docName] = new Y.Doc()
|
||||||
|
}
|
||||||
|
|
||||||
if (!providers[docName]) {
|
if (!providers[docName]) {
|
||||||
providers[docName] = new HocuspocusProvider({
|
providers[docName] = new HocuspocusProvider({
|
||||||
url: 'wss://hocuspocus.discours.io',
|
url: 'wss://hocuspocus.discours.io',
|
||||||
// url: 'ws://localhost:4242',
|
|
||||||
name: docName,
|
name: docName,
|
||||||
document: yDoc
|
document: yDocs[docName]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!persisters[docName]) {
|
if (!persisters[docName]) {
|
||||||
persisters[docName] = new IndexeddbPersistence(docName, yDoc)
|
persisters[docName] = new IndexeddbPersistence(docName, yDocs[docName])
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorElRef: {
|
const editorElRef: {
|
||||||
|
@ -123,7 +126,7 @@ export const Editor = (props: EditorProps) => {
|
||||||
OrderedList,
|
OrderedList,
|
||||||
ListItem,
|
ListItem,
|
||||||
Collaboration.configure({
|
Collaboration.configure({
|
||||||
document: yDoc
|
document: yDocs[docName]
|
||||||
}),
|
}),
|
||||||
CollaborationCursor.configure({
|
CollaborationCursor.configure({
|
||||||
provider: providers[docName],
|
provider: providers[docName],
|
||||||
|
@ -145,7 +148,6 @@ export const Editor = (props: EditorProps) => {
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Figure,
|
Figure,
|
||||||
TrailingNode,
|
|
||||||
Embed,
|
Embed,
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
|
|
|
@ -15,9 +15,9 @@
|
||||||
|
|
||||||
.menuHolder {
|
.menuHolder {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
left: 40px;
|
left: 24px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -0.4rem;
|
top: -2px;
|
||||||
min-width: 64vw;
|
min-width: 64vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { Menu } from './Menu'
|
||||||
import type { MenuItem } from './Menu/Menu'
|
import type { MenuItem } from './Menu/Menu'
|
||||||
import { showModal } from '../../../stores/ui'
|
import { showModal } from '../../../stores/ui'
|
||||||
import { UploadModalContent } from '../UploadModal'
|
import { UploadModalContent } from '../UploadModal'
|
||||||
|
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
|
||||||
|
|
||||||
type FloatingMenuProps = {
|
type FloatingMenuProps = {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
|
@ -57,6 +58,17 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const menuRef: { current: HTMLDivElement } = { current: null }
|
||||||
|
|
||||||
|
useOutsideClickHandler({
|
||||||
|
containerRef: menuRef,
|
||||||
|
handler: () => {
|
||||||
|
if (menuOpen()) {
|
||||||
|
setMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={props.ref} class={styles.editorFloatingMenu}>
|
<div ref={props.ref} class={styles.editorFloatingMenu}>
|
||||||
|
@ -69,7 +81,7 @@ export const EditorFloatingMenu = (props: FloatingMenuProps) => {
|
||||||
<Icon name="editor-plus" />
|
<Icon name="editor-plus" />
|
||||||
</button>
|
</button>
|
||||||
<Show when={menuOpen()}>
|
<Show when={menuOpen()}>
|
||||||
<div class={styles.menuHolder}>
|
<div class={styles.menuHolder} ref={(el) => (menuRef.current = el)}>
|
||||||
<Show when={!selectedMenuItem()}>
|
<Show when={!selectedMenuItem()}>
|
||||||
<Menu selectedItem={(value: MenuItem) => setSelectedMenuItem(value)} />
|
<Menu selectedItem={(value: MenuItem) => setSelectedMenuItem(value)} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
@ -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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
|
@ -11,9 +11,10 @@ import { showModal, useWarningsStore } from '../../stores/ui'
|
||||||
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { useLocalize } from '../../context/localize'
|
import { useLocalize } from '../../context/localize'
|
||||||
import { getPagePath } from '@nanostores/router'
|
import { getPagePath, openPage } from '@nanostores/router'
|
||||||
import { Button } from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
import { useEditorContext } from '../../context/editor'
|
import { useEditorContext } from '../../context/editor'
|
||||||
|
import { apiClient } from '../../utils/apiClient'
|
||||||
|
|
||||||
type HeaderAuthProps = {
|
type HeaderAuthProps = {
|
||||||
setIsProfilePopupVisible: (value: boolean) => void
|
setIsProfilePopupVisible: (value: boolean) => void
|
||||||
|
@ -28,7 +29,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
const { session, isSessionLoaded, isAuthenticated } = useSession()
|
const { session, isSessionLoaded, isAuthenticated } = useSession()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
actions: { toggleEditorPanel }
|
actions: { toggleEditorPanel, saveShout, publishShout }
|
||||||
} = useEditorContext()
|
} = useEditorContext()
|
||||||
|
|
||||||
const toggleWarnings = () => setVisibleWarnings(!visibleWarnings())
|
const toggleWarnings = () => setVisibleWarnings(!visibleWarnings())
|
||||||
|
@ -50,12 +51,19 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
const showNotifications = createMemo(() => isAuthenticated() && !isEditorPage())
|
const showNotifications = createMemo(() => isAuthenticated() && !isEditorPage())
|
||||||
const showSaveButton = createMemo(() => isAuthenticated() && isEditorPage())
|
const showSaveButton = createMemo(() => isAuthenticated() && isEditorPage())
|
||||||
const showCreatePostButton = createMemo(() => isAuthenticated() && !isEditorPage())
|
const showCreatePostButton = createMemo(() => isAuthenticated() && !isEditorPage())
|
||||||
const showAuthenticatedControls = createMemo(() => isAuthenticated() && isEditorPage())
|
const showAuthenticatedControls = createMemo(() => isAuthenticated())
|
||||||
|
|
||||||
const handleBurgerButtonClick = () => {
|
const handleBurgerButtonClick = () => {
|
||||||
toggleEditorPanel()
|
toggleEditorPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSaveButtonClick = async () => {
|
||||||
|
const result = await saveShout()
|
||||||
|
if (result) {
|
||||||
|
openPage(router, 'drafts')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShowOnlyOnClient>
|
<ShowOnlyOnClient>
|
||||||
<Show when={isSessionLoaded()} keyed={true}>
|
<Show when={isSessionLoaded()} keyed={true}>
|
||||||
|
@ -90,6 +98,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
variant={'outline'}
|
variant={'outline'}
|
||||||
|
onClick={handleSaveButtonClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
|
||||||
<a href={getPagePath(router, 'author', { slug: user().slug })}>{t('Profile')}</a>
|
<a href={getPagePath(router, 'author', { slug: user().slug })}>{t('Profile')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">{t('Drafts')}</a>
|
<a href={getPagePath(router, 'drafts')}>{t('Drafts')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">{t('Subscriptions')}</a>
|
<a href="#">{t('Subscriptions')}</a>
|
||||||
|
|
|
@ -132,8 +132,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollTopButtonLabel {
|
.scrollTopButtonLabel {
|
||||||
display: none;
|
|
||||||
@include font-size(1.4rem);
|
@include font-size(1.4rem);
|
||||||
|
|
||||||
|
display: none;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
left: 100%;
|
left: 100%;
|
||||||
padding-left: 0.5em;
|
padding-left: 0.5em;
|
||||||
|
@ -141,3 +142,14 @@
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inputContainer {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.validationError {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
font-size: small;
|
||||||
|
color: #f00;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -6,9 +6,7 @@ import { Title } from '@solidjs/meta'
|
||||||
import type { Shout, Topic } from '../../graphql/types.gen'
|
import type { Shout, Topic } from '../../graphql/types.gen'
|
||||||
import { apiClient } from '../../utils/apiClient'
|
import { apiClient } from '../../utils/apiClient'
|
||||||
import { TopicSelect } from '../Editor/TopicSelect/TopicSelect'
|
import { TopicSelect } from '../Editor/TopicSelect/TopicSelect'
|
||||||
import { router, useRouter } from '../../stores/router'
|
import { useRouter } from '../../stores/router'
|
||||||
import { openPage } from '@nanostores/router'
|
|
||||||
import { translit } from '../../utils/ru2en'
|
|
||||||
import { Editor } from '../Editor/Editor'
|
import { Editor } from '../Editor/Editor'
|
||||||
import { Panel } from '../Editor/Panel'
|
import { Panel } from '../Editor/Panel'
|
||||||
import { useEditorContext } from '../../context/editor'
|
import { useEditorContext } from '../../context/editor'
|
||||||
|
@ -26,16 +24,18 @@ export const EditView = (props: EditViewProps) => {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
form,
|
form,
|
||||||
actions: { setForm }
|
formErrors,
|
||||||
|
actions: { setForm, setFormErrors }
|
||||||
} = useEditorContext()
|
} = useEditorContext()
|
||||||
|
|
||||||
const [isSlugChanged, setIsSlugChanged] = createSignal(false)
|
const [isSlugChanged, setIsSlugChanged] = createSignal(false)
|
||||||
|
|
||||||
setForm({
|
setForm({
|
||||||
|
shoutId: props.shout.id,
|
||||||
slug: props.shout.slug,
|
slug: props.shout.slug,
|
||||||
title: props.shout.title,
|
title: props.shout.title,
|
||||||
subtitle: props.shout.subtitle,
|
subtitle: props.shout.subtitle,
|
||||||
selectedTopics: props.shout.topics,
|
selectedTopics: props.shout.topics || [],
|
||||||
mainTopic: props.shout.mainTopic,
|
mainTopic: props.shout.mainTopic,
|
||||||
body: props.shout.body,
|
body: props.shout.body,
|
||||||
coverImageUrl: props.shout.cover
|
coverImageUrl: props.shout.cover
|
||||||
|
@ -46,22 +46,18 @@ export const EditView = (props: EditViewProps) => {
|
||||||
setTopics(allTopics)
|
setTopics(allTopics)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleFormSubmit = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const article = await apiClient.publishDraft()
|
|
||||||
|
|
||||||
openPage(router, 'article', { slug: article.slug })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTitleInputChange = (e) => {
|
const handleTitleInputChange = (e) => {
|
||||||
const title = e.currentTarget.value
|
const title = e.currentTarget.value
|
||||||
setForm('title', title)
|
setForm('title', title)
|
||||||
|
|
||||||
if (!isSlugChanged()) {
|
if (title) {
|
||||||
const slug = translit(title).replaceAll(' ', '-')
|
setFormErrors('title', '')
|
||||||
setForm('slug', slug)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if (!isSlugChanged()) {
|
||||||
|
// const slug = translit(title).replaceAll(' ', '-')
|
||||||
|
// setForm('slug', slug)
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSlugInputChange = (e) => {
|
const handleSlugInputChange = (e) => {
|
||||||
|
@ -89,8 +85,7 @@ export const EditView = (props: EditViewProps) => {
|
||||||
|
|
||||||
<div class={styles.container}>
|
<div class={styles.container}>
|
||||||
<Title>{t('Write an article')}</Title>
|
<Title>{t('Write an article')}</Title>
|
||||||
|
<form>
|
||||||
<form onSubmit={handleFormSubmit}>
|
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
|
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
|
||||||
|
@ -99,21 +94,28 @@ export const EditView = (props: EditViewProps) => {
|
||||||
[styles.visible]: page().route === 'edit'
|
[styles.visible]: page().route === 'edit'
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<input
|
<div class={styles.inputContainer}>
|
||||||
class={styles.titleInput}
|
<input
|
||||||
type="text"
|
class={styles.titleInput}
|
||||||
name="title"
|
type="text"
|
||||||
id="title"
|
name="title"
|
||||||
placeholder="Заголовок"
|
id="title"
|
||||||
value={form.title}
|
placeholder="Заголовок"
|
||||||
onChange={handleTitleInputChange}
|
autocomplete="off"
|
||||||
/>
|
value={form.title}
|
||||||
|
onInput={handleTitleInputChange}
|
||||||
|
/>
|
||||||
|
<Show when={formErrors.title}>
|
||||||
|
<div class={styles.validationError}>{formErrors.title}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
class={styles.subtitleInput}
|
class={styles.subtitleInput}
|
||||||
type="text"
|
type="text"
|
||||||
name="subtitle"
|
name="subtitle"
|
||||||
id="subtitle"
|
id="subtitle"
|
||||||
|
autocomplete="off"
|
||||||
placeholder="Подзаголовок"
|
placeholder="Подзаголовок"
|
||||||
value={form.subtitle}
|
value={form.subtitle}
|
||||||
onChange={(e) => setForm('subtitle', e.currentTarget.value)}
|
onChange={(e) => setForm('subtitle', e.currentTarget.value)}
|
||||||
|
|
|
@ -2,6 +2,9 @@ import type { JSX } from 'solid-js'
|
||||||
import { Accessor, createContext, createSignal, useContext } from 'solid-js'
|
import { Accessor, createContext, createSignal, useContext } from 'solid-js'
|
||||||
import { createStore, SetStoreFunction } from 'solid-js/store'
|
import { createStore, SetStoreFunction } from 'solid-js/store'
|
||||||
import { Topic } from '../graphql/types.gen'
|
import { Topic } from '../graphql/types.gen'
|
||||||
|
import { apiClient } from '../utils/apiClient'
|
||||||
|
import { useLocalize } from './localize'
|
||||||
|
import { useSnackbar } from './snackbar'
|
||||||
|
|
||||||
type WordCounter = {
|
type WordCounter = {
|
||||||
characters: number
|
characters: number
|
||||||
|
@ -9,6 +12,7 @@ type WordCounter = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShoutForm = {
|
type ShoutForm = {
|
||||||
|
shoutId: number
|
||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
subtitle: string
|
subtitle: string
|
||||||
|
@ -22,10 +26,14 @@ type EditorContextType = {
|
||||||
isEditorPanelVisible: Accessor<boolean>
|
isEditorPanelVisible: Accessor<boolean>
|
||||||
wordCounter: Accessor<WordCounter>
|
wordCounter: Accessor<WordCounter>
|
||||||
form: ShoutForm
|
form: ShoutForm
|
||||||
|
formErrors: Partial<ShoutForm>
|
||||||
actions: {
|
actions: {
|
||||||
|
saveShout: () => Promise<boolean>
|
||||||
|
publishShout: () => Promise<boolean>
|
||||||
toggleEditorPanel: () => void
|
toggleEditorPanel: () => void
|
||||||
countWords: (value: WordCounter) => void
|
countWords: (value: WordCounter) => void
|
||||||
setForm: SetStoreFunction<ShoutForm>
|
setForm: SetStoreFunction<ShoutForm>
|
||||||
|
setFormErrors: SetStoreFunction<Partial<ShoutForm>>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,23 +44,84 @@ export function useEditorContext() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorProvider = (props: { children: JSX.Element }) => {
|
export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
|
const {
|
||||||
|
actions: { showSnackbar }
|
||||||
|
} = useSnackbar()
|
||||||
|
|
||||||
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
|
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
|
||||||
|
|
||||||
const [form, setForm] = createStore<ShoutForm>(null)
|
const [form, setForm] = createStore<ShoutForm>(null)
|
||||||
|
const [formErrors, setFormErrors] = createStore<Partial<ShoutForm>>(null)
|
||||||
|
|
||||||
const [wordCounter, setWordCounter] = createSignal<WordCounter>({
|
const [wordCounter, setWordCounter] = createSignal<WordCounter>({
|
||||||
characters: 0,
|
characters: 0,
|
||||||
words: 0
|
words: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleEditorPanel = () => setIsEditorPanelVisible((value) => !value)
|
const toggleEditorPanel = () => setIsEditorPanelVisible((value) => !value)
|
||||||
const countWords = (value) => setWordCounter(value)
|
const countWords = (value) => setWordCounter(value)
|
||||||
const actions = {
|
|
||||||
toggleEditorPanel,
|
const saveShout = async () => {
|
||||||
countWords,
|
if (!form.title) {
|
||||||
setForm
|
setFormErrors('title', t('Required'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.updateArticle({
|
||||||
|
shoutId: form.shoutId,
|
||||||
|
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 || '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>
|
return <EditorContext.Provider value={value}>{props.children}</EditorContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { gql } from '@urql/core'
|
import { gql } from '@urql/core'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
mutation DeleteShoutMutation($shout: String!) {
|
mutation DeleteShoutMutation($shoutId: Int!) {
|
||||||
deleteShout(slug: $shout) {
|
deleteShout(shout_id: $shoutId) {
|
||||||
error
|
error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { gql } from '@urql/core'
|
import { gql } from '@urql/core'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
mutation UpdateShoutMutation($slug: String!) {
|
mutation PublishShoutMutation($shoutId: Int!, $shoutInput: ShoutInput) {
|
||||||
publishShout(slug: $slug) {
|
publishShout(shout_id: $shoutId, shout_input: $shoutInput) {
|
||||||
error
|
error
|
||||||
shout {
|
shout {
|
||||||
id
|
id
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { gql } from '@urql/core'
|
import { gql } from '@urql/core'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
mutation UpdateShoutMutation($slug: String!, $shout: ShoutInput!) {
|
mutation UpdateShoutMutation($shoutId: Int!, $shoutInput: ShoutInput!) {
|
||||||
updateShout(slug: $slug, inp: $shout) {
|
updateShout(shout_id: $shoutId, shout_input: $shoutInput) {
|
||||||
error
|
error
|
||||||
shout {
|
shout {
|
||||||
id
|
id
|
||||||
|
|
16
src/graphql/mutation/shout-publish.ts
Normal file
16
src/graphql/mutation/shout-publish.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
|
@ -12,7 +12,7 @@ export default gql`
|
||||||
# community
|
# community
|
||||||
mainTopic
|
mainTopic
|
||||||
topics {
|
topics {
|
||||||
# id
|
id
|
||||||
title
|
title
|
||||||
body
|
body
|
||||||
slug
|
slug
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { gql } from '@urql/core'
|
import { gql } from '@urql/core'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
query LoadDraftsQuery($options: LoadShoutsOptions) {
|
query LoadDraftsQuery {
|
||||||
loadDrafts(options: $options) {
|
loadDrafts {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
subtitle
|
subtitle
|
||||||
|
@ -12,7 +12,7 @@ export default gql`
|
||||||
# community
|
# community
|
||||||
mainTopic
|
mainTopic
|
||||||
topics {
|
topics {
|
||||||
# id
|
id
|
||||||
title
|
title
|
||||||
body
|
body
|
||||||
slug
|
slug
|
||||||
|
@ -30,6 +30,12 @@ export default gql`
|
||||||
}
|
}
|
||||||
createdAt
|
createdAt
|
||||||
publishedAt
|
publishedAt
|
||||||
|
stat {
|
||||||
|
viewed
|
||||||
|
reacted
|
||||||
|
rating
|
||||||
|
commented
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
|
@ -228,7 +228,7 @@ export type MutationDeleteReactionArgs = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MutationDeleteShoutArgs = {
|
export type MutationDeleteShoutArgs = {
|
||||||
slug: Scalars['String']
|
shout_id: Scalars['Int']
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MutationDestroyTopicArgs = {
|
export type MutationDestroyTopicArgs = {
|
||||||
|
@ -246,8 +246,8 @@ export type MutationMarkAsReadArgs = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MutationPublishShoutArgs = {
|
export type MutationPublishShoutArgs = {
|
||||||
inp: ShoutInput
|
shout_id: Scalars['Int']
|
||||||
slug: Scalars['String']
|
shout_input?: InputMaybe<ShoutInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MutationRateUserArgs = {
|
export type MutationRateUserArgs = {
|
||||||
|
@ -292,8 +292,8 @@ export type MutationUpdateReactionArgs = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MutationUpdateShoutArgs = {
|
export type MutationUpdateShoutArgs = {
|
||||||
inp: ShoutInput
|
shout_id: Scalars['Int']
|
||||||
slug: Scalars['String']
|
shout_input: ShoutInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MutationUpdateTopicArgs = {
|
export type MutationUpdateTopicArgs = {
|
||||||
|
@ -340,7 +340,6 @@ export type Query = {
|
||||||
loadShouts: Array<Maybe<Shout>>
|
loadShouts: Array<Maybe<Shout>>
|
||||||
markdownBody: Scalars['String']
|
markdownBody: Scalars['String']
|
||||||
myFeed?: Maybe<Array<Maybe<Shout>>>
|
myFeed?: Maybe<Array<Maybe<Shout>>>
|
||||||
publishShout: Array<Maybe<Shout>>
|
|
||||||
searchMessages: Result
|
searchMessages: Result
|
||||||
searchRecipients: Result
|
searchRecipients: Result
|
||||||
signIn: AuthResult
|
signIn: AuthResult
|
||||||
|
@ -377,10 +376,6 @@ export type QueryLoadChatsArgs = {
|
||||||
offset?: InputMaybe<Scalars['Int']>
|
offset?: InputMaybe<Scalars['Int']>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueryLoadDraftsArgs = {
|
|
||||||
options?: InputMaybe<LoadShoutsOptions>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QueryLoadMessagesByArgs = {
|
export type QueryLoadMessagesByArgs = {
|
||||||
by: MessagesBy
|
by: MessagesBy
|
||||||
limit?: InputMaybe<Scalars['Int']>
|
limit?: InputMaybe<Scalars['Int']>
|
||||||
|
@ -414,10 +409,6 @@ export type QueryMyFeedArgs = {
|
||||||
options?: InputMaybe<LoadShoutsOptions>
|
options?: InputMaybe<LoadShoutsOptions>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueryPublishShoutArgs = {
|
|
||||||
slug: Scalars['String']
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuerySearchMessagesArgs = {
|
export type QuerySearchMessagesArgs = {
|
||||||
by: MessagesBy
|
by: MessagesBy
|
||||||
limit?: InputMaybe<Scalars['Int']>
|
limit?: InputMaybe<Scalars['Int']>
|
||||||
|
|
|
@ -4,10 +4,14 @@ import type { PageProps } from './types'
|
||||||
import { createSignal, onMount, Show } from 'solid-js'
|
import { createSignal, onMount, Show } from 'solid-js'
|
||||||
import { loadAllAuthors } from '../stores/zine/authors'
|
import { loadAllAuthors } from '../stores/zine/authors'
|
||||||
import { Loading } from '../components/_shared/Loading'
|
import { Loading } from '../components/_shared/Loading'
|
||||||
|
import { Title } from '@solidjs/meta'
|
||||||
|
import { useLocalize } from '../context/localize'
|
||||||
|
|
||||||
export const AllAuthorsPage = (props: PageProps) => {
|
export const AllAuthorsPage = (props: PageProps) => {
|
||||||
const [isLoaded, setIsLoaded] = createSignal<boolean>(Boolean(props.allAuthors))
|
const [isLoaded, setIsLoaded] = createSignal<boolean>(Boolean(props.allAuthors))
|
||||||
|
|
||||||
|
const { t } = useLocalize()
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (isLoaded()) {
|
if (isLoaded()) {
|
||||||
return
|
return
|
||||||
|
@ -19,6 +23,7 @@ export const AllAuthorsPage = (props: PageProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
|
<Title>{t('Authors')}</Title>
|
||||||
<Show when={isLoaded()} fallback={<Loading />}>
|
<Show when={isLoaded()} fallback={<Loading />}>
|
||||||
<AllAuthorsView authors={props.allAuthors} />
|
<AllAuthorsView authors={props.allAuthors} />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
@ -3,6 +3,8 @@ import { PageLayout } from '../components/_shared/PageLayout'
|
||||||
import { useSession } from '../context/session'
|
import { useSession } from '../context/session'
|
||||||
import { Shout } from '../graphql/types.gen'
|
import { Shout } from '../graphql/types.gen'
|
||||||
import { apiClient } from '../utils/apiClient'
|
import { apiClient } from '../utils/apiClient'
|
||||||
|
import { getPagePath } from '@nanostores/router'
|
||||||
|
import { router } from '../stores/router'
|
||||||
|
|
||||||
export const DraftsPage = () => {
|
export const DraftsPage = () => {
|
||||||
const { isAuthenticated, isSessionLoaded, user } = useSession()
|
const { isAuthenticated, isSessionLoaded, user } = useSession()
|
||||||
|
@ -10,22 +12,28 @@ export const DraftsPage = () => {
|
||||||
const [drafts, setDrafts] = createSignal<Shout[]>([])
|
const [drafts, setDrafts] = createSignal<Shout[]>([])
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const loadedDrafts = await apiClient.getShouts({
|
const loadedDrafts = await apiClient.getDrafts()
|
||||||
filters: {
|
|
||||||
author: user().slug,
|
|
||||||
visibility: 'owner'
|
|
||||||
},
|
|
||||||
limit: 9999
|
|
||||||
})
|
|
||||||
setDrafts(loadedDrafts)
|
setDrafts(loadedDrafts)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<Show when={isSessionLoaded()}>
|
<Show when={isSessionLoaded()}>
|
||||||
<Show when={isAuthenticated()} fallback="Давайте авторизуемся">
|
<div class="wide-container">
|
||||||
<For each={drafts()}>{(draft) => <div>{draft.title}</div>}</For>
|
<div class="row">
|
||||||
</Show>
|
<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>
|
</Show>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
|
|
|
@ -46,11 +46,13 @@ import createChat from '../graphql/mutation/create-chat'
|
||||||
import reactionsLoadBy from '../graphql/query/reactions-load-by'
|
import reactionsLoadBy from '../graphql/query/reactions-load-by'
|
||||||
import authorsLoadBy from '../graphql/query/authors-load-by'
|
import authorsLoadBy from '../graphql/query/authors-load-by'
|
||||||
import shoutsLoadBy from '../graphql/query/articles-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 shoutLoad from '../graphql/query/article-load'
|
||||||
import loadRecipients from '../graphql/query/chat-recipients'
|
import loadRecipients from '../graphql/query/chat-recipients'
|
||||||
import createMessage from '../graphql/mutation/create-chat-message'
|
import createMessage from '../graphql/mutation/create-chat-message'
|
||||||
import updateProfile from '../graphql/mutation/update-profile'
|
import updateProfile from '../graphql/mutation/update-profile'
|
||||||
import updateArticle from '../graphql/mutation/article-update'
|
import updateArticle from '../graphql/mutation/article-update'
|
||||||
|
import publishShout from '../graphql/mutation/shout-publish'
|
||||||
|
|
||||||
type ApiErrorCode =
|
type ApiErrorCode =
|
||||||
| 'unknown'
|
| 'unknown'
|
||||||
|
@ -246,41 +248,29 @@ export const apiClient = {
|
||||||
console.debug('[createArticle]:', response.data)
|
console.debug('[createArticle]:', response.data)
|
||||||
return response.data.createShout.shout
|
return response.data.createShout.shout
|
||||||
},
|
},
|
||||||
updateArticle: async ({ slug, article }: { slug: string; article: ShoutInput }): Promise<Shout> => {
|
updateArticle: async ({
|
||||||
const response = await privateGraphQLClient
|
shoutId,
|
||||||
.mutation(updateArticle, { slug, shout: article })
|
shoutInput
|
||||||
.toPromise()
|
}: {
|
||||||
|
shoutId: number
|
||||||
|
shoutInput: ShoutInput
|
||||||
|
}): Promise<Shout> => {
|
||||||
|
const response = await privateGraphQLClient.mutation(updateArticle, { shoutId, shoutInput }).toPromise()
|
||||||
console.debug('[updateArticle]:', response.data)
|
console.debug('[updateArticle]:', response.data)
|
||||||
return response.data.updateArticle.shout
|
return response.data.updateShout.shout
|
||||||
},
|
},
|
||||||
publishDraft: async (): Promise<Shout> => {
|
publishShout: async ({ slug, shoutInput }: { slug: string; shoutInput: ShoutInput }): Promise<Shout> => {
|
||||||
console.log('publishDraft')
|
const response = await privateGraphQLClient
|
||||||
return {
|
.mutation(publishShout, { slug, shout: shoutInput })
|
||||||
authors: undefined,
|
.toPromise()
|
||||||
body: '',
|
console.debug('[publishShout]:', response)
|
||||||
community: '',
|
return response.data.publishShout.shout
|
||||||
cover: '',
|
},
|
||||||
createdAt: undefined,
|
getDrafts: async (): Promise<Shout[]> => {
|
||||||
deletedAt: undefined,
|
const response = await privateGraphQLClient.query(draftsLoad, {}).toPromise()
|
||||||
deletedBy: undefined,
|
console.debug('[getDrafts]:', response)
|
||||||
id: 0,
|
return response.data.loadDrafts
|
||||||
lang: '',
|
|
||||||
layout: '',
|
|
||||||
mainTopic: '',
|
|
||||||
media: '',
|
|
||||||
publishedAt: undefined,
|
|
||||||
slug: '',
|
|
||||||
stat: undefined,
|
|
||||||
subtitle: '',
|
|
||||||
title: '',
|
|
||||||
topics: undefined,
|
|
||||||
updatedAt: undefined,
|
|
||||||
updatedBy: undefined,
|
|
||||||
versionOf: '',
|
|
||||||
visibility: ''
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
createReaction: async (input: ReactionInput) => {
|
createReaction: async (input: ReactionInput) => {
|
||||||
const response = await privateGraphQLClient.mutation(reactionCreate, { reaction: input }).toPromise()
|
const response = await privateGraphQLClient.mutation(reactionCreate, { reaction: input }).toPromise()
|
||||||
console.debug('[createReaction]:', response)
|
console.debug('[createReaction]:', response)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user