sass-fixes+minieditor-storybooked

This commit is contained in:
Untone 2024-09-15 22:39:32 +03:00
commit ebed7f38c3
43 changed files with 779 additions and 29884 deletions

View File

@ -7,8 +7,7 @@ const config: StorybookConfig = {
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
'@storybook/addon-themes',
'@storybook/addon-style-config'
'@storybook/addon-themes'
],
framework: {
name: 'storybook-solidjs-vite',

38
api/jsonify.js Normal file
View File

@ -0,0 +1,38 @@
// api/convert.js
import { Editor } from '@tiptap/core'
import { base, custom } from 'src/lib/editorOptions'
// Добавьте другие расширения при необходимости
export default function handler(req, res) {
// Разрешаем только метод POST
if (req.method !== 'POST') {
res.status(405).json({ error: 'Method not allowed' })
return
}
// Получаем HTML из тела запроса
const { html } = req.body
if (!html) {
res.status(400).json({ error: 'No HTML content provided' })
return
}
try {
const editor = new Editor({ extensions: [...base, ...custom] })
editor.commands.setContent(html, false, {
parseOptions: {
preserveWhitespace: 'full'
}
})
const jsonOutput = editor.getJSON()
res.status(200).json(jsonOutput)
} catch (error) {
console.error('Ошибка при конвертации:', error)
res.status(500).json({ error: 'Internal Server Error' })
}
}

View File

@ -1,5 +1,12 @@
import { SolidStartInlineConfig, defineConfig } from '@solidjs/start/config'
import viteConfig, { runtime } from './vite.config'
import viteConfig from './vite.config'
const isVercel = Boolean(process?.env.VERCEL)
const isNetlify = Boolean(process?.env.NETLIFY)
const isBun = Boolean(process.env.BUN)
export const runtime = isNetlify ? 'netlify' : isVercel ? 'vercel_edge' : isBun ? 'bun' : 'node'
console.info(`[app.config] solid-start build for ${runtime}!`)
export default defineConfig({
nitro: {

29485
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -46,12 +46,11 @@
"@storybook/addon-essentials": "^8.3.0",
"@storybook/addon-interactions": "^8.3.0",
"@storybook/addon-links": "^8.3.0",
"@storybook/addon-styling": "1.3.7",
"@storybook/addon-themes": "^8.3.0",
"@storybook/addon-viewport": "^8.3.0",
"@storybook/blocks": "^8.3.0",
"@storybook/builder-vite": "8.2.9",
"@storybook/docs-tools": "8.2.9",
"@storybook/builder-vite": "^8.3.0",
"@storybook/docs-tools": "^8.3.0",
"@storybook/html": "^8.3.0",
"@storybook/react": "^8.3.0",
"@storybook/test-runner": "^0.19.1",
@ -85,6 +84,7 @@
"@tiptap/extension-text": "^2.6.6",
"@tiptap/extension-underline": "^2.6.6",
"@tiptap/extension-youtube": "^2.6.6",
"@tiptap/starter-kit": "^2.6.6",
"@types/cookie": "^0.6.0",
"@types/cookie-signature": "^1.1.2",
"@types/node": "^22.5.5",
@ -112,7 +112,7 @@
"sass": "1.76.0",
"solid-js": "^1.8.22",
"solid-popper": "^0.3.0",
"solid-tiptap": "0.7.0",
"solid-tiptap": "^0.7.0",
"solid-transition-group": "^0.2.3",
"storybook": "^8.3.0",
"storybook-solidjs": "^1.0.0-beta.2",
@ -123,6 +123,7 @@
"stylelint-order": "^6.0.4",
"stylelint-scss": "^6.6.0",
"swiper": "^11.1.14",
"terracotta": "^1.0.6",
"throttle-debounce": "^5.0.2",
"tslib": "^2.7.0",
"typescript": "^5.6.2",
@ -133,11 +134,12 @@
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-sass-dts": "^1.3.29",
"y-prosemirror": "1.2.12",
"yjs": "13.6.18"
"yjs": "13.6.19"
},
"overrides": {
"yjs": "13.6.18",
"y-prosemirror": "1.2.12"
"yjs": "13.6.19",
"y-prosemirror": "1.2.12",
"prosemirror-view": "1.34.2"
},
"engines": {
"node": ">= 20"

View File

@ -1,11 +0,0 @@
<svg
width="13" height="16"
viewBox="0 0 13 16"
fill="none"
version="1.1"
xmlns="http://www.w3.org/2000/svg">
<path
d="M 10.1573,7.43667 C 11.2197,6.70286 11.9645,5.49809 11.9645,4.38095 11.9645,1.90571 10.0478,0 7.58352,0 H 0.738281 V 15.3333 H 8.44876 c 2.28904,0 4.06334,-1.8619 4.06334,-4.1509 0,-1.66478 -0.9419,-3.08859 -2.3548,-3.74573 z M 4.02344,2.73828 h 3.28571 c 0.90905,0 1.64286,0.73381 1.64286,1.64286 0,0.90905 -0.73381,1.64286 -1.64286,1.64286 H 4.02344 Z M 4.01629,9.3405869 h 3.87946 c 0.9090501,0 1.6428601,0.7338101 1.6428601,1.6428601 0,0.90905 -0.73381,1.64286 -1.6428601,1.64286 H 4.01629 Z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 677 B

View File

@ -3,6 +3,7 @@ import { Router } from '@solidjs/router'
import { FileRoutes } from '@solidjs/start/router'
import { type JSX, Suspense } from 'solid-js'
import { AuthToken } from '@authorizerdev/authorizer-js'
import { Loading } from './components/_shared/Loading'
import { AuthorsProvider } from './context/authors'
import { EditorProvider } from './context/editor'
@ -10,13 +11,18 @@ import { FeedProvider } from './context/feed'
import { LocalizeProvider } from './context/localize'
import { SessionProvider } from './context/session'
import { TopicsProvider } from './context/topics'
import { UIProvider } from './context/ui' // snackbar included
import { UIProvider } from './context/ui'
import '~/styles/app.scss'
export const Providers = (props: { children?: JSX.Element }) => {
const sessionStateChanged = (payload: AuthToken) => {
console.debug(payload)
// TODO: maybe load subs here
}
return (
<LocalizeProvider>
<SessionProvider onStateChangeCallback={console.info}>
<SessionProvider onStateChangeCallback={sessionStateChanged}>
<TopicsProvider>
<FeedProvider>
<MetaProvider>

View File

@ -47,7 +47,7 @@ export const Comment = (props: Props) => {
const [editedBody, setEditedBody] = createSignal<string>()
const { session } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const { createReaction, updateReaction } = useReactions()
const { createShoutReaction, updateShoutReaction } = useReactions()
const { showConfirm } = useUI()
const { showSnackbar } = useSnackbar()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
@ -99,7 +99,7 @@ export const Comment = (props: Props) => {
const handleCreate = async (value: string) => {
try {
setLoading(true)
await createReaction({
await createShoutReaction({
reaction: {
kind: ReactionKind.Comment,
reply_to: props.comment.id,
@ -123,7 +123,7 @@ export const Comment = (props: Props) => {
const handleUpdate = async (value: string) => {
setLoading(true)
try {
const reaction = await updateReaction({
const reaction = await updateShoutReaction({
reaction: {
id: props.comment.id || 0,
kind: ReactionKind.Comment,

View File

@ -22,7 +22,7 @@ export const CommentRatingControl = (props: Props) => {
const { session } = useSession()
const uid = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
const { showSnackbar } = useSnackbar()
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
const { reactionEntities, createShoutReaction, deleteShoutReaction, loadReactionsBy } = useReactions()
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
@ -53,7 +53,7 @@ export const CommentRatingControl = (props: Props) => {
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id
)
if (reactionToDelete) return deleteReaction(reactionToDelete.id)
if (reactionToDelete) return deleteShoutReaction(reactionToDelete.id)
}
const handleRatingChange = async (isUpvote: boolean) => {
@ -63,7 +63,7 @@ export const CommentRatingControl = (props: Props) => {
} else if (isDownvoted()) {
await deleteCommentReaction(ReactionKind.Dislike)
} else {
await createReaction({
await createShoutReaction({
reaction: {
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.comment.shout.id,

View File

@ -29,10 +29,10 @@ export const CommentsTree = (props: Props) => {
const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
const [clearEditor, setClearEditor] = createSignal(false)
const [clickedReplyId, setClickedReplyId] = createSignal<number>()
const { reactionEntities, createReaction, loadReactionsBy } = useReactions()
const { reactionEntities, createShoutReaction, loadReactionsBy } = useReactions()
const comments = createMemo(() =>
Object.values(reactionEntities).filter((reaction) => reaction.kind === 'COMMENT')
Object.values(reactionEntities()).filter((reaction) => reaction.kind === 'COMMENT')
)
const sortedComments = createMemo(() => {
@ -74,7 +74,7 @@ export const CommentsTree = (props: Props) => {
const handleSubmitComment = async (value: string) => {
setPosting(true)
try {
await createReaction({
await createShoutReaction({
reaction: {
kind: ReactionKind.Comment,
body: value,
@ -158,11 +158,11 @@ export const CommentsTree = (props: Props) => {
<SimplifiedEditor
quoteEnabled={true}
imageEnabled={true}
autoFocus={false}
options={{ autofocus: false }}
submitByCtrlEnter={true}
placeholder={t('Write a comment...')}
onSubmit={(value) => handleSubmitComment(value)}
setClear={clearEditor()}
reset={clearEditor()}
isPosting={posting()}
/>
</ShowIfAuthenticated>

View File

@ -38,7 +38,6 @@ import { ShoutRatingControl } from './ShoutRatingControl'
type Props = {
article: Shout
scrollToComments?: boolean
}
type IframeSize = {
@ -47,8 +46,7 @@ type IframeSize = {
}
export type ArticlePageSearchParams = {
scrollTo: 'comments'
commentId: string
commentId?: string
slide?: string
}
@ -67,7 +65,7 @@ export const COMMENTS_PER_PAGE = 30
const VOTES_PER_PAGE = 50
export const FullArticle = (props: Props) => {
const [searchParams, changeSearchParams] = useSearchParams<ArticlePageSearchParams>()
const [searchParams] = useSearchParams<ArticlePageSearchParams>()
const { showModal } = useUI()
const { loadReactionsBy } = useReactions()
const [selectedImage, setSelectedImage] = createSignal('')
@ -83,18 +81,20 @@ export const FullArticle = (props: Props) => {
createEffect(
on(
pages,
async (p: Record<string, number>) => {
await loadReactionsBy({
(p: Record<string, number>) => {
console.debug('content paginated')
loadReactionsBy({
by: { shout: props.article.slug, comment: true },
limit: COMMENTS_PER_PAGE,
offset: COMMENTS_PER_PAGE * p.comments || 0
})
await loadReactionsBy({
loadReactionsBy({
by: { shout: props.article.slug, rating: true },
limit: VOTES_PER_PAGE,
offset: VOTES_PER_PAGE * p.rating || 0
})
setIsReactionsLoaded(true)
console.debug('reactions paginated')
},
{ defer: true }
)
@ -165,15 +165,16 @@ export const FullArticle = (props: Props) => {
const media = createMemo<MediaItem[]>(() => JSON.parse(props.article.media || '[]'))
let commentsRef: HTMLDivElement | undefined
createEffect(() => {
if (searchParams?.commentId && isReactionsLoaded()) {
const commentElement = document.querySelector<HTMLElement>(
`[id='comment_${searchParams?.commentId}']`
)
console.debug('comment id is in link, scroll to')
const scrollToElement =
document.querySelector<HTMLElement>(`[id='comment_${searchParams?.commentId}']`) ||
commentsRef ||
document.body
if (commentElement) {
requestAnimationFrame(() => scrollTo(commentElement))
if (scrollToElement) {
requestAnimationFrame(() => scrollTo(scrollToElement))
}
}
})
@ -316,14 +317,6 @@ export const FullArticle = (props: Props) => {
onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
})
createEffect(() => props.scrollToComments && commentsRef && scrollTo(commentsRef))
createEffect(() => {
if (searchParams?.scrollTo === 'comments' && commentsRef) {
requestAnimationFrame(() => commentsRef && scrollTo(commentsRef))
changeSearchParams({ scrollTo: undefined })
}
})
const shareUrl = createMemo(() => getShareUrl({ pathname: `/${props.article.slug || ''}` }))
const getAuthorName = (a: Author) =>
lang() === 'en' && isCyrillic(a.name || '') ? capitalize(a.slug.replaceAll('-', ' ')) : a.name

View File

@ -22,11 +22,11 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const { loadShout } = useFeed()
const { requireAuthentication, session } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
const { reactionEntities, createShoutReaction, deleteShoutReaction, loadReactionsBy } = useReactions()
const [isLoading, setIsLoading] = createSignal(false)
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
Object.values(reactionEntities()).some(
(r) =>
r.kind === reactionKind &&
r.created_by.id === author()?.id &&
@ -38,12 +38,12 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const shoutRatingReactions = createMemo(() =>
Object.values(reactionEntities).filter(
Object.values(reactionEntities()).filter(
(r) => ['LIKE', 'DISLIKE'].includes(r.kind) && r.shout.id === props.shout.id && !r.reply_to
)
)
const deleteShoutReaction = async (reactionKind: ReactionKind) => {
const removeReaction = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
@ -51,18 +51,18 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
r.shout.id === props.shout.id &&
!r.reply_to
)
if (reactionToDelete) return deleteReaction(reactionToDelete.id)
if (reactionToDelete) return deleteShoutReaction(reactionToDelete.id)
}
const handleRatingChange = (isUpvote: boolean) => {
requireAuthentication(async () => {
setIsLoading(true)
if (isUpvoted()) {
await deleteShoutReaction(ReactionKind.Like)
await removeReaction(ReactionKind.Like)
} else if (isDownvoted()) {
await deleteShoutReaction(ReactionKind.Dislike)
await removeReaction(ReactionKind.Dislike)
} else {
await createReaction({
await createShoutReaction({
reaction: {
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.shout.id

View File

@ -29,17 +29,16 @@ import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'sol
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
import uniqolor from 'uniqolor'
import { Doc } from 'yjs'
import { useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session'
import { useSnackbar } from '~/context/ui'
import { Author } from '~/graphql/schema/core.gen'
import { handleImageUpload } from '~/lib/handleImageUpload'
import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu'
import { TextBubbleMenu } from './TextBubbleMenu'
import Article from './extensions/Article'
import { ArticleNode } from './extensions/Article'
import { CustomBlockquote } from './extensions/CustomBlockquote'
import { Figcaption } from './extensions/Figcaption'
import { Figure } from './extensions/Figure'
@ -50,7 +49,7 @@ import { ToggleTextWrap } from './extensions/ToggleTextWrap'
import { TrailingNode } from './extensions/TrailingNode'
import './Prosemirror.scss'
import { Author } from '~/graphql/schema/core.gen'
import { renderUploadedImage } from './renderUploadedImage'
type Props = {
shoutId: number
@ -124,26 +123,8 @@ export const EditorComponent = (props: Props) => {
}
showSnackbar({ body: t('Uploading image') })
const result = await handleImageUpload(uplFile, session()?.access_token || '')
editor()
?.chain()
.focus()
.insertContent({
type: 'figure',
attrs: { 'data-type': 'image' },
content: [
{
type: 'image',
attrs: { src: result.url }
},
{
type: 'figcaption',
content: [{ type: 'text', text: result.originalFilename }]
}
]
})
.run()
const image = await handleImageUpload(uplFile, session()?.access_token || '')
renderUploadedImage(editor() as Editor, image)
} catch (error) {
console.error('[Paste Image Error]:', error)
}
@ -293,7 +274,7 @@ export const EditorComponent = (props: Props) => {
}
}),
TrailingNode,
Article
ArticleNode
],
onTransaction: ({ transaction }) => {
if (transaction.docChanged) {

View File

@ -40,7 +40,6 @@ export const InsertLinkForm = (props: Props) => {
.setLink({ href: checkUrl(value) })
.run()
}
return (
<div>
<InlineForm
@ -49,7 +48,7 @@ export const InsertLinkForm = (props: Props) => {
onClear={handleClearLinkForm}
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
onSubmit={handleLinkFormSubmit}
onClose={() => props.onClose()}
onClose={props.onClose}
/>
</div>
)

View File

@ -0,0 +1,64 @@
import { Meta, StoryObj } from 'storybook-solidjs'
import MiniEditor from './MiniEditor'
const meta: Meta<typeof MiniEditor> = {
title: 'Components/MiniEditor',
component: MiniEditor,
argTypes: {
content: {
control: 'text',
description: 'Initial content for the editor',
defaultValue: ''
},
limit: {
control: 'number',
description: 'Character limit for the editor',
defaultValue: 500
},
placeholder: {
control: 'text',
description: 'Placeholder text when the editor is empty',
defaultValue: 'Start typing here...'
},
onChange: {
action: 'changed',
description: 'Callback when the content changes'
}
}
}
export default meta
type Story = StoryObj<typeof MiniEditor>
export const Default: Story = {
args: {
content: '',
limit: 500,
placeholder: 'Start typing here...'
}
}
export const WithInitialContent: Story = {
args: {
content: 'This is some initial content',
limit: 500,
placeholder: 'Start typing here...'
}
}
export const WithCharacterLimit: Story = {
args: {
content: '',
limit: 50,
placeholder: 'You have a 50 character limit...'
}
}
export const WithCustomPlaceholder: Story = {
args: {
content: '',
limit: 500,
placeholder: 'Custom placeholder here...'
}
}

View File

@ -0,0 +1,191 @@
import type { Editor } from '@tiptap/core'
import CharacterCount from '@tiptap/extension-character-count'
import Placeholder from '@tiptap/extension-placeholder'
import clsx from 'clsx'
import { type JSX, Show, createEffect, createSignal, onCleanup } from 'solid-js'
import {
createEditorTransaction,
createTiptapEditor,
useEditorHTML,
useEditorIsEmpty,
useEditorIsFocused
} from 'solid-tiptap'
import { Toolbar } from 'terracotta'
import { Icon } from '~/components/_shared/Icon/Icon'
import { Popover } from '~/components/_shared/Popover/Popover'
import { useLocalize } from '~/context/localize'
import { useUI } from '~/context/ui'
import { base, custom } from '~/lib/editorOptions'
import { InsertLinkForm } from '../InsertLinkForm/InsertLinkForm'
import styles from '../SimplifiedEditor.module.scss'
interface ControlProps {
editor: Editor
title: string
key: string
onChange: () => void
isActive?: (editor: Editor) => boolean
children: JSX.Element
}
function Control(props: ControlProps): JSX.Element {
const handleClick = (ev?: MouseEvent) => {
ev?.preventDefault()
ev?.stopPropagation()
props.onChange?.()
}
return (
<Popover content={props.title}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: props.editor.isActive(props.key) })}
onClick={handleClick}
>
{props.children}
</button>
)}
</Popover>
)
}
interface MiniEditorProps {
content?: string
onChange?: (content: string) => void
limit?: number
placeholder?: string
}
export default function MiniEditor(props: MiniEditorProps): JSX.Element {
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
const [counter, setCounter] = createSignal(0)
const [showLinkInput, setShowLinkInput] = createSignal(false)
const [showSimpleMenu, setShowSimpleMenu] = createSignal(false)
const { t } = useLocalize()
const { showModal } = useUI()
const editor = createTiptapEditor(() => ({
element: editorElement()!,
extensions: [
...base,
...custom,
Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }),
CharacterCount.configure({ limit: props.limit })
],
editorProps: {
attributes: {
class: styles.simplifiedEditorField
}
},
content: props.content || ''
}))
const isEmpty = useEditorIsEmpty(editor)
const isFocused = useEditorIsFocused(editor)
const isTextSelection = createEditorTransaction(editor, (instance) => !instance?.state.selection.empty)
const html = useEditorHTML(editor)
createEffect(() => setShowSimpleMenu(isTextSelection()))
createEffect(() => {
const textLength = editor()?.getText().length || 0
setCounter(textLength)
const content = html()
content && props.onChange?.(content)
})
const handleLinkClick = () => {
setShowLinkInput(!showLinkInput())
editor()?.chain().focus().run()
}
// Prevent focus loss when clicking inside the toolbar
const handleMouseDownOnToolbar = (event: MouseEvent) => {
event.preventDefault() // Prevent the default focus shift
}
const [toolbarElement, setToolbarElement] = createSignal<HTMLElement>()
// Attach the event handler to the toolbar
onCleanup(() => {
toolbarElement()?.removeEventListener('mousedown', handleMouseDownOnToolbar)
})
return (
<div
class={clsx(styles.SimplifiedEditor, styles.bordered, {
[styles.isFocused]: isEmpty() || isFocused()
})}
>
<div>
<Show when={showSimpleMenu() || showLinkInput()}>
<Toolbar style={{ 'background-color': 'white' }} ref={setToolbarElement} horizontal>
<Show when={editor()} keyed>
{(instance) => (
<div class={styles.controls}>
<Show
when={!showLinkInput()}
fallback={<InsertLinkForm editor={instance} onClose={() => setShowLinkInput(false)} />}
>
<div class={styles.actions}>
<Control
key="bold"
editor={instance}
onChange={() => instance.chain().focus().toggleBold().run()}
title={t('Bold')}
>
<Icon name="editor-bold" />
</Control>
<Control
key="italic"
editor={instance}
onChange={() => instance.chain().focus().toggleItalic().run()}
title={t('Italic')}
>
<Icon name="editor-italic" />
</Control>
<Control
key="link"
editor={instance}
onChange={handleLinkClick}
title={t('Add url')}
isActive={showLinkInput}
>
<Icon name="editor-link" />
</Control>
<Control
key="blockquote"
editor={instance}
onChange={() => instance.chain().focus().toggleBlockquote().run()}
title={t('Add blockquote')}
>
<Icon name="editor-quote" />
</Control>
<Control
key="image"
editor={instance}
onChange={() => showModal('simplifiedEditorUploadImage')}
title={t('Add image')}
>
<Icon name="editor-image-dd-full" />
</Control>
</div>
</Show>
</div>
)}
</Show>
</Toolbar>
</Show>
<div id="mini-editor" ref={setEditorElement} />
<Show when={counter() > 0}>
<small class={styles.limit}>
{counter()} / {props.limit || '∞'}
</small>
</Show>
</div>
</div>
)
}

View File

@ -1,4 +1,3 @@
import { Editor } from '@tiptap/core'
import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold'
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
@ -11,7 +10,7 @@ import { Paragraph } from '@tiptap/extension-paragraph'
import { Placeholder } from '@tiptap/extension-placeholder'
import { Text } from '@tiptap/extension-text'
import { clsx } from 'clsx'
import { Show, createEffect, createReaction, createSignal, on, onCleanup, onMount } from 'solid-js'
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
import { Portal } from 'solid-js/web'
import {
createEditorTransaction,
@ -20,23 +19,26 @@ import {
useEditorIsEmpty,
useEditorIsFocused
} from 'solid-tiptap'
import { useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize'
import { useUI } from '~/context/ui'
import { UploadedFile } from '~/types/upload'
import { Button } from '../_shared/Button'
import { Icon } from '../_shared/Icon'
import { Loading } from '../_shared/Loading'
import { Modal } from '../_shared/Modal'
import { Popover } from '../_shared/Popover'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
import styles from './SimplifiedEditor.module.scss'
import { TextBubbleMenu } from './TextBubbleMenu'
import { UploadModalContent } from './UploadModalContent'
import { Figcaption } from './extensions/Figcaption'
import { Figure } from './extensions/Figure'
import { Editor } from '@tiptap/core'
import { useUI } from '~/context/ui'
import { Modal } from '../_shared/Modal/Modal'
import styles from './SimplifiedEditor.module.scss'
type Props = {
placeholder: string
initialContent?: string
@ -69,27 +71,103 @@ const SimplifiedEditor = (props: Props) => {
const { showModal, hideModal } = useUI()
const [counter, setCounter] = createSignal<number>(0)
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
const isCancelButtonVisible = createMemo(() => props.isCancelButtonVisible !== false)
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
const { editor, setEditor } = useEditorContext()
const maxLength = props.maxLength ?? DEFAULT_MAX_LENGTH
let editorEl: HTMLDivElement | undefined
let wrapperEditorElRef: HTMLElement | undefined
let textBubbleMenuRef: HTMLDivElement | undefined
let linkBubbleMenuRef: HTMLDivElement | undefined
// Extend the Figure extension to include Figcaption
const ImageFigure = Figure.extend({
name: 'capturedImage',
content: 'figcaption image'
})
createEffect(
on(
() => editorElement(),
(ee: HTMLDivElement | undefined) => {
if (ee && textBubbleMenuRef && linkBubbleMenuRef) {
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: ee,
editorProps: {
attributes: {
class: styles.simplifiedEditorField
}
},
extensions: [
Document,
Text,
Paragraph,
Bold,
Italic,
Link.extend({
inclusive: false
}).configure({
autolink: true,
openOnClick: false
}),
CharacterCount.configure({
limit: props.noLimits ? null : maxLength
}),
Blockquote.configure({
HTMLAttributes: {
class: styles.blockQuote
}
}),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return false
const { selection } = state
const { empty } = selection
return view.hasFocus() && !empty
}
}),
BubbleMenu.configure({
pluginKey: 'linkBubbleMenu',
element: linkBubbleMenuRef,
shouldShow: ({ state }) => {
const { selection } = state
const { empty } = selection
return !empty && shouldShowLinkBubbleMenu()
},
tippyOptions: {
placement: 'bottom'
}
}),
ImageFigure,
Image,
Figcaption,
Placeholder.configure({
emptyNodeClass: styles.emptyNode,
placeholder: props.placeholder
})
],
autofocus: props.autoFocus,
content: props.initialContent || null
}))
const editorInstance = freshEditor()
if (!editorInstance) return
setEditor(editorInstance)
}
},
{ defer: true }
)
)
const isEmpty = useEditorIsEmpty(() => editor())
const isFocused = useEditorIsFocused(() => editor())
const isActive = (name: string) =>
createEditorTransaction(
() => editor(),
(ed) => ed?.isActive(name)
(ed) => {
return ed?.isActive(name)
}
)
const html = useEditorHTML(() => editor())
@ -127,6 +205,16 @@ const SimplifiedEditor = (props: Props) => {
editor()?.commands.clearContent(true)
}
createEffect(() => {
if (props.setClear) {
editor()?.commands.clearContent(true)
}
if (props.resetToInitial) {
editor()?.commands.clearContent(true)
if (props.initialContent) editor()?.commands.setContent(props.initialContent)
}
})
const handleKeyDown = (event: KeyboardEvent) => {
if (isEmpty() || !isFocused()) {
return
@ -155,89 +243,19 @@ const SimplifiedEditor = (props: Props) => {
window.removeEventListener('keydown', handleKeyDown)
editor()?.destroy()
})
console.debug('[SimplifiedEditor] mounted')
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: editorEl as HTMLDivElement,
editorProps: {
attributes: {
class: styles.simplifiedEditorField
}
},
extensions: [
Document,
Text,
Paragraph,
Bold,
Italic,
Link.extend({
inclusive: false
}).configure({
autolink: true,
openOnClick: false
}),
CharacterCount.configure({
limit: props.noLimits ? null : maxLength
}),
Blockquote.configure({
HTMLAttributes: {
class: styles.blockQuote
}
}),
BubbleMenu.configure({
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef,
shouldShow: ({ view, state }) => {
if (!props.onlyBubbleControls) return false
const { selection } = state
return view.hasFocus() && !selection.empty
}
}),
BubbleMenu.configure({
pluginKey: 'linkBubbleMenu',
element: linkBubbleMenuRef,
shouldShow: ({ state }) =>
state.selection && !state.selection.empty && shouldShowLinkBubbleMenu(),
tippyOptions: {
placement: 'bottom'
}
}),
ImageFigure,
Image,
Figcaption,
Placeholder.configure({
emptyNodeClass: styles.emptyNode,
placeholder: props.placeholder
})
],
autofocus: props.autoFocus,
content: props.initialContent || null
}))
const ed = freshEditor()
ed && setEditor(ed)
})
createReaction(
on(
editor,
(e) => {
e?.commands.clearContent(props.resetToInitial || props.setClear)
props.initialContent && e?.commands.setContent(props.initialContent)
},
{}
)
)
if (props.onChange) {
createEffect(() => {
props.onChange?.(html() || '')
})
}
createEffect(
on(
html,
(content) => {
content && setCounter(editor()?.storage.characterCount.characters())
props.onChange?.(content || '')
},
{}
)
)
createEffect(() => {
if (html()) {
setCounter(editor()?.storage.characterCount.characters())
}
})
const maxHeightStyle = {
overflow: 'auto',
@ -272,7 +290,7 @@ const SimplifiedEditor = (props: Props) => {
<Show when={props.label && counter() > 0}>
<div class={styles.label}>{props.label}</div>
</Show>
<div style={props.maxHeight ? maxHeightStyle : undefined} ref={(el) => (editorEl = el)} />
<div style={props.maxHeight ? maxHeightStyle : undefined} ref={setEditorElement} />
<Show when={!props.onlyBubbleControls}>
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
<div class={styles.actions}>
@ -343,7 +361,7 @@ const SimplifiedEditor = (props: Props) => {
</div>
<Show when={!props.onChange}>
<div class={styles.buttons}>
<Show when={props.isCancelButtonVisible}>
<Show when={isCancelButtonVisible()}>
<Button value={t('Cancel')} variant="secondary" onClick={handleClear} />
</Show>
<Show when={!props.isPosting} fallback={<Loading />}>
@ -387,4 +405,4 @@ const SimplifiedEditor = (props: Props) => {
)
}
export default SimplifiedEditor
export default SimplifiedEditor // "export default" need to use for asynchronous (lazy) imports in the comments tree

View File

@ -10,7 +10,7 @@ declare module '@tiptap/core' {
}
}
export default Node.create({
export const ArticleNode = Node.create({
name: 'article',
group: 'block',
content: 'block+',
@ -65,3 +65,5 @@ export default Node.create({
}
}
})
export default ArticleNode

View File

@ -1,4 +1,4 @@
import { A, useNavigate, useSearchParams } from '@solidjs/router'
import { A, useNavigate } from '@solidjs/router'
import { clsx } from 'clsx'
import { Accessor, For, Show, createMemo, createSignal } from 'solid-js'
import { Icon } from '~/components/_shared/Icon'
@ -7,7 +7,6 @@ import { Popover } from '~/components/_shared/Popover'
import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session'
import type { Author, Maybe, Shout, Topic } from '~/graphql/schema/core.gen'
import { sentenceSeparator } from '~/intl/chars'
import { capitalize } from '~/utils/capitalize'
import { descFromBody } from '~/utils/meta'
import { CoverImage } from '../../Article/CoverImage'
@ -56,7 +55,7 @@ const desktopCoverImageWidths: Record<string, number> = {
M: 600,
L: 800
}
const titleSeparator = /{!|\?|:|;}\s/
const getTitleAndSubtitle = (
article: Shout
): {
@ -70,7 +69,7 @@ const getTitleAndSubtitle = (
let titleParts = article.title?.split('. ') || []
if (titleParts?.length === 1) {
titleParts = article.title?.split(sentenceSeparator) || []
titleParts = article.title?.split(titleSeparator) || []
}
if (titleParts && titleParts.length > 1) {
@ -106,7 +105,6 @@ export const ArticleCard = (props: ArticleCardProps) => {
const { t, lang, formatDate } = useLocalize()
const { session } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const [, changeSearchParams] = useSearchParams()
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const [isCoverImageLoadError, setIsCoverImageLoadError] = createSignal(false)
const [isCoverImageLoading, setIsCoverImageLoading] = createSignal(true)
@ -130,10 +128,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
const scrollToComments = (event: MouseEvent & { currentTarget: HTMLAnchorElement; target: Element }) => {
event.preventDefault()
changeSearchParams({
scrollTo: 'comments'
})
navigate(`/${props.article.slug}`)
navigate(`/${props.article.slug}?commentId=0`)
}
const onInvite = () => {

View File

@ -23,7 +23,6 @@ type Props = {
isHeaderFixed?: boolean
desc?: string
cover?: string
scrollToComments?: (value: boolean) => void
}
type HeaderSearchParams = {
@ -38,7 +37,7 @@ export const Header = (props: Props) => {
const { t, lang } = useLocalize()
const { modal } = useUI()
const { requireAuthentication } = useSession()
const [searchParams] = useSearchParams<HeaderSearchParams>()
const [searchParams, changeSearchParams] = useSearchParams<HeaderSearchParams>()
const [getIsScrollingBottom, setIsScrollingBottom] = createSignal(false)
const [getIsScrolled, setIsScrolled] = createSignal(false)
const [fixed, setFixed] = createSignal(false)
@ -85,14 +84,6 @@ export const Header = (props: Props) => {
})
})
const scrollToComments = (
event: MouseEvent & { currentTarget: HTMLDivElement; target: Element },
value: boolean
) => {
event.preventDefault()
props.scrollToComments?.(value)
}
const handleBookmarkButtonClick = (ev: { preventDefault: () => void }) => {
requireAuthentication(() => {
// TODO: implement bookmark clicked
@ -320,7 +311,7 @@ export const Header = (props: Props) => {
</>
}
/>
<div onClick={(event) => scrollToComments(event, true)} class={styles.control}>
<div onClick={() => changeSearchParams({ commentId: 0 })} class={styles.control}>
<Icon name="comment" class={styles.icon} />
<Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} />
</div>

View File

@ -1,4 +1,6 @@
@mixin search-filter-control {
@include font-size(1.4rem);
height: 4rem;
padding: 0 2rem;
background: rgb(64 64 64 / 50%);
@ -7,8 +9,6 @@
font-weight: 500;
white-space: nowrap;
@include font-size(1.4rem);
&:hover {
background: #404040;
}
@ -23,6 +23,8 @@
}
.searchInput {
@include font-size(4.8rem);
width: 100%;
padding: 0 0 0.5rem;
background: none;
@ -32,8 +34,6 @@
font-weight: bold;
outline: none;
@include font-size(4.8rem);
&::placeholder {
color: rgb(255 255 255 / 32%);
}
@ -60,10 +60,10 @@
}
.searchDescription {
@include font-size(1.6rem);
margin-bottom: 44px;
color: rgb(255 255 255 / 64%);
@include font-size(1.6rem);
}
.topicsList {

View File

@ -44,7 +44,7 @@ export const FullTopic = (props: Props) => {
)
createEffect(() => {
if (follows?.topics?.length !== 0) {
if (follows?.topics?.length ?? true) {
const items = follows.topics || []
setFollowed(items.some((x: Topic) => x?.slug === props.topic?.slug))
}

View File

@ -17,7 +17,7 @@ export const RandomTopics = () => {
const [randomTopics, setRandomTopics] = createSignal<Topic[]>([])
createEffect(
on(sortedTopics, (ttt: Topic[]) => {
if (ttt?.length) {
if (ttt?.length > 0) {
setRandomTopics(getRandomItemsFromArray(ttt))
}
})

View File

@ -56,7 +56,7 @@ export const AllAuthors = (props: Props) => {
// store by first char
const byLetterFiltered = createMemo<{ [letter: string]: Author[] }>(() => {
if (!(filteredAuthors()?.length > 0)) return {}
if (!filteredAuthors()) return {}
console.debug('[components.AllAuthors] update byLetterFiltered', filteredAuthors()?.length)
return (
filteredAuthors()?.reduce(

View File

@ -175,7 +175,7 @@ export const AuthorView = (props: AuthorViewProps) => {
const [loadMoreCommentsHidden, setLoadMoreCommentsHidden] = createSignal(
Boolean(props.author?.stat && props.author?.stat?.comments === 0)
)
const { commentsByAuthor, addReactions } = useReactions()
const { commentsByAuthor, addShoutReactions } = useReactions()
const loadMoreComments = async () => {
if (!author()) return [] as LoadMoreItems
saveScrollPosition()
@ -189,7 +189,7 @@ export const AuthorView = (props: AuthorViewProps) => {
offset: commentsByAuthor()[aid]?.length || 0
})
const result = await authorCommentsFetcher()
result && addReactions(result)
result && addShoutReactions(result)
restoreScrollPosition()
return result as LoadMoreItems
}

View File

@ -1,5 +1,4 @@
import { createSignal } from 'solid-js'
import { Show } from 'solid-js/web'
import { Show, createSignal } from 'solid-js'
import { useLocalize } from '~/context/localize'
export const ConnectView = () => {

View File

@ -130,7 +130,7 @@ export const EditView = (props: Props) => {
draft,
(d) => {
if (d) {
const draftForm = Object.keys(d).length !== 0 ? d : { shoutId: props.shout.id }
const draftForm = Object.keys(d) ? d : { shoutId: props.shout.id }
setForm(draftForm)
console.debug('draft from localstorage: ', draftForm)
}

View File

@ -118,7 +118,7 @@ export const FeedView = (props: FeedProps) => {
<Placeholder type={loc?.pathname} mode="feed" />
</Show>
<Show when={(session() || loc?.pathname === 'feed') && props.shouts?.length}>
<Show when={(session() || loc?.pathname === 'feed') && props.shouts}>
<div class={styles.filtersContainer}>
<ul class={clsx('view-switcher', styles.feedFilter)}>
<li class={clsx({ 'view-switcher__item--selected': !props.order })}>

View File

@ -14,6 +14,7 @@ import {
onMount
} from 'solid-js'
import { createStore } from 'solid-js/store'
import SimplifiedEditor from '~/components/Editor/SimplifiedEditor'
import { useLocalize } from '~/context/localize'
import { useProfile } from '~/context/profile'
import { useSession } from '~/context/session'
@ -34,7 +35,7 @@ import { SocialNetworkInput } from '../../_shared/SocialNetworkInput'
import styles from './Settings.module.scss'
import { profileSocialLinks } from './profileSocialLinks'
const SimplifiedEditor = lazy(() => import('~/components/Editor/SimplifiedEditor'))
// const SimplifiedEditor = lazy(() => import('~/components/Editor/SimplifiedEditor'))
const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
function filterNulls(arr: InputMaybe<string>[]): string[] {
@ -56,11 +57,11 @@ export const ProfileSettings = () => {
const [slugError, setSlugError] = createSignal<string>()
const [nameError, setNameError] = createSignal<string>()
const { form, submit, updateFormField, setForm } = useProfile()
const [about, setAbout] = createSignal(form.about)
const { showSnackbar } = useSnackbar()
const { loadSession, session } = useSession()
const [prevForm, setPrevForm] = createStore<ProfileInput>()
const { showConfirm } = useUI()
const [clearAbout, setClearAbout] = createSignal(false)
const { showModal, hideModal } = useUI()
const [loading, setLoading] = createSignal(true)
@ -111,6 +112,7 @@ export const ProfileSettings = () => {
try {
await submit(form)
setPrevForm(clone(form))
setAbout(form.about)
showSnackbar({ body: t('Profile successfully saved') })
} catch (error) {
if (error?.toString().search('duplicate_slug')) {
@ -132,11 +134,7 @@ export const ProfileSettings = () => {
confirmButtonVariant: 'primary',
declineButtonVariant: 'secondary'
})
if (isConfirmed) {
setClearAbout(true)
setForm(clone(prevForm))
setClearAbout(false)
}
isConfirmed && setForm(clone(prevForm))
}
const handleCropAvatar = () => {
@ -254,7 +252,7 @@ export const ProfileSettings = () => {
</Popover>
{/* @@TODO inspect popover below. onClick causes page refreshing */}
{/* <Popover content={t('Upload userpic')}>
<Popover content={t('Upload userpic')}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
@ -264,7 +262,7 @@ export const ProfileSettings = () => {
<Icon name="user-image-black" />
</button>
)}
</Popover> */}
</Popover>
</div>
</Match>
<Match when={!form.pic}>
@ -284,18 +282,20 @@ export const ProfileSettings = () => {
)}
</p>
<div class="pretty-form__item">
<input
type="text"
name="nameOfUser"
id="nameOfUser"
data-lpignore="true"
autocomplete="one-time-code"
placeholder={t('Name')}
onInput={(event) => updateFormField('name', event.currentTarget.value)}
value={form.name || ''}
ref={(el) => (nameInputRef = el)}
/>
<label for="nameOfUser">{t('Name')}</label>
<label for="nameOfUser">
<input
type="text"
name="nameOfUser"
id="nameOfUser"
data-lpignore="true"
autocomplete="one-time-code"
placeholder={t('Name')}
onInput={(event) => updateFormField('name', event.currentTarget.value)}
value={form.name || ''}
ref={(el) => (nameInputRef = el)}
/>
{t('Name')}
</label>
<Show when={nameError()}>
<div
style={{ position: 'absolute', 'margin-top': '-4px' }}
@ -341,16 +341,16 @@ export const ProfileSettings = () => {
<h4>{t('About')}</h4>
<SimplifiedEditor
resetToInitial={clearAbout()}
resetToInitial={true}
noLimits={true}
variant="bordered"
onlyBubbleControls={true}
smallHeight={true}
placeholder={t('About')}
label={t('About')}
initialContent={form.about || ''}
initialContent={about() || ''}
autoFocus={false}
onChange={(value) => updateFormField('about', value)}
onChange={setAbout}
placeholder={t('About')}
/>
<div class={clsx(styles.multipleControls, 'pretty-form__item')}>
<div class={styles.multipleControlsHeader}>

View File

@ -1,7 +1,7 @@
import { useNavigate } from '@solidjs/router'
import { clsx } from 'clsx'
import { Show, createEffect, createMemo, createSignal, lazy, onMount } from 'solid-js'
import { Show, createEffect, createSignal, lazy, onMount } from 'solid-js'
import { createStore } from 'solid-js/store'
import { Button } from '~/components/_shared/Button'
import { Icon } from '~/components/_shared/Icon'
import { Image } from '~/components/_shared/Image'
@ -11,11 +11,10 @@ import { useSession } from '~/context/session'
import { useTopics } from '~/context/topics'
import { useSnackbar, useUI } from '~/context/ui'
import { Topic } from '~/graphql/schema/core.gen'
import { UploadedFile } from '~/types/upload'
import { TopicSelect, UploadModalContent } from '../../Editor'
import { Modal } from '../../_shared/Modal'
import { useNavigate } from '@solidjs/router'
import { UploadedFile } from '~/types/upload'
import stylesBeside from '../../Feed/Beside.module.scss'
import styles from './PublishSettings.module.scss'
@ -77,7 +76,7 @@ export const PublishSettings = (props: Props) => {
return props.form.description
}
const initialData = createMemo(() => {
const initialData = () => {
return {
coverImageUrl: props.form?.coverImageUrl,
mainTopic: props.form?.mainTopic || EMPTY_TOPIC,
@ -87,7 +86,7 @@ export const PublishSettings = (props: Props) => {
description: composeDescription() || '',
selectedTopics: []
}
})
}
const [settingsForm, setSettingsForm] = createStore<FormConfig>(emptyConfig)
@ -240,8 +239,16 @@ export const PublishSettings = (props: Props) => {
<h4>{t('Slug')}</h4>
<div class="pretty-form__item">
<input type="text" name="slug" id="slug" value={settingsForm.slug} onInput={removeSpecial} />
<label for="slug">{t('Slug')}</label>
<label for="slug">
<input
type="text"
name="slug"
id="slug"
value={settingsForm.slug}
onInput={removeSpecial}
/>
{t('Slug')}
</label>
</div>
<h4>{t('Topics')}</h4>

View File

@ -2,7 +2,7 @@ import { Meta, Title } from '@solidjs/meta'
import { useLocation } from '@solidjs/router'
import { clsx } from 'clsx'
import type { JSX } from 'solid-js'
import { Show, createEffect, createMemo, createSignal } from 'solid-js'
import { Show, createMemo } from 'solid-js'
import { useLocalize } from '~/context/localize'
import { Shout } from '~/graphql/schema/core.gen'
import enKeywords from '~/intl/locales/en/keywords.json'
@ -27,7 +27,6 @@ type PageLayoutProps = {
class?: string
withPadding?: boolean
zeroBottomPadding?: boolean
scrollToComments?: (value: boolean) => void
key?: string
}
@ -48,12 +47,10 @@ export const PageLayout = (props: PageLayoutProps) => {
: imageUrl
)
const description = createMemo(() => props.desc || (props.article && descFromBody(props.article.body)))
const keypath = createMemo(() => (props.key || loc?.pathname.split('/')[0]) as keyof typeof ruKeywords)
const keywords = createMemo(
() => props.keywords || (lang() === 'ru' ? ruKeywords[keypath()] : enKeywords[keypath()])
)
const [scrollToComments, setScrollToComments] = createSignal<boolean>(false)
createEffect(() => props.scrollToComments?.(scrollToComments()))
const keywords = createMemo(() => {
const keypath = (props.key || loc?.pathname.split('/')[0]) as keyof typeof ruKeywords
return props.keywords || lang() === 'ru' ? ruKeywords[keypath] : enKeywords[keypath]
})
return (
<>
<Title>{props.article?.title || t(props.title)}</Title>
@ -63,7 +60,6 @@ export const PageLayout = (props: PageLayoutProps) => {
desc={props.desc}
cover={imageUrl}
isHeaderFixed={isHeaderFixed}
scrollToComments={(value) => setScrollToComments(value)}
/>
<Meta name="descprition" content={description() || ''} />
<Meta name="keywords" content={keywords()} />

View File

@ -97,8 +97,10 @@ export const ShareLinks = (props: Props) => {
}
>
<form class={clsx('pretty-form__item', styles.linkInput)}>
<input type="text" name="link" readonly value={props.shareUrl} />
<label for="link">{t('Copy link')}</label>
<label for="link">
<input type="text" name="link" readonly value={props.shareUrl} />
{t('Copy link')}
</label>
<Popover content={t('Copy link')}>
{(triggerRef: (el: HTMLElement) => void) => (

View File

@ -17,8 +17,9 @@ type Props = {
articleView?: boolean
}
const watchPattern = /watch=(\w+)/
const ytPattern = /(youtu.be)\/(\w+)/
const ytPattern = /youtu.be\/(\w+)/
const vimeoPattern = /vimeo.com\/(\d+)/
export const VideoPlayer = (props: Props) => {
const { t } = useLocalize()
const [videoId, setVideoId] = createSignal<string | undefined>()

View File

@ -73,9 +73,10 @@ export const AuthorsProvider = (props: { children: JSX.Element }) => {
console.debug('[context.authors] storing new authors:', newAuthors)
setAuthors((prevAuthors) => {
const updatedAuthors = { ...prevAuthors }
newAuthors.forEach((author) => {
updatedAuthors[author.slug] = author
})
Array.isArray(newAuthors) &&
newAuthors.forEach((author) => {
updatedAuthors[author.slug] = author
})
return updatedAuthors
})
}

View File

@ -1,7 +1,5 @@
import type { Accessor, JSX } from 'solid-js'
import { createContext, createMemo, createSignal, onCleanup, useContext } from 'solid-js'
import { createStore, reconcile } from 'solid-js/store'
import { coreApiUrl } from '~/config'
import { loadReactions } from '~/graphql/api/public'
import createReactionMutation from '~/graphql/mutation/core/reaction-create'
@ -20,14 +18,14 @@ import { useSession } from './session'
import { useSnackbar } from './ui'
type ReactionsContextType = {
reactionEntities: Record<number, Reaction>
reactionsByShout: Record<number, Reaction[]>
reactionEntities: Accessor<Record<number, Reaction>>
reactionsByShout: Accessor<Record<number, Reaction[]>>
commentsByAuthor: Accessor<Record<number, Reaction[]>>
loadReactionsBy: (args: QueryLoad_Reactions_ByArgs) => Promise<Reaction[]>
createReaction: (reaction: MutationCreate_ReactionArgs) => Promise<void>
updateReaction: (reaction: MutationUpdate_ReactionArgs) => Promise<Reaction>
deleteReaction: (id: number) => Promise<{ error: string } | null>
addReactions: (rrr: Reaction[]) => void
createShoutReaction: (reaction: MutationCreate_ReactionArgs) => Promise<void>
updateShoutReaction: (reaction: MutationUpdate_ReactionArgs) => Promise<Reaction>
deleteShoutReaction: (id: number) => Promise<{ error: string } | null>
addShoutReactions: (rrr: Reaction[]) => void
}
const ReactionsContext = createContext<ReactionsContextType>({} as ReactionsContextType)
@ -37,29 +35,29 @@ export function useReactions() {
}
export const ReactionsProvider = (props: { children: JSX.Element }) => {
const [reactionEntities, setReactionEntities] = createStore<Record<number, Reaction>>({})
const [reactionsByShout, setReactionsByShout] = createStore<Record<number, Reaction[]>>({})
const [reactionsByAuthor, setReactionsByAuthor] = createStore<Record<number, Reaction[]>>({})
const [reactionEntities, setReactionEntities] = createSignal<Record<number, Reaction>>({})
const [reactionsByShout, setReactionsByShout] = createSignal<Record<number, Reaction[]>>({})
const [reactionsByAuthor, setReactionsByAuthor] = createSignal<Record<number, Reaction[]>>({})
const [commentsByAuthor, setCommentsByAuthor] = createSignal<Record<number, Reaction[]>>({})
const { t } = useLocalize()
const { showSnackbar } = useSnackbar()
const { session } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const addReactions = (rrr: Reaction[]) => {
const newReactionsByShout: Record<number, Reaction[]> = { ...reactionsByShout }
const newReactionsByAuthor: Record<number, Reaction[]> = { ...reactionsByAuthor }
const newReactionEntities = rrr.reduce(
(acc: { [reaction_id: number]: Reaction }, reaction: Reaction) => {
acc[reaction.id] = reaction
if (!newReactionsByShout[reaction.shout.id]) newReactionsByShout[reaction.shout.id] = []
newReactionsByShout[reaction.shout.id].push(reaction)
if (!newReactionsByAuthor[reaction.created_by.id]) newReactionsByAuthor[reaction.created_by.id] = []
newReactionsByAuthor[reaction.created_by.id].push(reaction)
return acc
},
{ ...reactionEntities }
)
const addShoutReactions = (rrr: Reaction[]) => {
const newReactionEntities = { ...reactionEntities() }
const newReactionsByShout = { ...reactionsByShout() }
const newReactionsByAuthor = { ...reactionsByAuthor() }
rrr.forEach((reaction) => {
newReactionEntities[reaction.id] = reaction
if (!newReactionsByShout[reaction.shout.id]) newReactionsByShout[reaction.shout.id] = []
newReactionsByShout[reaction.shout.id].push(reaction)
if (!newReactionsByAuthor[reaction.created_by.id]) newReactionsByAuthor[reaction.created_by.id] = []
newReactionsByAuthor[reaction.created_by.id].push(reaction)
})
setReactionEntities(newReactionEntities)
setReactionsByShout(newReactionsByShout)
@ -68,7 +66,7 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
const newCommentsByAuthor = Object.fromEntries(
Object.entries(newReactionsByAuthor).map(([authorId, reactions]) => [
authorId,
reactions.filter((x: Reaction) => x.kind === ReactionKind.Comment)
reactions.filter((x) => x.kind === ReactionKind.Comment)
])
)
@ -76,80 +74,93 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
}
const loadReactionsBy = async (opts: QueryLoad_Reactions_ByArgs): Promise<Reaction[]> => {
!opts.by && console.warn('reactions provider got wrong opts')
if (!opts.by) console.warn('reactions provider got wrong opts')
const fetcher = await loadReactions(opts)
const result = (await fetcher()) || []
console.debug('[context.reactions] loaded', result)
result && addReactions(result)
if (result) addShoutReactions(result)
return result
}
const createReaction = async (input: MutationCreate_ReactionArgs): Promise<void> => {
const createShoutReaction = async (input: MutationCreate_ReactionArgs): Promise<void> => {
const resp = await client()?.mutation(createReactionMutation, input).toPromise()
const { error, reaction } = resp?.data?.create_reaction || {}
if (error) await showSnackbar({ type: 'error', body: t(error) })
if (!reaction) return
const changes = {
[reaction.id]: reaction
}
if ([ReactionKind.Like, ReactionKind.Dislike].includes(reaction.kind)) {
const oppositeReactionKind =
reaction.kind === ReactionKind.Like ? ReactionKind.Dislike : ReactionKind.Like
const oppositeReaction = Object.values(reactionEntities).find(
(r) =>
r.kind === oppositeReactionKind &&
r.created_by.slug === reaction.created_by.slug &&
r.shout.id === reaction.shout.id &&
r.reply_to === reaction.reply_to
)
if (oppositeReaction) {
changes[oppositeReaction.id] = undefined
}
}
setReactionEntities(changes)
addShoutReactions([reaction])
}
const deleteReaction = async (
const deleteShoutReaction = async (
reaction_id: number
): Promise<{ error: string; reaction?: string } | null> => {
if (reaction_id) {
const resp = await client()?.mutation(destroyReactionMutation, { reaction_id }).toPromise()
const result = resp?.data?.destroy_reaction
if (!result.error) {
setReactionEntities({
[reaction_id]: undefined
})
const reactionToDelete = reactionEntities()[reaction_id]
if (reactionToDelete) {
const newReactionEntities = { ...reactionEntities() }
delete newReactionEntities[reaction_id]
const newReactionsByShout = { ...reactionsByShout() }
const shoutReactions = newReactionsByShout[reactionToDelete.shout.id]
if (shoutReactions) {
newReactionsByShout[reactionToDelete.shout.id] = shoutReactions.filter(
(r) => r.id !== reaction_id
)
}
const newReactionsByAuthor = { ...reactionsByAuthor() }
const authorReactions = newReactionsByAuthor[reactionToDelete.created_by.id]
if (authorReactions) {
newReactionsByAuthor[reactionToDelete.created_by.id] = authorReactions.filter(
(r) => r.id !== reaction_id
)
}
setReactionEntities(newReactionEntities)
setReactionsByShout(newReactionsByShout)
setReactionsByAuthor(newReactionsByAuthor)
}
}
return result
}
return null
}
const updateReaction = async (input: MutationUpdate_ReactionArgs): Promise<Reaction> => {
const updateShoutReaction = async (input: MutationUpdate_ReactionArgs): Promise<Reaction> => {
const resp = await client()?.mutation(updateReactionMutation, input).toPromise()
const result = resp?.data?.update_reaction
if (!result) throw new Error('cannot update reaction')
const { error, reaction } = result
if (error) await showSnackbar({ type: 'error', body: t(error) })
if (reaction) setReactionEntities(reaction.id, reaction)
if (reaction) {
const newReactionEntities = { ...reactionEntities() }
newReactionEntities[reaction.id] = reaction
setReactionEntities(newReactionEntities)
}
return reaction
}
onCleanup(() => setReactionEntities(reconcile({})))
onCleanup(() => setReactionEntities({}))
const actions = {
loadReactionsBy,
createReaction,
updateReaction,
deleteReaction,
addReactions
createShoutReaction,
updateShoutReaction,
deleteShoutReaction,
addShoutReactions
}
const value: ReactionsContextType = { reactionEntities, reactionsByShout, commentsByAuthor, ...actions }
const value: ReactionsContextType = {
reactionEntities,
reactionsByShout,
commentsByAuthor,
...actions
}
return <ReactionsContext.Provider value={value}>{props.children}</ReactionsContext.Provider>
}

103
src/lib/editorOptions.ts Normal file
View File

@ -0,0 +1,103 @@
import { EditorOptions } from '@tiptap/core'
import Highlight from '@tiptap/extension-highlight'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline'
import StarterKit from '@tiptap/starter-kit'
import { CustomBlockquote } from '~/components/Editor/extensions/CustomBlockquote'
import { Figcaption } from '~/components/Editor/extensions/Figcaption'
import { Figure } from '~/components/Editor/extensions/Figure'
import { Footnote } from '~/components/Editor/extensions/Footnote'
import { Iframe } from '~/components/Editor/extensions/Iframe'
import { Span } from '~/components/Editor/extensions/Span'
import { ToggleTextWrap } from '~/components/Editor/extensions/ToggleTextWrap'
import { TrailingNode } from '~/components/Editor/extensions/TrailingNode'
// Extend the Figure extension to include Figcaption
const ImageFigure = Figure.extend({
name: 'capturedImage',
content: 'figcaption image'
})
export const base: EditorOptions['extensions'] = [
StarterKit.configure({
heading: {
levels: [2, 3, 4]
},
horizontalRule: {
HTMLAttributes: {
class: 'horizontalRule'
}
},
blockquote: undefined
}),
Underline, // не входит в StarterKit
Link.configure({
autolink: true,
openOnClick: false
}),
Image,
Highlight.configure({
multicolor: true,
HTMLAttributes: {
class: 'highlight'
}
})
]
export const custom: EditorOptions['extensions'] = [
ImageFigure,
Figure,
Figcaption,
Footnote,
CustomBlockquote,
Iframe,
Span,
ToggleTextWrap,
TrailingNode
// Добавьте другие кастомные расширения здесь
]
export const collab: EditorOptions['extensions'] = []
/*
content: '',
autofocus: false,
editable: false,
element: undefined,
injectCSS: false,
injectNonce: undefined,
editorProps: {} as EditorProps,
parseOptions: {} as EditorOptions['parseOptions'],
enableInputRules: false,
enablePasteRules: false,
enableCoreExtensions: false,
enableContentCheck: false,
onBeforeCreate: (_props: EditorEvents['beforeCreate']): void => {
throw new Error('Function not implemented.')
},
onCreate: (_props: EditorEvents['create']): void => {
throw new Error('Function not implemented.')
},
onContentError: (_props: EditorEvents['contentError']): void => {
throw new Error('Function not implemented.')
},
onUpdate: (_props: EditorEvents['update']): void => {
throw new Error('Function not implemented.')
},
onSelectionUpdate: (_props: EditorEvents['selectionUpdate']): void => {
throw new Error('Function not implemented.')
},
onTransaction: (_props: EditorEvents['transaction']): void => {
throw new Error('Function not implemented.')
},
onFocus: (_props: EditorEvents['focus']): void => {
throw new Error('Function not implemented.')
},
onBlur: (_props: EditorEvents['blur']): void => {
throw new Error('Function not implemented.')
},
onDestroy: (_props: EditorEvents['destroy']): void => {
throw new Error('Function not implemented.')
}
}
*/

19
src/lib/fromPeriod.ts Normal file
View File

@ -0,0 +1,19 @@
export type FromPeriod = 'week' | 'month' | 'year'
export const getFromDate = (period: FromPeriod): number => {
const now = new Date()
let d: Date = now
switch (period) {
case 'month': {
d = new Date(now.setMonth(now.getMonth() - 1))
break
}
case 'year': {
d = new Date(now.setFullYear(now.getFullYear() - 1))
break
}
default: // 'week'
d = new Date(now.setDate(now.getDate() - 7))
}
return Math.floor(d.getTime() / 1000)
}

View File

@ -1,6 +1,6 @@
import { RouteDefinition, RouteSectionProps, createAsync, useLocation } from '@solidjs/router'
import { HttpStatusCode } from '@solidjs/start'
import { ErrorBoundary, Show, Suspense, createEffect, createSignal, on, onMount } from 'solid-js'
import { ErrorBoundary, Show, Suspense, createEffect, on, onMount } from 'solid-js'
import { FourOuFourView } from '~/components/Views/FourOuFour'
import { Loading } from '~/components/_shared/Loading'
import { gaIdentity } from '~/config'
@ -28,9 +28,14 @@ export const route: RouteDefinition = {
})
}
type ArticlePageProps = { article?: Shout; comments?: Reaction[]; votes?: Reaction[]; author?: Author }
export type ArticlePageProps = {
article?: Shout
comments?: Reaction[]
votes?: Reaction[]
author?: Author
}
type SlugPageProps = {
export type SlugPageProps = {
article?: Shout
comments?: Reaction[]
votes?: Reaction[]
@ -38,7 +43,7 @@ type SlugPageProps = {
topics: Topic[]
}
export default (props: RouteSectionProps<SlugPageProps>) => {
export default function ArticlePage(props: RouteSectionProps<SlugPageProps>) {
if (props.params.slug.startsWith('@')) {
console.debug('[routes] [slug]/[...tab] starts with @, render as author page')
const patchedProps = {
@ -66,7 +71,6 @@ export default (props: RouteSectionProps<SlugPageProps>) => {
function ArticlePage(props: RouteSectionProps<ArticlePageProps>) {
const loc = useLocation()
const { t } = useLocalize()
const [scrollToComments, setScrollToComments] = createSignal<boolean>(false)
const data = createAsync(async () => props.data?.article || (await fetchShout(props.params.slug)))
onMount(async () => {
@ -114,10 +118,9 @@ export default (props: RouteSectionProps<SlugPageProps>) => {
headerTitle={data()?.title || ''}
slug={data()?.slug}
cover={data()?.cover || ''}
scrollToComments={(value) => setScrollToComments(value)}
>
<ReactionsProvider>
<FullArticle article={data() as Shout} scrollToComments={scrollToComments()} />
<FullArticle article={data() as Shout} />
</ReactionsProvider>
</PageLayout>
</Show>

View File

@ -0,0 +1,3 @@
import ArticlePage from '~/routes/[slug]/[...tab]'
export default ArticlePage

View File

@ -11,40 +11,16 @@ import { ReactionsProvider } from '~/context/reactions'
import { useTopics } from '~/context/topics'
import { loadShouts } from '~/graphql/api/public'
import { LoadShoutsOptions, Shout, Topic } from '~/graphql/schema/core.gen'
import { FromPeriod, getFromDate } from '~/lib/fromPeriod'
import { SHOUTS_PER_PAGE } from '../(main)'
const paramPattern = /^(hot|likes)$/
export type FeedPeriod = 'week' | 'month' | 'year'
export type PeriodItem = {
value: FeedPeriod
value: FromPeriod
title: string
}
export type FeedSearchParams = {
period: FeedPeriod
}
const getFromDate = (period: FeedPeriod): number => {
const now = new Date()
let d: Date = now
switch (period) {
case 'week': {
d = new Date(now.setDate(now.getDate() - 7))
break
}
case 'year': {
d = new Date(now.setFullYear(now.getFullYear() - 1))
break
}
// case 'month': {
default: {
d = new Date(now.setMonth(now.getMonth() - 1))
break
}
}
return Math.floor(d.getTime() / 1000)
period: FromPeriod
}
const feedLoader = async (options: Partial<LoadShoutsOptions>, _client?: Client) => {
@ -60,6 +36,8 @@ export const route = {
}
}
const paramPattern = /^(hot|likes)$/
export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>) => {
const [searchParams] = useSearchParams<FeedSearchParams>() // ?period=month
const { t } = useLocalize()
@ -90,7 +68,7 @@ export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>)
// ?period=month - time period filter
if (searchParams?.period) {
const period = searchParams?.period || 'month'
options.filters = { after: getFromDate(period as FeedPeriod) }
options.filters = { after: getFromDate(period as FromPeriod) }
}
const loaded = await feedLoader(options)

View File

@ -19,6 +19,7 @@ import {
} from '~/graphql/api/private'
import { graphqlClientCreate } from '~/graphql/client'
import { LoadShoutsOptions, Shout, Topic } from '~/graphql/schema/core.gen'
import { FromPeriod, getFromDate } from '~/lib/fromPeriod'
const feeds = {
followed: loadFollowedShouts,
@ -26,35 +27,13 @@ const feeds = {
coauthored: loadCoauthoredShouts,
unrated: loadUnratedShouts
}
export type FeedPeriod = 'week' | 'month' | 'year'
export type FeedSearchParams = { period?: FeedPeriod }
const paramModePattern = /^(followed|discussed|liked|coauthored|unrated)$/
const paramPattern = /(hot|likes)/
const getFromDate = (period: FeedPeriod): number => {
const now = new Date()
let d: Date = now
switch (period) {
case 'week': {
d = new Date(now.setDate(now.getDate() - 7))
break
}
case 'year': {
d = new Date(now.setFullYear(now.getFullYear() - 1))
break
}
// case 'month':
default: {
d = new Date(now.setMonth(now.getMonth() - 1))
break
}
}
return Math.floor(d.getTime() / 1000)
}
export type FeedSearchParams = { period?: FromPeriod }
// /feed/my/followed/hot
const paramModePattern = /^(followed|discussed|liked|coauthored|unrated)$/
const paramOrderPattern = /^(hot|likes)$/
export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>) => {
const [searchParams] = useSearchParams<FeedSearchParams>() // ?period=month
const { t } = useLocalize()
@ -75,7 +54,7 @@ export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>)
const order = createMemo(() => {
return (
(paramPattern.test(props.params.order)
(paramOrderPattern.test(props.params.order)
? props.params.order === 'hot'
? 'last_comment'
: props.params.order
@ -97,7 +76,7 @@ export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>)
// ?period=month - time period filter
if (searchParams?.period) {
const period = searchParams?.period || 'month'
options.filters = { after: getFromDate(period as FeedPeriod) }
options.filters = { after: getFromDate(period as FromPeriod) }
}
const shoutsLoader = gqlHandler(client(), options)

View File

@ -5,7 +5,6 @@ import { type Page, expect, test } from '@playwright/test'
/* Global starting test config */
let page: Page
function httpsGet(url: string): Promise<void> {
return new Promise((resolve, reject) => {
https

View File

@ -1,14 +1,13 @@
// biome-ignore lint/correctness/noNodejsModules: <explanation>
import path from 'node:path'
import { CSSOptions } from 'vite'
// import { visualizer } from 'rollup-plugin-visualizer'
import mkcert from 'vite-plugin-mkcert'
import { PolyfillOptions, nodePolyfills } from 'vite-plugin-node-polyfills'
import sassDts from 'vite-plugin-sass-dts'
// import { visualizer } from 'rollup-plugin-visualizer'
const isVercel = Boolean(process?.env.VERCEL)
const isNetlify = Boolean(process?.env.NETLIFY)
const isBun = Boolean(process.env.BUN)
export const runtime = isNetlify ? 'netlify' : isVercel ? 'vercel_edge' : isBun ? 'bun' : 'node'
console.info(`[app.config] build for ${runtime}!`)
const isDev = process.env.NODE_ENV !== 'production'
console.log(`[vite.config] development mode: ${isDev}`)
const polyfillOptions = {
include: ['path', 'stream', 'util'],
@ -23,8 +22,13 @@ const polyfillOptions = {
} as PolyfillOptions
export default {
resolve: {
alias: {
'~': path.resolve(__dirname, './src')
}
},
envPrefix: 'PUBLIC_',
plugins: [!isVercel && mkcert(), nodePolyfills(polyfillOptions), sassDts()],
plugins: [isDev && mkcert(), nodePolyfills(polyfillOptions), sassDts()],
css: {
preprocessorOptions: {
scss: {