abc-sort-fix

This commit is contained in:
Untone 2024-09-16 02:41:48 +03:00
parent 433a74a58a
commit 6e0a830168
18 changed files with 442 additions and 261 deletions

View File

@ -158,11 +158,11 @@ export const CommentsTree = (props: Props) => {
<SimplifiedEditor <SimplifiedEditor
quoteEnabled={true} quoteEnabled={true}
imageEnabled={true} imageEnabled={true}
options={{ autofocus: false }} autoFocus={false}
submitByCtrlEnter={true} submitByCtrlEnter={true}
placeholder={t('Write a comment...')} placeholder={t('Write a comment...')}
onSubmit={(value) => handleSubmitComment(value)} onSubmit={(value) => handleSubmitComment(value)}
reset={clearEditor()} setClear={clearEditor()}
isPosting={posting()} isPosting={posting()}
/> />
</ShowIfAuthenticated> </ShowIfAuthenticated>

View File

@ -0,0 +1,162 @@
import { Editor } from '@tiptap/core'
import { createSignal } from 'solid-js'
import { createStore } from 'solid-js/store'
import { Meta, StoryObj } from 'storybook-solidjs'
import { EditorContext, EditorContextType, ShoutForm } from '~/context/editor'
import { LocalizeContext, LocalizeContextType } from '~/context/localize'
import { SessionContext, SessionContextType } from '~/context/session'
import { SnackbarContext, SnackbarContextType } from '~/context/ui'
import { EditorComponent } from './Editor'
// Mock any necessary data
const mockSession = {
session: () => ({
user: {
app_data: {
profile: {
name: 'Test User',
slug: 'test-user'
}
}
},
access_token: 'mock-access-token'
})
}
const mockLocalize = {
t: (key: string) => key,
lang: () => 'en'
}
const [_form, setForm] = createStore<ShoutForm>({
body: '',
slug: '',
shoutId: 0,
title: '',
selectedTopics: []
})
const [_formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>)
const [editor, setEditor] = createSignal<Editor | undefined>()
const mockEditorContext: EditorContextType = {
countWords: () => 0,
isEditorPanelVisible: (): boolean => {
throw new Error('Function not implemented.')
},
wordCounter: (): { characters: number; words: number } => {
throw new Error('Function not implemented.')
},
form: {
layout: undefined,
shoutId: 0,
slug: '',
title: '',
subtitle: undefined,
lead: undefined,
description: undefined,
selectedTopics: [],
mainTopic: undefined,
body: '',
coverImageUrl: undefined,
media: undefined
},
formErrors: {} as Record<keyof ShoutForm, string>,
setEditor,
editor,
saveShout: (_form: ShoutForm): Promise<void> => {
throw new Error('Function not implemented.')
},
saveDraft: (_form: ShoutForm): Promise<void> => {
throw new Error('Function not implemented.')
},
saveDraftToLocalStorage: (_form: ShoutForm): void => {
throw new Error('Function not implemented.')
},
getDraftFromLocalStorage: (_shoutId: number): ShoutForm => {
throw new Error('Function not implemented.')
},
publishShout: (_form: ShoutForm): Promise<void> => {
throw new Error('Function not implemented.')
},
publishShoutById: (_shoutId: number): Promise<void> => {
throw new Error('Function not implemented.')
},
deleteShout: (_shoutId: number): Promise<boolean> => {
throw new Error('Function not implemented.')
},
toggleEditorPanel: (): void => {
throw new Error('Function not implemented.')
},
setForm,
setFormErrors
}
const mockSnackbarContext = {
showSnackbar: console.log
}
const meta: Meta<typeof EditorComponent> = {
title: 'Components/Editor',
component: EditorComponent,
argTypes: {
shoutId: {
control: 'number',
description: 'Unique identifier for the shout (document)',
defaultValue: 1
},
initialContent: {
control: 'text',
description: 'Initial content for the editor',
defaultValue: ''
},
onChange: {
action: 'contentChanged',
description: 'Callback when the content changes'
},
disableCollaboration: {
control: 'boolean',
description: 'Disable collaboration features for Storybook',
defaultValue: true
}
}
}
export default meta
type Story = StoryObj<typeof EditorComponent>
export const Default: Story = {
render: (args) => {
const [_content, setContent] = createSignal(args.initialContent || '')
return (
<SessionContext.Provider value={mockSession as SessionContextType}>
<LocalizeContext.Provider value={mockLocalize as LocalizeContextType}>
<SnackbarContext.Provider value={mockSnackbarContext as SnackbarContextType}>
<EditorContext.Provider value={mockEditorContext as EditorContextType}>
<EditorComponent
{...args}
onChange={(text: string) => {
args.onChange(text)
setContent(text)
}}
/>
</EditorContext.Provider>
</SnackbarContext.Provider>
</LocalizeContext.Provider>
</SessionContext.Provider>
)
},
args: {
shoutId: 1,
initialContent: '',
disableCollaboration: true
}
}
export const WithInitialContent: Story = {
...Default,
args: {
...Default.args,
initialContent: '<p>This is some initial content in the editor.</p>'
}
}

View File

@ -1,5 +1,5 @@
import { HocuspocusProvider } from '@hocuspocus/provider' import { HocuspocusProvider } from '@hocuspocus/provider'
import { Editor, isTextSelection } from '@tiptap/core' import { Editor, EditorOptions, isTextSelection } from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold' import { Bold } from '@tiptap/extension-bold'
import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { BulletList } from '@tiptap/extension-bullet-list' import { BulletList } from '@tiptap/extension-bullet-list'
@ -26,7 +26,7 @@ import { Strike } from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text' import { Text } from '@tiptap/extension-text'
import { Underline } from '@tiptap/extension-underline' import { Underline } from '@tiptap/extension-underline'
import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js' import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js'
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap' import { createTiptapEditor } from 'solid-tiptap'
import uniqolor from 'uniqolor' import uniqolor from 'uniqolor'
import { Doc } from 'yjs' import { Doc } from 'yjs'
import { useEditorContext } from '~/context/editor' import { useEditorContext } from '~/context/editor'
@ -47,14 +47,15 @@ import { Iframe } from './extensions/Iframe'
import { Span } from './extensions/Span' import { Span } from './extensions/Span'
import { ToggleTextWrap } from './extensions/ToggleTextWrap' import { ToggleTextWrap } from './extensions/ToggleTextWrap'
import { TrailingNode } from './extensions/TrailingNode' import { TrailingNode } from './extensions/TrailingNode'
import { renderUploadedImage } from './renderUploadedImage'
import './Prosemirror.scss' import './Prosemirror.scss'
import { renderUploadedImage } from './renderUploadedImage'
type Props = { type Props = {
shoutId: number shoutId: number
initialContent?: string initialContent?: string
onChange: (text: string) => void onChange: (text: string) => void
disableCollaboration?: boolean
} }
const allowedImageTypes = new Set([ const allowedImageTypes = new Set([
@ -78,28 +79,14 @@ export const EditorComponent = (props: Props) => {
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false) const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false) const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const { setEditor, countWords } = useEditorContext()
const docName = `shout-${props.shoutId}` const [extensions, setExtensions] = createSignal<EditorOptions['extensions']>([])
const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>()
if (!yDocs[docName]) { const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
yDocs[docName] = new Doc() const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLElement | undefined>()
} const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLElement | undefined>()
const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLElement | undefined>()
if (!providers[docName]) { const [floatingMenuRef, setFloatingMenuRef] = createSignal<HTMLDivElement | undefined>()
providers[docName] = new HocuspocusProvider({
url: 'wss://hocuspocus.discours.io',
name: docName,
document: yDocs[docName],
token: session()?.access_token || ''
})
}
const [editorElRef, setEditorElRef] = createSignal<HTMLElement>()
let textBubbleMenuRef: HTMLDivElement | undefined
let incutBubbleMenuRef: HTMLElement | undefined
let figureBubbleMenuRef: HTMLElement | undefined
let blockquoteBubbleMenuRef: HTMLElement | undefined
let floatingMenuRef: HTMLDivElement | undefined
const handleClipboardPaste = async () => { const handleClipboardPaste = async () => {
try { try {
@ -130,13 +117,8 @@ export const EditorComponent = (props: Props) => {
} }
} }
const { initialContent } = props const editor = createTiptapEditor(() => ({
const { editor, setEditor, countWords } = useEditorContext() element: editorElRef()!,
createEffect(
on(editorElRef, (ee: HTMLElement | undefined) => {
if (ee) {
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: ee,
editorProps: { editorProps: {
attributes: { attributes: {
class: 'articleEditor' class: 'articleEditor'
@ -149,57 +131,63 @@ export const EditorComponent = (props: Props) => {
return false return false
} }
}, },
extensions: [ extensions: extensions(),
onTransaction: ({ transaction, editor }) => {
if (transaction.docChanged) {
// Get the current HTML content from the editor
const html = editor.getHTML()
// Trigger the onChange callback with the updated HTML
html && props.onChange(html)
// Get the word count from the editor's storage (using CharacterCount)
const wordCount = editor.storage.characterCount.words()
// Update the word count
wordCount && countWords(wordCount)
}
},
content: props.initialContent || ''
}))
createEffect(() => editor() && setEditor(editor() as Editor))
createEffect(
on(
[extensions, editorElRef, author, () => `shout-${props.shoutId}`],
([eee, element, a, docName]) =>
eee.length === 0 &&
a &&
element &&
setExtensions([
Document, Document,
Text, Text,
Paragraph, Paragraph,
Dropcursor,
CustomBlockquote,
Bold, Bold,
Italic, Italic,
Span,
ToggleTextWrap,
Strike, Strike,
HorizontalRule.configure({ Heading.configure({ levels: [2, 3, 4] }),
HTMLAttributes: {
class: 'horizontalRule'
}
}),
Underline,
Link.extend({
inclusive: false
}).configure({
autolink: true,
openOnClick: false
}),
Heading.configure({
levels: [2, 3, 4]
}),
BulletList, BulletList,
OrderedList, OrderedList,
ListItem, ListItem,
Collaboration.configure({
document: yDocs[docName] HorizontalRule.configure({ HTMLAttributes: { class: 'horizontalRule' } }),
}), Dropcursor,
CustomBlockquote,
Span,
ToggleTextWrap,
Underline,
Link.extend({ inclusive: false }).configure({ autolink: true, openOnClick: false }),
Collaboration.configure({ document: yDocs[docName] }),
CollaborationCursor.configure({ CollaborationCursor.configure({
provider: providers[docName], provider: providers[docName],
user: { user: { name: a.name, color: uniqolor(a.slug).color }
name: author().name,
color: uniqolor(author().slug).color
}
}),
Placeholder.configure({
placeholder: t('Add a link or click plus to embed media')
}), }),
Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }),
Focus, Focus,
Gapcursor, Gapcursor,
HardBreak, HardBreak,
Highlight.configure({ Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }),
multicolor: true,
HTMLAttributes: {
class: 'highlight'
}
}),
Image, Image,
Iframe, Iframe,
Figure, Figure,
@ -209,96 +197,107 @@ export const EditorComponent = (props: Props) => {
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689 CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
BubbleMenu.configure({ BubbleMenu.configure({
pluginKey: 'textBubbleMenu', pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef, element: textBubbleMenuRef(),
shouldShow: ({ editor: e, view, state, from, to }) => { shouldShow: ({ editor: e, view, state: { doc, selection }, from, to }) => {
const { doc, selection } = state const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
const { empty } = selection isEmptyTextBlock &&
const isEmptyTextBlock =
doc.textBetween(from, to).length === 0 && isTextSelection(selection)
if (isEmptyTextBlock) {
e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run() e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
}
setIsCommonMarkup(e?.isActive('figcaption')) setIsCommonMarkup(e?.isActive('figcaption'))
const result = const result =
(view.hasFocus() && (view.hasFocus() &&
!empty && !selection.empty &&
!isEmptyTextBlock && !isEmptyTextBlock &&
!e.isActive('image') && !e.isActive('image') &&
!e.isActive('figure')) || !e.isActive('figure')) ||
e.isActive('footnote') || e.isActive('footnote') ||
(e.isActive('figcaption') && !empty) (e.isActive('figcaption') && !selection.empty)
setShouldShowTextBubbleMenu(result) setShouldShowTextBubbleMenu(result)
return result return result
}, },
tippyOptions: { tippyOptions: {
onHide: () => { onHide: () => editor()?.commands.focus() as false
const fe = freshEditor() as Editor
fe?.commands.focus()
}
} }
}), }),
BubbleMenu.configure({ BubbleMenu.configure({
pluginKey: 'blockquoteBubbleMenu', pluginKey: 'blockquoteBubbleMenu',
element: blockquoteBubbleMenuRef, element: blockquoteBubbleMenuRef(),
shouldShow: ({ editor: e, view, state }) => { shouldShow: ({ editor: e, view, state }) =>
const { empty } = state.selection view.hasFocus() && !state.selection.empty && e.isActive('blockquote')
return view.hasFocus() && !empty && e.isActive('blockquote')
}
}), }),
BubbleMenu.configure({ BubbleMenu.configure({
pluginKey: 'figureBubbleMenu', pluginKey: 'figureBubbleMenu',
element: figureBubbleMenuRef, element: figureBubbleMenuRef(),
shouldShow: ({ editor: e, view, state }) => { shouldShow: ({ editor: e, view, state }) =>
const { empty } = state.selection view.hasFocus() && !state.selection.empty && e.isActive('figure')
return view.hasFocus() && !empty && e.isActive('figure')
}
}), }),
BubbleMenu.configure({ BubbleMenu.configure({
pluginKey: 'incutBubbleMenu', pluginKey: 'incutBubbleMenu',
element: incutBubbleMenuRef, element: incutBubbleMenuRef(),
shouldShow: ({ editor: e, view, state }) => { shouldShow: ({ editor: e, view, state }) =>
const { empty } = state.selection view.hasFocus() && !state.selection.empty && e.isActive('figcaption')
return view.hasFocus() && !empty && e.isActive('figcaption')
}
}), }),
FloatingMenu.configure({ FloatingMenu.configure({
element: floatingMenuRef, element: floatingMenuRef(),
pluginKey: 'floatingMenu', pluginKey: 'floatingMenu',
shouldShow: ({ editor: e, state }) => { shouldShow: ({ editor: e, state: { selection } }) => {
const { $anchor, empty } = state.selection const isRootDepth = selection.$anchor.depth === 1
const isRootDepth = $anchor.depth === 1 if (!(isRootDepth && selection.empty)) return false
if (!(isRootDepth && empty)) return false
return !(e.isActive('codeBlock') || e.isActive('heading')) return !(e.isActive('codeBlock') || e.isActive('heading'))
} }
}), }),
TrailingNode, TrailingNode,
ArticleNode ArticleNode
], ])
onTransaction: ({ transaction }) => { )
if (transaction.docChanged) { )
const fe = freshEditor()
if (fe) {
const changeHandle = useEditorHTML(() => fe as Editor | undefined)
props.onChange(changeHandle() || '')
countWords(fe?.storage.characterCount.words())
}
}
},
content: initialContent
}))
if (freshEditor) { createEffect(
editorElRef()?.addEventListener('focus', (_event) => { on(
if (freshEditor()?.isActive('figcaption')) { [
freshEditor()?.commands.focus() () => !props.disableCollaboration,
() => `shout-${props.shoutId}`,
() => session()?.access_token || '',
author
],
([collab, docName, token, profile]) => {
if (!yDocs[docName]) {
yDocs[docName] = new Doc()
}
if (!providers[docName]) {
providers[docName] = new HocuspocusProvider({
url: 'wss://hocuspocus.discours.io',
name: docName,
document: yDocs[docName],
token
})
}
collab &&
setExtensions((old: EditorOptions['extensions']) => [
...old,
Collaboration.configure({ document: yDocs[docName] }),
CollaborationCursor.configure({
provider: providers[docName],
user: {
name: profile.name,
color: uniqolor(profile.slug).color
} }
}) })
setEditor(freshEditor() as Editor) ])
} }
)
)
createEffect(
on(editorElRef, (ee: HTMLElement | undefined) => {
ee?.addEventListener('focus', (_event) => {
if (editor()?.isActive('figcaption')) {
editor()?.commands.focus()
} }
}) })
})
) )
onCleanup(() => { onCleanup(() => {
@ -318,27 +317,12 @@ export const EditorComponent = (props: Props) => {
shouldShow={shouldShowTextBubbleMenu()} shouldShow={shouldShowTextBubbleMenu()}
isCommonMarkup={isCommonMarkup()} isCommonMarkup={isCommonMarkup()}
editor={editor() as Editor} editor={editor() as Editor}
ref={(el) => (textBubbleMenuRef = el)} ref={setTextBubbleMenuRef}
/> />
<BlockquoteBubbleMenu <BlockquoteBubbleMenu ref={setBlockquoteBubbleMenuRef} editor={editor() as Editor} />
ref={(el) => { <FigureBubbleMenu editor={editor() as Editor} ref={setFigureBubbleMenuRef} />
blockquoteBubbleMenuRef = el <IncutBubbleMenu editor={editor() as Editor} ref={setIncutBubbleMenuRef} />
}} <EditorFloatingMenu editor={editor() as Editor} ref={setFloatingMenuRef} />
editor={editor() as Editor}
/>
<FigureBubbleMenu
editor={editor() as Editor}
ref={(el) => {
figureBubbleMenuRef = el
}}
/>
<IncutBubbleMenu
editor={editor() as Editor}
ref={(el) => {
incutBubbleMenuRef = el
}}
/>
<EditorFloatingMenu editor={editor() as Editor} ref={(el) => (floatingMenuRef = el)} />
</Show> </Show>
</> </>
) )

View File

@ -25,12 +25,12 @@ export const Panel = (props: Props) => {
const { const {
isEditorPanelVisible, isEditorPanelVisible,
wordCounter, wordCounter,
editor,
form, form,
toggleEditorPanel, toggleEditorPanel,
saveShout, saveShout,
saveDraft, saveDraft,
publishShout publishShout,
editor
} = useEditorContext() } = useEditorContext()
let containerRef: HTMLElement | undefined let containerRef: HTMLElement | undefined
@ -58,7 +58,7 @@ export const Panel = (props: Props) => {
} }
} }
const html = useEditorHTML(() => editor()) // FIXME: lost current() call const html = useEditorHTML(editor)
const handleFixTypographyClick = () => { const handleFixTypographyClick = () => {
editor()?.commands.setContent(typograf.execute(html() || '')) // here too editor()?.commands.setContent(typograf.execute(html() || '')) // here too

View File

@ -1,3 +1,4 @@
import { Editor } from '@tiptap/core'
import { Blockquote } from '@tiptap/extension-blockquote' import { Blockquote } from '@tiptap/extension-blockquote'
import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { CharacterCount } from '@tiptap/extension-character-count' import { CharacterCount } from '@tiptap/extension-character-count'
@ -14,12 +15,14 @@ import {
useEditorIsEmpty, useEditorIsEmpty,
useEditorIsFocused useEditorIsFocused
} from 'solid-tiptap' } from 'solid-tiptap'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useUI } from '~/context/ui'
import { base } from '~/lib/editorOptions'
import { UploadedFile } from '~/types/upload' import { UploadedFile } from '~/types/upload'
import { Button } from '../_shared/Button' import { Button } from '../_shared/Button'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { Loading } from '../_shared/Loading' import { Loading } from '../_shared/Loading'
import { Modal } from '../_shared/Modal/Modal'
import { Popover } from '../_shared/Popover' import { Popover } from '../_shared/Popover'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient' import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { LinkBubbleMenuModule } from './LinkBubbleMenu' import { LinkBubbleMenuModule } from './LinkBubbleMenu'
@ -27,14 +30,10 @@ import { TextBubbleMenu } from './TextBubbleMenu'
import { UploadModalContent } from './UploadModalContent' import { UploadModalContent } from './UploadModalContent'
import { Figcaption } from './extensions/Figcaption' import { Figcaption } from './extensions/Figcaption'
import { Figure } from './extensions/Figure' import { Figure } from './extensions/Figure'
import { Editor } from '@tiptap/core'
import { useUI } from '~/context/ui'
import { base } from '~/lib/editorOptions'
import { Modal } from '../_shared/Modal/Modal'
import styles from './SimplifiedEditor.module.scss'
import { renderUploadedImage } from './renderUploadedImage' import { renderUploadedImage } from './renderUploadedImage'
import styles from './SimplifiedEditor.module.scss'
type Props = { type Props = {
placeholder: string placeholder: string
initialContent?: string initialContent?: string

View File

@ -5,7 +5,7 @@ import { Icon } from '~/components/_shared/Icon'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useTopics } from '~/context/topics' import { useTopics } from '~/context/topics'
import type { Topic } from '~/graphql/schema/core.gen' import type { Topic } from '~/graphql/schema/core.gen'
import { ruChars } from '~/intl/chars' import { notLatin } from '~/intl/chars'
import { getRandomItemsFromArray } from '~/utils/random' import { getRandomItemsFromArray } from '~/utils/random'
import styles from './TopicsNav.module.scss' import styles from './TopicsNav.module.scss'
@ -13,7 +13,7 @@ export const RandomTopics = () => {
const { sortedTopics } = useTopics() const { sortedTopics } = useTopics()
const { lang, t } = useLocalize() const { lang, t } = useLocalize()
const tag = (topic: Topic) => const tag = (topic: Topic) =>
ruChars.test(topic.title || '') && lang() !== 'ru' ? topic.slug : topic.title notLatin.test(topic.title || '') && lang() !== 'ru' ? topic.slug : topic.title
const [randomTopics, setRandomTopics] = createSignal<Topic[]>([]) const [randomTopics, setRandomTopics] = createSignal<Topic[]>([])
createEffect( createEffect(
on(sortedTopics, (ttt: Topic[]) => { on(sortedTopics, (ttt: Topic[]) => {

View File

@ -11,7 +11,6 @@ import { useLocalize } from '~/context/localize'
import type { Author } from '~/graphql/schema/core.gen' import type { Author } from '~/graphql/schema/core.gen'
import { dummyFilter } from '~/intl/dummyFilter' import { dummyFilter } from '~/intl/dummyFilter'
import { authorLetterReduce, translateAuthor } from '~/intl/translate' import { authorLetterReduce, translateAuthor } from '~/intl/translate'
// import { byFirstChar, byStat } from '~/lib/sort'
import { scrollHandler } from '~/utils/scroll' import { scrollHandler } from '~/utils/scroll'
import styles from './AllAuthors.module.scss' import styles from './AllAuthors.module.scss'
import stylesAuthorList from './AuthorsList.module.scss' import stylesAuthorList from './AuthorsList.module.scss'
@ -25,8 +24,8 @@ type Props = {
export const AUTHORS_PER_PAGE = 20 export const AUTHORS_PER_PAGE = 20
export const ABC = { export const ABC = {
ru: 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#', ru: 'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@',
en: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#' en: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ@'
} }
// useAuthors sorted from context, set filter/sort // useAuthors sorted from context, set filter/sort

View File

@ -6,8 +6,9 @@ import { SearchField } from '~/components/_shared/SearchField'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useTopics } from '~/context/topics' import { useTopics } from '~/context/topics'
import type { Topic } from '~/graphql/schema/core.gen' import type { Topic } from '~/graphql/schema/core.gen'
import { enChars, ruChars } from '~/intl/chars' import { findFirstReadableCharIndex, notLatin, notRus } from '~/intl/chars'
import { dummyFilter } from '~/intl/dummyFilter' import { dummyFilter } from '~/intl/dummyFilter'
import { capitalize } from '~/utils/capitalize'
import { scrollHandler } from '~/utils/scroll' import { scrollHandler } from '~/utils/scroll'
import { TopicBadge } from '../../Topic/TopicBadge' import { TopicBadge } from '../../Topic/TopicBadge'
import styles from './AllTopics.module.scss' import styles from './AllTopics.module.scss'
@ -28,16 +29,20 @@ export const AllTopics = (props: Props) => {
const { setTopicsSort, sortedTopics } = useTopics() const { setTopicsSort, sortedTopics } = useTopics()
const topics = createMemo(() => sortedTopics() || props.topics) const topics = createMemo(() => sortedTopics() || props.topics)
const [searchParams, changeSearchParams] = useSearchParams<{ by?: string }>() const [searchParams, changeSearchParams] = useSearchParams<{ by?: string }>()
onMount(() => changeSearchParams({ by: 'shouts' }))
createEffect(on(() => searchParams?.by || 'shouts', setTopicsSort, { defer: true })) createEffect(on(() => searchParams?.by || 'shouts', setTopicsSort, { defer: true }))
onMount(() => !searchParams?.by && changeSearchParams({ by: 'shouts' }))
// sorted derivative // sorted derivative
const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => { const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => {
return topics().reduce( return topics().reduce(
(acc, topic) => { (acc, topic) => {
let letter = lang() === 'en' ? topic.slug[0].toUpperCase() : (topic?.title?.[0] || '').toUpperCase() const firstCharIndex = findFirstReadableCharIndex(topic?.title || '')
if (enChars.test(letter) && lang() === 'ru') letter = '#' let letter =
if (ruChars.test(letter) && lang() === 'en') letter = '#' lang() === 'en'
? topic.slug[0].toUpperCase()
: (topic?.title?.[firstCharIndex] || '').toUpperCase()
if (notRus.test(letter) && lang() === 'ru') letter = '#'
if (notLatin.test(letter) && lang() === 'en') letter = '#'
if (!acc[letter]) acc[letter] = [] if (!acc[letter]) acc[letter] = []
acc[letter].push(topic) acc[letter].push(topic)
return acc return acc
@ -124,7 +129,9 @@ export const AllTopics = (props: Props) => {
<For each={byLetter()[letter]}> <For each={byLetter()[letter]}>
{(topic) => ( {(topic) => (
<div class={clsx(styles.topicTitle, 'col-sm-12 col-md-8')}> <div class={clsx(styles.topicTitle, 'col-sm-12 col-md-8')}>
<A href={`/topic/${topic.slug}`}>{topic.title || topic.slug}</A> <A href={`/topic/${topic.slug}`}>
{lang() !== 'ru' ? capitalize(topic.slug.replaceAll('-', ' ')) : topic.title}
</A>
<Show when={topic.stat?.shouts || 0}> <Show when={topic.stat?.shouts || 0}>
<span class={styles.articlesCounter}>{topic.stat?.shouts || 0}</span> <span class={styles.articlesCounter}>{topic.stat?.shouts || 0}</span>
</Show> </Show>

View File

@ -46,7 +46,6 @@ export const EditSettingsView = (props: Props) => {
const [isScrolled, setIsScrolled] = createSignal(false) const [isScrolled, setIsScrolled] = createSignal(false)
const { session } = useSession() const { session } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token)) const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const { form, setForm, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage } = useEditorContext() const { form, setForm, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage } = useEditorContext()
const [shoutTopics, setShoutTopics] = createSignal<Topic[]>([]) const [shoutTopics, setShoutTopics] = createSignal<Topic[]>([])
const [draft, setDraft] = createSignal() const [draft, setDraft] = createSignal()

View File

@ -1,3 +1,4 @@
import { Editor } from '@tiptap/core'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import deepEqual from 'fast-deep-equal' import deepEqual from 'fast-deep-equal'
import { import {
@ -32,7 +33,7 @@ import { isDesktop } from '~/lib/mediaQuery'
import { LayoutType } from '~/types/common' import { LayoutType } from '~/types/common'
import { MediaItem } from '~/types/mediaitem' import { MediaItem } from '~/types/mediaitem'
import { clone } from '~/utils/clone' import { clone } from '~/utils/clone'
import { Editor, Panel } from '../../Editor' import { Editor as EditorComponent, Panel } from '../../Editor'
import { AudioUploader } from '../../Editor/AudioUploader' import { AudioUploader } from '../../Editor/AudioUploader'
import { AutoSaveNotice } from '../../Editor/AutoSaveNotice' import { AutoSaveNotice } from '../../Editor/AutoSaveNotice'
import { VideoUploader } from '../../Editor/VideoUploader' import { VideoUploader } from '../../Editor/VideoUploader'
@ -45,6 +46,7 @@ const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/
type Props = { type Props = {
shout: Shout shout: Shout
editor: Editor
} }
export const MAX_HEADER_LIMIT = 100 export const MAX_HEADER_LIMIT = 100
@ -456,10 +458,10 @@ export const EditView = (props: Props) => {
</div> </div>
</div> </div>
<Show when={form?.shoutId} fallback={<Loading />}> <Show when={form?.shoutId} fallback={<Loading />}>
<Editor <EditorComponent
shoutId={form.shoutId} shoutId={form.shoutId}
initialContent={form.body} initialContent={form.body}
onChange={(body) => handleInputChange('body', body)} onChange={(body: string) => handleInputChange('body', body)}
/> />
</Show> </Show>
</div> </div>

View File

@ -1,4 +1,5 @@
import { useMatch, useNavigate } from '@solidjs/router' import { useMatch, useNavigate } from '@solidjs/router'
import { Editor } from '@tiptap/core'
import type { JSX } from 'solid-js' import type { JSX } from 'solid-js'
import { Accessor, createContext, createMemo, createSignal, useContext } from 'solid-js' import { Accessor, createContext, createMemo, createSignal, useContext } from 'solid-js'
import { SetStoreFunction, createStore } from 'solid-js/store' import { SetStoreFunction, createStore } from 'solid-js/store'
@ -33,7 +34,7 @@ export type ShoutForm = {
media?: string media?: string
} }
type EditorContextType = { export type EditorContextType = {
isEditorPanelVisible: Accessor<boolean> isEditorPanelVisible: Accessor<boolean>
wordCounter: Accessor<WordCounter> wordCounter: Accessor<WordCounter>
form: ShoutForm form: ShoutForm
@ -49,9 +50,11 @@ type EditorContextType = {
countWords: (value: WordCounter) => void countWords: (value: WordCounter) => void
setForm: SetStoreFunction<ShoutForm> setForm: SetStoreFunction<ShoutForm>
setFormErrors: SetStoreFunction<Record<keyof ShoutForm, string>> setFormErrors: SetStoreFunction<Record<keyof ShoutForm, string>>
editor: Accessor<Editor | undefined>
setEditor: (e: Editor) => void
} }
const EditorContext = createContext<EditorContextType>({} as EditorContextType) export const EditorContext = createContext<EditorContextType>({} as EditorContextType)
export function useEditorContext() { export function useEditorContext() {
return useContext(EditorContext) return useContext(EditorContext)
@ -83,7 +86,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
const matchEditSettings = useMatch(() => '/editSettings') const matchEditSettings = useMatch(() => '/editSettings')
const { session } = useSession() const { session } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token)) const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const [editor, setEditor] = createSignal<Editor | undefined>()
const { addFeed } = useFeed() const { addFeed } = useFeed()
const snackbar = useSnackbar() const snackbar = useSnackbar()
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false) const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
@ -278,7 +281,9 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
toggleEditorPanel, toggleEditorPanel,
countWords, countWords,
setForm, setForm,
setFormErrors setFormErrors,
editor,
setEditor
} }
const value: EditorContextType = { const value: EditorContextType = {

View File

@ -15,7 +15,7 @@ import { processPrepositions } from '~/intl/prepositions'
i18nextInit() i18nextInit()
type LocalizeContextType = { export type LocalizeContextType = {
t: i18n['t'] t: i18n['t']
lang: Accessor<Language> lang: Accessor<Language>
setLang: (lang: Language) => void setLang: (lang: Language) => void
@ -26,7 +26,7 @@ type LocalizeContextType = {
export type Language = 'ru' | 'en' export type Language = 'ru' | 'en'
const LocalizeContext = createContext<LocalizeContextType>({ export const LocalizeContext = createContext<LocalizeContextType>({
t: (s: string) => s t: (s: string) => s
} as LocalizeContextType) } as LocalizeContextType)

View File

@ -85,7 +85,8 @@ const metaRes = {
} }
} }
} }
const SessionContext = createContext<SessionContextType>({} as SessionContextType)
export const SessionContext = createContext<SessionContextType>({} as SessionContextType)
export function useSession() { export function useSession() {
return useContext(SessionContext) return useContext(SessionContext)

View File

@ -17,12 +17,12 @@ type SnackbarMessage = {
duration?: number duration?: number
} }
type SnackbarContextType = { export type SnackbarContextType = {
snackbarMessage: Accessor<SnackbarMessage | null | undefined> snackbarMessage: Accessor<SnackbarMessage | null | undefined>
showSnackbar: (message: SnackbarMessage) => Promise<void> showSnackbar: (message: SnackbarMessage) => Promise<void>
} }
const SnackbarContext = createContext<SnackbarContextType>({ export const SnackbarContext = createContext<SnackbarContextType>({
snackbarMessage: () => undefined, snackbarMessage: () => undefined,
showSnackbar: async (_m: SnackbarMessage) => undefined showSnackbar: async (_m: SnackbarMessage) => undefined
} as SnackbarContextType) } as SnackbarContextType)

View File

@ -1,6 +1,16 @@
export const allChars = /[^\dA-zА-я]/ export const notChar = /[^\dA-Za-zА-Яа-я]/
export const slugChars = /[^\da-z]/g export const allChar = /[\dA-Za-zА-Яа-я]/
export const enChars = /[^A-z]/ export const notLatin = /[^A-Za-z]/
export const ruChars = /[^ËА-яё]/ export const notRus = /[^ËА-Яа-яё]/
export const sentenceSeparator = /{!|\?|:|;}\s/ export const sentenceSeparator = /{!|\?|:|;}\s/
export const cyrillicRegex = /[\u0400-\u04FF]/ // Range for Cyrillic characters export const cyrillicRegex = /[\u0400-\u04FF]/ // Range for Cyrillic characters
export function findFirstReadableCharIndex(input: string): number {
for (let i = 0; i < input.length; i++) {
// Test each character against the "allChar" regex (readable characters).
if (allChar.test(input[i])) {
return i // Return the index of the first non-readable character
}
}
return 0 // Return -1 if no non-readable characters are found
}

View File

@ -1,6 +1,6 @@
import { Author } from '~/graphql/schema/core.gen' import { Author } from '~/graphql/schema/core.gen'
import { capitalize } from '~/utils/capitalize' import { capitalize } from '~/utils/capitalize'
import { allChars, cyrillicRegex, enChars, ruChars } from './chars' import { cyrillicRegex, findFirstReadableCharIndex, notChar, notLatin, notRus } from './chars'
import { translit } from './translit' import { translit } from './translit'
export const isCyrillic = (s: string): boolean => { export const isCyrillic = (s: string): boolean => {
@ -17,22 +17,35 @@ export const translateAuthor = (author: Author, lng: string) =>
export const authorLetterReduce = (acc: { [x: string]: Author[] }, author: Author, lng: string) => { export const authorLetterReduce = (acc: { [x: string]: Author[] }, author: Author, lng: string) => {
let letter = '' let letter = ''
if (!letter && author && author.name) {
const name =
translateAuthor(author, lng || 'ru')
?.replace(allChars, ' ')
.trim() || ''
const nameParts = name.trim().split(' ')
const found = nameParts.filter(Boolean).pop()
if (found && found.length > 0) {
letter = found[0].toUpperCase()
}
}
if (ruChars.test(letter) && lng === 'ru') letter = '@'
if (enChars.test(letter) && lng === 'en') letter = '@'
if (author?.name) {
// Get the translated author name and clean it up
const name = translateAuthor(author, lng || 'ru')?.trim() || ''
const nameParts = name.split(' ')
const lastName = nameParts.filter(Boolean).pop()?.replace(notChar, ' ').trim() || '' // Replace non-readable characters
// Get the last part of the name
if (lastName && lastName.length > 0) {
const firstCharIndex = findFirstReadableCharIndex(lastName)
// Make sure the index is valid before accessing the character
if (firstCharIndex !== -1) {
letter = lastName[firstCharIndex].toUpperCase()
}
}
}
// Handle non-readable letters based on the language
if (notRus.test(letter) && lng === 'ru') letter = '@'
if (notLatin.test(letter) && lng === 'en') letter = '@'
// Initialize the letter group if it doesn't exist
if (!acc[letter]) acc[letter] = [] if (!acc[letter]) acc[letter] = []
// Translate the author's name for the current language
author.name = translateAuthor(author, lng) author.name = translateAuthor(author, lng)
// Push the author into the corresponding letter group
acc[letter].push(author) acc[letter].push(author)
// Sort authors within each letter group alphabetically by name // Sort authors within each letter group alphabetically by name

View File

@ -1,5 +1,5 @@
import translitConfig from './abc-translit.json' import translitConfig from './abc-translit.json'
import { ruChars, slugChars } from './chars' import { notChar, notLatin } from './chars'
const ru2en: { [key: string]: string } = translitConfig const ru2en: { [key: string]: string } = translitConfig
@ -8,7 +8,7 @@ export const translit = (str: string) => {
return '' return ''
} }
const isCyrillic = ruChars.test(str) const isCyrillic = notLatin.test(str)
if (!isCyrillic) { if (!isCyrillic) {
return str return str
@ -18,5 +18,5 @@ export const translit = (str: string) => {
} }
export const slugify = (text: string) => { export const slugify = (text: string) => {
return translit(text.toLowerCase()).replaceAll(' ', '-').replaceAll(slugChars, '') return translit(text.toLowerCase()).replaceAll(' ', '-').replaceAll(notChar, '')
} }

View File

@ -24,7 +24,7 @@ const polyfillOptions = {
export default { export default {
resolve: { resolve: {
alias: { alias: {
'~': path.resolve(__dirname, './src') '~': path.resolve('./src')
} }
}, },
envPrefix: 'PUBLIC_', envPrefix: 'PUBLIC_',