sass-fixes+minieditor-storybooked
This commit is contained in:
commit
ebed7f38c3
|
@ -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
38
api/jsonify.js
Normal 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' })
|
||||
}
|
||||
}
|
|
@ -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
29485
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
|
@ -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"
|
||||
|
|
|
@ -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 |
10
src/app.tsx
10
src/app.tsx
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
64
src/components/Editor/MiniEditor/MiniEditor.stories.tsx
Normal file
64
src/components/Editor/MiniEditor/MiniEditor.stories.tsx
Normal 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...'
|
||||
}
|
||||
}
|
191
src/components/Editor/MiniEditor/MiniEditor.tsx
Normal file
191
src/components/Editor/MiniEditor/MiniEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 })}>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()} />
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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>()
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
103
src/lib/editorOptions.ts
Normal 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
19
src/lib/fromPeriod.ts
Normal 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)
|
||||
}
|
|
@ -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>
|
||||
|
|
3
src/routes/articles/[topic]/[slug].tsx
Normal file
3
src/routes/articles/[topic]/[slug].tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
import ArticlePage from '~/routes/[slug]/[...tab]'
|
||||
|
||||
export default ArticlePage
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
Loading…
Reference in New Issue
Block a user