sass-fixes+minieditor-storybooked
This commit is contained in:
commit
ebed7f38c3
|
@ -7,8 +7,7 @@ const config: StorybookConfig = {
|
||||||
'@storybook/addon-essentials',
|
'@storybook/addon-essentials',
|
||||||
'@storybook/addon-interactions',
|
'@storybook/addon-interactions',
|
||||||
'@storybook/addon-a11y',
|
'@storybook/addon-a11y',
|
||||||
'@storybook/addon-themes',
|
'@storybook/addon-themes'
|
||||||
'@storybook/addon-style-config'
|
|
||||||
],
|
],
|
||||||
framework: {
|
framework: {
|
||||||
name: 'storybook-solidjs-vite',
|
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 { 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({
|
export default defineConfig({
|
||||||
nitro: {
|
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-essentials": "^8.3.0",
|
||||||
"@storybook/addon-interactions": "^8.3.0",
|
"@storybook/addon-interactions": "^8.3.0",
|
||||||
"@storybook/addon-links": "^8.3.0",
|
"@storybook/addon-links": "^8.3.0",
|
||||||
"@storybook/addon-styling": "1.3.7",
|
|
||||||
"@storybook/addon-themes": "^8.3.0",
|
"@storybook/addon-themes": "^8.3.0",
|
||||||
"@storybook/addon-viewport": "^8.3.0",
|
"@storybook/addon-viewport": "^8.3.0",
|
||||||
"@storybook/blocks": "^8.3.0",
|
"@storybook/blocks": "^8.3.0",
|
||||||
"@storybook/builder-vite": "8.2.9",
|
"@storybook/builder-vite": "^8.3.0",
|
||||||
"@storybook/docs-tools": "8.2.9",
|
"@storybook/docs-tools": "^8.3.0",
|
||||||
"@storybook/html": "^8.3.0",
|
"@storybook/html": "^8.3.0",
|
||||||
"@storybook/react": "^8.3.0",
|
"@storybook/react": "^8.3.0",
|
||||||
"@storybook/test-runner": "^0.19.1",
|
"@storybook/test-runner": "^0.19.1",
|
||||||
|
@ -85,6 +84,7 @@
|
||||||
"@tiptap/extension-text": "^2.6.6",
|
"@tiptap/extension-text": "^2.6.6",
|
||||||
"@tiptap/extension-underline": "^2.6.6",
|
"@tiptap/extension-underline": "^2.6.6",
|
||||||
"@tiptap/extension-youtube": "^2.6.6",
|
"@tiptap/extension-youtube": "^2.6.6",
|
||||||
|
"@tiptap/starter-kit": "^2.6.6",
|
||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/cookie-signature": "^1.1.2",
|
"@types/cookie-signature": "^1.1.2",
|
||||||
"@types/node": "^22.5.5",
|
"@types/node": "^22.5.5",
|
||||||
|
@ -112,7 +112,7 @@
|
||||||
"sass": "1.76.0",
|
"sass": "1.76.0",
|
||||||
"solid-js": "^1.8.22",
|
"solid-js": "^1.8.22",
|
||||||
"solid-popper": "^0.3.0",
|
"solid-popper": "^0.3.0",
|
||||||
"solid-tiptap": "0.7.0",
|
"solid-tiptap": "^0.7.0",
|
||||||
"solid-transition-group": "^0.2.3",
|
"solid-transition-group": "^0.2.3",
|
||||||
"storybook": "^8.3.0",
|
"storybook": "^8.3.0",
|
||||||
"storybook-solidjs": "^1.0.0-beta.2",
|
"storybook-solidjs": "^1.0.0-beta.2",
|
||||||
|
@ -123,6 +123,7 @@
|
||||||
"stylelint-order": "^6.0.4",
|
"stylelint-order": "^6.0.4",
|
||||||
"stylelint-scss": "^6.6.0",
|
"stylelint-scss": "^6.6.0",
|
||||||
"swiper": "^11.1.14",
|
"swiper": "^11.1.14",
|
||||||
|
"terracotta": "^1.0.6",
|
||||||
"throttle-debounce": "^5.0.2",
|
"throttle-debounce": "^5.0.2",
|
||||||
"tslib": "^2.7.0",
|
"tslib": "^2.7.0",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
|
@ -133,11 +134,12 @@
|
||||||
"vite-plugin-node-polyfills": "^0.22.0",
|
"vite-plugin-node-polyfills": "^0.22.0",
|
||||||
"vite-plugin-sass-dts": "^1.3.29",
|
"vite-plugin-sass-dts": "^1.3.29",
|
||||||
"y-prosemirror": "1.2.12",
|
"y-prosemirror": "1.2.12",
|
||||||
"yjs": "13.6.18"
|
"yjs": "13.6.19"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"yjs": "13.6.18",
|
"yjs": "13.6.19",
|
||||||
"y-prosemirror": "1.2.12"
|
"y-prosemirror": "1.2.12",
|
||||||
|
"prosemirror-view": "1.34.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20"
|
"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 { FileRoutes } from '@solidjs/start/router'
|
||||||
import { type JSX, Suspense } from 'solid-js'
|
import { type JSX, Suspense } from 'solid-js'
|
||||||
|
|
||||||
|
import { AuthToken } from '@authorizerdev/authorizer-js'
|
||||||
import { Loading } from './components/_shared/Loading'
|
import { Loading } from './components/_shared/Loading'
|
||||||
import { AuthorsProvider } from './context/authors'
|
import { AuthorsProvider } from './context/authors'
|
||||||
import { EditorProvider } from './context/editor'
|
import { EditorProvider } from './context/editor'
|
||||||
|
@ -10,13 +11,18 @@ import { FeedProvider } from './context/feed'
|
||||||
import { LocalizeProvider } from './context/localize'
|
import { LocalizeProvider } from './context/localize'
|
||||||
import { SessionProvider } from './context/session'
|
import { SessionProvider } from './context/session'
|
||||||
import { TopicsProvider } from './context/topics'
|
import { TopicsProvider } from './context/topics'
|
||||||
import { UIProvider } from './context/ui' // snackbar included
|
import { UIProvider } from './context/ui'
|
||||||
|
|
||||||
import '~/styles/app.scss'
|
import '~/styles/app.scss'
|
||||||
|
|
||||||
export const Providers = (props: { children?: JSX.Element }) => {
|
export const Providers = (props: { children?: JSX.Element }) => {
|
||||||
|
const sessionStateChanged = (payload: AuthToken) => {
|
||||||
|
console.debug(payload)
|
||||||
|
// TODO: maybe load subs here
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<LocalizeProvider>
|
<LocalizeProvider>
|
||||||
<SessionProvider onStateChangeCallback={console.info}>
|
<SessionProvider onStateChangeCallback={sessionStateChanged}>
|
||||||
<TopicsProvider>
|
<TopicsProvider>
|
||||||
<FeedProvider>
|
<FeedProvider>
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
|
|
|
@ -47,7 +47,7 @@ export const Comment = (props: Props) => {
|
||||||
const [editedBody, setEditedBody] = createSignal<string>()
|
const [editedBody, setEditedBody] = createSignal<string>()
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const { createReaction, updateReaction } = useReactions()
|
const { createShoutReaction, updateShoutReaction } = useReactions()
|
||||||
const { showConfirm } = useUI()
|
const { showConfirm } = useUI()
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||||
|
@ -99,7 +99,7 @@ export const Comment = (props: Props) => {
|
||||||
const handleCreate = async (value: string) => {
|
const handleCreate = async (value: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
await createReaction({
|
await createShoutReaction({
|
||||||
reaction: {
|
reaction: {
|
||||||
kind: ReactionKind.Comment,
|
kind: ReactionKind.Comment,
|
||||||
reply_to: props.comment.id,
|
reply_to: props.comment.id,
|
||||||
|
@ -123,7 +123,7 @@ export const Comment = (props: Props) => {
|
||||||
const handleUpdate = async (value: string) => {
|
const handleUpdate = async (value: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const reaction = await updateReaction({
|
const reaction = await updateShoutReaction({
|
||||||
reaction: {
|
reaction: {
|
||||||
id: props.comment.id || 0,
|
id: props.comment.id || 0,
|
||||||
kind: ReactionKind.Comment,
|
kind: ReactionKind.Comment,
|
||||||
|
|
|
@ -22,7 +22,7 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
const uid = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
|
const uid = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
|
const { reactionEntities, createShoutReaction, deleteShoutReaction, loadReactionsBy } = useReactions()
|
||||||
|
|
||||||
const checkReaction = (reactionKind: ReactionKind) =>
|
const checkReaction = (reactionKind: ReactionKind) =>
|
||||||
Object.values(reactionEntities).some(
|
Object.values(reactionEntities).some(
|
||||||
|
@ -53,7 +53,7 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
r.shout.id === props.comment.shout.id &&
|
r.shout.id === props.comment.shout.id &&
|
||||||
r.reply_to === props.comment.id
|
r.reply_to === props.comment.id
|
||||||
)
|
)
|
||||||
if (reactionToDelete) return deleteReaction(reactionToDelete.id)
|
if (reactionToDelete) return deleteShoutReaction(reactionToDelete.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRatingChange = async (isUpvote: boolean) => {
|
const handleRatingChange = async (isUpvote: boolean) => {
|
||||||
|
@ -63,7 +63,7 @@ export const CommentRatingControl = (props: Props) => {
|
||||||
} else if (isDownvoted()) {
|
} else if (isDownvoted()) {
|
||||||
await deleteCommentReaction(ReactionKind.Dislike)
|
await deleteCommentReaction(ReactionKind.Dislike)
|
||||||
} else {
|
} else {
|
||||||
await createReaction({
|
await createShoutReaction({
|
||||||
reaction: {
|
reaction: {
|
||||||
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
||||||
shout: props.comment.shout.id,
|
shout: props.comment.shout.id,
|
||||||
|
|
|
@ -29,10 +29,10 @@ export const CommentsTree = (props: Props) => {
|
||||||
const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
|
const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
|
||||||
const [clearEditor, setClearEditor] = createSignal(false)
|
const [clearEditor, setClearEditor] = createSignal(false)
|
||||||
const [clickedReplyId, setClickedReplyId] = createSignal<number>()
|
const [clickedReplyId, setClickedReplyId] = createSignal<number>()
|
||||||
const { reactionEntities, createReaction, loadReactionsBy } = useReactions()
|
const { reactionEntities, createShoutReaction, loadReactionsBy } = useReactions()
|
||||||
|
|
||||||
const comments = createMemo(() =>
|
const comments = createMemo(() =>
|
||||||
Object.values(reactionEntities).filter((reaction) => reaction.kind === 'COMMENT')
|
Object.values(reactionEntities()).filter((reaction) => reaction.kind === 'COMMENT')
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortedComments = createMemo(() => {
|
const sortedComments = createMemo(() => {
|
||||||
|
@ -74,7 +74,7 @@ export const CommentsTree = (props: Props) => {
|
||||||
const handleSubmitComment = async (value: string) => {
|
const handleSubmitComment = async (value: string) => {
|
||||||
setPosting(true)
|
setPosting(true)
|
||||||
try {
|
try {
|
||||||
await createReaction({
|
await createShoutReaction({
|
||||||
reaction: {
|
reaction: {
|
||||||
kind: ReactionKind.Comment,
|
kind: ReactionKind.Comment,
|
||||||
body: value,
|
body: value,
|
||||||
|
@ -158,11 +158,11 @@ export const CommentsTree = (props: Props) => {
|
||||||
<SimplifiedEditor
|
<SimplifiedEditor
|
||||||
quoteEnabled={true}
|
quoteEnabled={true}
|
||||||
imageEnabled={true}
|
imageEnabled={true}
|
||||||
autoFocus={false}
|
options={{ autofocus: false }}
|
||||||
submitByCtrlEnter={true}
|
submitByCtrlEnter={true}
|
||||||
placeholder={t('Write a comment...')}
|
placeholder={t('Write a comment...')}
|
||||||
onSubmit={(value) => handleSubmitComment(value)}
|
onSubmit={(value) => handleSubmitComment(value)}
|
||||||
setClear={clearEditor()}
|
reset={clearEditor()}
|
||||||
isPosting={posting()}
|
isPosting={posting()}
|
||||||
/>
|
/>
|
||||||
</ShowIfAuthenticated>
|
</ShowIfAuthenticated>
|
||||||
|
|
|
@ -38,7 +38,6 @@ import { ShoutRatingControl } from './ShoutRatingControl'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
article: Shout
|
article: Shout
|
||||||
scrollToComments?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type IframeSize = {
|
type IframeSize = {
|
||||||
|
@ -47,8 +46,7 @@ type IframeSize = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ArticlePageSearchParams = {
|
export type ArticlePageSearchParams = {
|
||||||
scrollTo: 'comments'
|
commentId?: string
|
||||||
commentId: string
|
|
||||||
slide?: string
|
slide?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,7 +65,7 @@ export const COMMENTS_PER_PAGE = 30
|
||||||
const VOTES_PER_PAGE = 50
|
const VOTES_PER_PAGE = 50
|
||||||
|
|
||||||
export const FullArticle = (props: Props) => {
|
export const FullArticle = (props: Props) => {
|
||||||
const [searchParams, changeSearchParams] = useSearchParams<ArticlePageSearchParams>()
|
const [searchParams] = useSearchParams<ArticlePageSearchParams>()
|
||||||
const { showModal } = useUI()
|
const { showModal } = useUI()
|
||||||
const { loadReactionsBy } = useReactions()
|
const { loadReactionsBy } = useReactions()
|
||||||
const [selectedImage, setSelectedImage] = createSignal('')
|
const [selectedImage, setSelectedImage] = createSignal('')
|
||||||
|
@ -83,18 +81,20 @@ export const FullArticle = (props: Props) => {
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
pages,
|
pages,
|
||||||
async (p: Record<string, number>) => {
|
(p: Record<string, number>) => {
|
||||||
await loadReactionsBy({
|
console.debug('content paginated')
|
||||||
|
loadReactionsBy({
|
||||||
by: { shout: props.article.slug, comment: true },
|
by: { shout: props.article.slug, comment: true },
|
||||||
limit: COMMENTS_PER_PAGE,
|
limit: COMMENTS_PER_PAGE,
|
||||||
offset: COMMENTS_PER_PAGE * p.comments || 0
|
offset: COMMENTS_PER_PAGE * p.comments || 0
|
||||||
})
|
})
|
||||||
await loadReactionsBy({
|
loadReactionsBy({
|
||||||
by: { shout: props.article.slug, rating: true },
|
by: { shout: props.article.slug, rating: true },
|
||||||
limit: VOTES_PER_PAGE,
|
limit: VOTES_PER_PAGE,
|
||||||
offset: VOTES_PER_PAGE * p.rating || 0
|
offset: VOTES_PER_PAGE * p.rating || 0
|
||||||
})
|
})
|
||||||
setIsReactionsLoaded(true)
|
setIsReactionsLoaded(true)
|
||||||
|
console.debug('reactions paginated')
|
||||||
},
|
},
|
||||||
{ defer: true }
|
{ defer: true }
|
||||||
)
|
)
|
||||||
|
@ -165,15 +165,16 @@ export const FullArticle = (props: Props) => {
|
||||||
const media = createMemo<MediaItem[]>(() => JSON.parse(props.article.media || '[]'))
|
const media = createMemo<MediaItem[]>(() => JSON.parse(props.article.media || '[]'))
|
||||||
|
|
||||||
let commentsRef: HTMLDivElement | undefined
|
let commentsRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (searchParams?.commentId && isReactionsLoaded()) {
|
if (searchParams?.commentId && isReactionsLoaded()) {
|
||||||
const commentElement = document.querySelector<HTMLElement>(
|
console.debug('comment id is in link, scroll to')
|
||||||
`[id='comment_${searchParams?.commentId}']`
|
const scrollToElement =
|
||||||
)
|
document.querySelector<HTMLElement>(`[id='comment_${searchParams?.commentId}']`) ||
|
||||||
|
commentsRef ||
|
||||||
|
document.body
|
||||||
|
|
||||||
if (commentElement) {
|
if (scrollToElement) {
|
||||||
requestAnimationFrame(() => scrollTo(commentElement))
|
requestAnimationFrame(() => scrollTo(scrollToElement))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -316,14 +317,6 @@ export const FullArticle = (props: Props) => {
|
||||||
onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
|
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 shareUrl = createMemo(() => getShareUrl({ pathname: `/${props.article.slug || ''}` }))
|
||||||
const getAuthorName = (a: Author) =>
|
const getAuthorName = (a: Author) =>
|
||||||
lang() === 'en' && isCyrillic(a.name || '') ? capitalize(a.slug.replaceAll('-', ' ')) : a.name
|
lang() === 'en' && isCyrillic(a.name || '') ? capitalize(a.slug.replaceAll('-', ' ')) : a.name
|
||||||
|
|
|
@ -22,11 +22,11 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
|
||||||
const { loadShout } = useFeed()
|
const { loadShout } = useFeed()
|
||||||
const { requireAuthentication, session } = useSession()
|
const { requireAuthentication, session } = useSession()
|
||||||
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
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 [isLoading, setIsLoading] = createSignal(false)
|
||||||
|
|
||||||
const checkReaction = (reactionKind: ReactionKind) =>
|
const checkReaction = (reactionKind: ReactionKind) =>
|
||||||
Object.values(reactionEntities).some(
|
Object.values(reactionEntities()).some(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.kind === reactionKind &&
|
r.kind === reactionKind &&
|
||||||
r.created_by.id === author()?.id &&
|
r.created_by.id === author()?.id &&
|
||||||
|
@ -38,12 +38,12 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
|
||||||
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
|
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
|
||||||
|
|
||||||
const shoutRatingReactions = createMemo(() =>
|
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
|
(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(
|
const reactionToDelete = Object.values(reactionEntities).find(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.kind === reactionKind &&
|
r.kind === reactionKind &&
|
||||||
|
@ -51,18 +51,18 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
|
||||||
r.shout.id === props.shout.id &&
|
r.shout.id === props.shout.id &&
|
||||||
!r.reply_to
|
!r.reply_to
|
||||||
)
|
)
|
||||||
if (reactionToDelete) return deleteReaction(reactionToDelete.id)
|
if (reactionToDelete) return deleteShoutReaction(reactionToDelete.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRatingChange = (isUpvote: boolean) => {
|
const handleRatingChange = (isUpvote: boolean) => {
|
||||||
requireAuthentication(async () => {
|
requireAuthentication(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
if (isUpvoted()) {
|
if (isUpvoted()) {
|
||||||
await deleteShoutReaction(ReactionKind.Like)
|
await removeReaction(ReactionKind.Like)
|
||||||
} else if (isDownvoted()) {
|
} else if (isDownvoted()) {
|
||||||
await deleteShoutReaction(ReactionKind.Dislike)
|
await removeReaction(ReactionKind.Dislike)
|
||||||
} else {
|
} else {
|
||||||
await createReaction({
|
await createShoutReaction({
|
||||||
reaction: {
|
reaction: {
|
||||||
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
|
||||||
shout: props.shout.id
|
shout: props.shout.id
|
||||||
|
|
|
@ -29,17 +29,16 @@ import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'sol
|
||||||
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
|
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
|
||||||
import uniqolor from 'uniqolor'
|
import uniqolor from 'uniqolor'
|
||||||
import { Doc } from 'yjs'
|
import { Doc } from 'yjs'
|
||||||
|
|
||||||
import { useEditorContext } from '~/context/editor'
|
import { useEditorContext } from '~/context/editor'
|
||||||
import { useLocalize } from '~/context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useSession } from '~/context/session'
|
import { useSession } from '~/context/session'
|
||||||
import { useSnackbar } from '~/context/ui'
|
import { useSnackbar } from '~/context/ui'
|
||||||
|
import { Author } from '~/graphql/schema/core.gen'
|
||||||
import { handleImageUpload } from '~/lib/handleImageUpload'
|
import { handleImageUpload } from '~/lib/handleImageUpload'
|
||||||
|
|
||||||
import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
|
import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
|
||||||
import { EditorFloatingMenu } from './EditorFloatingMenu'
|
import { EditorFloatingMenu } from './EditorFloatingMenu'
|
||||||
import { TextBubbleMenu } from './TextBubbleMenu'
|
import { TextBubbleMenu } from './TextBubbleMenu'
|
||||||
import Article from './extensions/Article'
|
import { ArticleNode } from './extensions/Article'
|
||||||
import { CustomBlockquote } from './extensions/CustomBlockquote'
|
import { CustomBlockquote } from './extensions/CustomBlockquote'
|
||||||
import { Figcaption } from './extensions/Figcaption'
|
import { Figcaption } from './extensions/Figcaption'
|
||||||
import { Figure } from './extensions/Figure'
|
import { Figure } from './extensions/Figure'
|
||||||
|
@ -50,7 +49,7 @@ import { ToggleTextWrap } from './extensions/ToggleTextWrap'
|
||||||
import { TrailingNode } from './extensions/TrailingNode'
|
import { TrailingNode } from './extensions/TrailingNode'
|
||||||
|
|
||||||
import './Prosemirror.scss'
|
import './Prosemirror.scss'
|
||||||
import { Author } from '~/graphql/schema/core.gen'
|
import { renderUploadedImage } from './renderUploadedImage'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
shoutId: number
|
shoutId: number
|
||||||
|
@ -124,26 +123,8 @@ export const EditorComponent = (props: Props) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
showSnackbar({ body: t('Uploading image') })
|
showSnackbar({ body: t('Uploading image') })
|
||||||
const result = await handleImageUpload(uplFile, session()?.access_token || '')
|
const image = await handleImageUpload(uplFile, session()?.access_token || '')
|
||||||
|
renderUploadedImage(editor() as Editor, image)
|
||||||
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()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Paste Image Error]:', error)
|
console.error('[Paste Image Error]:', error)
|
||||||
}
|
}
|
||||||
|
@ -293,7 +274,7 @@ export const EditorComponent = (props: Props) => {
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
Article
|
ArticleNode
|
||||||
],
|
],
|
||||||
onTransaction: ({ transaction }) => {
|
onTransaction: ({ transaction }) => {
|
||||||
if (transaction.docChanged) {
|
if (transaction.docChanged) {
|
||||||
|
|
|
@ -40,7 +40,6 @@ export const InsertLinkForm = (props: Props) => {
|
||||||
.setLink({ href: checkUrl(value) })
|
.setLink({ href: checkUrl(value) })
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<InlineForm
|
<InlineForm
|
||||||
|
@ -49,7 +48,7 @@ export const InsertLinkForm = (props: Props) => {
|
||||||
onClear={handleClearLinkForm}
|
onClear={handleClearLinkForm}
|
||||||
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
|
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
|
||||||
onSubmit={handleLinkFormSubmit}
|
onSubmit={handleLinkFormSubmit}
|
||||||
onClose={() => props.onClose()}
|
onClose={props.onClose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { Blockquote } from '@tiptap/extension-blockquote'
|
||||||
import { Bold } from '@tiptap/extension-bold'
|
import { Bold } from '@tiptap/extension-bold'
|
||||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
||||||
|
@ -11,7 +10,7 @@ import { Paragraph } from '@tiptap/extension-paragraph'
|
||||||
import { Placeholder } from '@tiptap/extension-placeholder'
|
import { Placeholder } from '@tiptap/extension-placeholder'
|
||||||
import { Text } from '@tiptap/extension-text'
|
import { Text } from '@tiptap/extension-text'
|
||||||
import { clsx } from 'clsx'
|
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 { Portal } from 'solid-js/web'
|
||||||
import {
|
import {
|
||||||
createEditorTransaction,
|
createEditorTransaction,
|
||||||
|
@ -20,23 +19,26 @@ import {
|
||||||
useEditorIsEmpty,
|
useEditorIsEmpty,
|
||||||
useEditorIsFocused
|
useEditorIsFocused
|
||||||
} from 'solid-tiptap'
|
} from 'solid-tiptap'
|
||||||
|
|
||||||
import { useEditorContext } from '~/context/editor'
|
import { useEditorContext } from '~/context/editor'
|
||||||
import { useLocalize } from '~/context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useUI } from '~/context/ui'
|
|
||||||
import { UploadedFile } from '~/types/upload'
|
import { UploadedFile } from '~/types/upload'
|
||||||
import { Button } from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { Loading } from '../_shared/Loading'
|
import { Loading } from '../_shared/Loading'
|
||||||
import { Modal } from '../_shared/Modal'
|
|
||||||
import { Popover } from '../_shared/Popover'
|
import { Popover } from '../_shared/Popover'
|
||||||
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
|
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
|
||||||
import styles from './SimplifiedEditor.module.scss'
|
|
||||||
import { TextBubbleMenu } from './TextBubbleMenu'
|
import { TextBubbleMenu } from './TextBubbleMenu'
|
||||||
import { UploadModalContent } from './UploadModalContent'
|
import { UploadModalContent } from './UploadModalContent'
|
||||||
import { Figcaption } from './extensions/Figcaption'
|
import { Figcaption } from './extensions/Figcaption'
|
||||||
import { Figure } from './extensions/Figure'
|
import { Figure } from './extensions/Figure'
|
||||||
|
|
||||||
|
import { Editor } from '@tiptap/core'
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
|
import { Modal } from '../_shared/Modal/Modal'
|
||||||
|
import styles from './SimplifiedEditor.module.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
placeholder: string
|
placeholder: string
|
||||||
initialContent?: string
|
initialContent?: string
|
||||||
|
@ -69,27 +71,103 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
const { showModal, hideModal } = useUI()
|
const { showModal, hideModal } = useUI()
|
||||||
const [counter, setCounter] = createSignal<number>(0)
|
const [counter, setCounter] = createSignal<number>(0)
|
||||||
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
|
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
|
||||||
|
const isCancelButtonVisible = createMemo(() => props.isCancelButtonVisible !== false)
|
||||||
|
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
|
||||||
const { editor, setEditor } = useEditorContext()
|
const { editor, setEditor } = useEditorContext()
|
||||||
|
|
||||||
const maxLength = props.maxLength ?? DEFAULT_MAX_LENGTH
|
const maxLength = props.maxLength ?? DEFAULT_MAX_LENGTH
|
||||||
let editorEl: HTMLDivElement | undefined
|
|
||||||
let wrapperEditorElRef: HTMLElement | undefined
|
let wrapperEditorElRef: HTMLElement | undefined
|
||||||
let textBubbleMenuRef: HTMLDivElement | undefined
|
let textBubbleMenuRef: HTMLDivElement | undefined
|
||||||
let linkBubbleMenuRef: HTMLDivElement | undefined
|
let linkBubbleMenuRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
// Extend the Figure extension to include Figcaption
|
|
||||||
const ImageFigure = Figure.extend({
|
const ImageFigure = Figure.extend({
|
||||||
name: 'capturedImage',
|
name: 'capturedImage',
|
||||||
content: 'figcaption image'
|
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 isEmpty = useEditorIsEmpty(() => editor())
|
||||||
const isFocused = useEditorIsFocused(() => editor())
|
const isFocused = useEditorIsFocused(() => editor())
|
||||||
|
|
||||||
const isActive = (name: string) =>
|
const isActive = (name: string) =>
|
||||||
createEditorTransaction(
|
createEditorTransaction(
|
||||||
() => editor(),
|
() => editor(),
|
||||||
(ed) => ed?.isActive(name)
|
(ed) => {
|
||||||
|
return ed?.isActive(name)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const html = useEditorHTML(() => editor())
|
const html = useEditorHTML(() => editor())
|
||||||
|
@ -127,6 +205,16 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
editor()?.commands.clearContent(true)
|
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) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (isEmpty() || !isFocused()) {
|
if (isEmpty() || !isFocused()) {
|
||||||
return
|
return
|
||||||
|
@ -155,89 +243,19 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
window.removeEventListener('keydown', handleKeyDown)
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
editor()?.destroy()
|
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(
|
if (props.onChange) {
|
||||||
on(
|
createEffect(() => {
|
||||||
editor,
|
props.onChange?.(html() || '')
|
||||||
(e) => {
|
})
|
||||||
e?.commands.clearContent(props.resetToInitial || props.setClear)
|
}
|
||||||
props.initialContent && e?.commands.setContent(props.initialContent)
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(() => {
|
||||||
on(
|
if (html()) {
|
||||||
html,
|
setCounter(editor()?.storage.characterCount.characters())
|
||||||
(content) => {
|
}
|
||||||
content && setCounter(editor()?.storage.characterCount.characters())
|
})
|
||||||
props.onChange?.(content || '')
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const maxHeightStyle = {
|
const maxHeightStyle = {
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
|
@ -272,7 +290,7 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
<Show when={props.label && counter() > 0}>
|
<Show when={props.label && counter() > 0}>
|
||||||
<div class={styles.label}>{props.label}</div>
|
<div class={styles.label}>{props.label}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div style={props.maxHeight ? maxHeightStyle : undefined} ref={(el) => (editorEl = el)} />
|
<div style={props.maxHeight ? maxHeightStyle : undefined} ref={setEditorElement} />
|
||||||
<Show when={!props.onlyBubbleControls}>
|
<Show when={!props.onlyBubbleControls}>
|
||||||
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
|
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
|
@ -343,7 +361,7 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
<Show when={!props.onChange}>
|
<Show when={!props.onChange}>
|
||||||
<div class={styles.buttons}>
|
<div class={styles.buttons}>
|
||||||
<Show when={props.isCancelButtonVisible}>
|
<Show when={isCancelButtonVisible()}>
|
||||||
<Button value={t('Cancel')} variant="secondary" onClick={handleClear} />
|
<Button value={t('Cancel')} variant="secondary" onClick={handleClear} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!props.isPosting} fallback={<Loading />}>
|
<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',
|
name: 'article',
|
||||||
group: 'block',
|
group: 'block',
|
||||||
content: '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 { clsx } from 'clsx'
|
||||||
import { Accessor, For, Show, createMemo, createSignal } from 'solid-js'
|
import { Accessor, For, Show, createMemo, createSignal } from 'solid-js'
|
||||||
import { Icon } from '~/components/_shared/Icon'
|
import { Icon } from '~/components/_shared/Icon'
|
||||||
|
@ -7,7 +7,6 @@ import { Popover } from '~/components/_shared/Popover'
|
||||||
import { useLocalize } from '~/context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useSession } from '~/context/session'
|
import { useSession } from '~/context/session'
|
||||||
import type { Author, Maybe, Shout, Topic } from '~/graphql/schema/core.gen'
|
import type { Author, Maybe, Shout, Topic } from '~/graphql/schema/core.gen'
|
||||||
import { sentenceSeparator } from '~/intl/chars'
|
|
||||||
import { capitalize } from '~/utils/capitalize'
|
import { capitalize } from '~/utils/capitalize'
|
||||||
import { descFromBody } from '~/utils/meta'
|
import { descFromBody } from '~/utils/meta'
|
||||||
import { CoverImage } from '../../Article/CoverImage'
|
import { CoverImage } from '../../Article/CoverImage'
|
||||||
|
@ -56,7 +55,7 @@ const desktopCoverImageWidths: Record<string, number> = {
|
||||||
M: 600,
|
M: 600,
|
||||||
L: 800
|
L: 800
|
||||||
}
|
}
|
||||||
|
const titleSeparator = /{!|\?|:|;}\s/
|
||||||
const getTitleAndSubtitle = (
|
const getTitleAndSubtitle = (
|
||||||
article: Shout
|
article: Shout
|
||||||
): {
|
): {
|
||||||
|
@ -70,7 +69,7 @@ const getTitleAndSubtitle = (
|
||||||
let titleParts = article.title?.split('. ') || []
|
let titleParts = article.title?.split('. ') || []
|
||||||
|
|
||||||
if (titleParts?.length === 1) {
|
if (titleParts?.length === 1) {
|
||||||
titleParts = article.title?.split(sentenceSeparator) || []
|
titleParts = article.title?.split(titleSeparator) || []
|
||||||
}
|
}
|
||||||
|
|
||||||
if (titleParts && titleParts.length > 1) {
|
if (titleParts && titleParts.length > 1) {
|
||||||
|
@ -106,7 +105,6 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
const { t, lang, formatDate } = useLocalize()
|
const { t, lang, formatDate } = useLocalize()
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||||
const [, changeSearchParams] = useSearchParams()
|
|
||||||
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
|
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
|
||||||
const [isCoverImageLoadError, setIsCoverImageLoadError] = createSignal(false)
|
const [isCoverImageLoadError, setIsCoverImageLoadError] = createSignal(false)
|
||||||
const [isCoverImageLoading, setIsCoverImageLoading] = createSignal(true)
|
const [isCoverImageLoading, setIsCoverImageLoading] = createSignal(true)
|
||||||
|
@ -130,10 +128,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
|
|
||||||
const scrollToComments = (event: MouseEvent & { currentTarget: HTMLAnchorElement; target: Element }) => {
|
const scrollToComments = (event: MouseEvent & { currentTarget: HTMLAnchorElement; target: Element }) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
changeSearchParams({
|
navigate(`/${props.article.slug}?commentId=0`)
|
||||||
scrollTo: 'comments'
|
|
||||||
})
|
|
||||||
navigate(`/${props.article.slug}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onInvite = () => {
|
const onInvite = () => {
|
||||||
|
|
|
@ -23,7 +23,6 @@ type Props = {
|
||||||
isHeaderFixed?: boolean
|
isHeaderFixed?: boolean
|
||||||
desc?: string
|
desc?: string
|
||||||
cover?: string
|
cover?: string
|
||||||
scrollToComments?: (value: boolean) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type HeaderSearchParams = {
|
type HeaderSearchParams = {
|
||||||
|
@ -38,7 +37,7 @@ export const Header = (props: Props) => {
|
||||||
const { t, lang } = useLocalize()
|
const { t, lang } = useLocalize()
|
||||||
const { modal } = useUI()
|
const { modal } = useUI()
|
||||||
const { requireAuthentication } = useSession()
|
const { requireAuthentication } = useSession()
|
||||||
const [searchParams] = useSearchParams<HeaderSearchParams>()
|
const [searchParams, changeSearchParams] = useSearchParams<HeaderSearchParams>()
|
||||||
const [getIsScrollingBottom, setIsScrollingBottom] = createSignal(false)
|
const [getIsScrollingBottom, setIsScrollingBottom] = createSignal(false)
|
||||||
const [getIsScrolled, setIsScrolled] = createSignal(false)
|
const [getIsScrolled, setIsScrolled] = createSignal(false)
|
||||||
const [fixed, setFixed] = 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 }) => {
|
const handleBookmarkButtonClick = (ev: { preventDefault: () => void }) => {
|
||||||
requireAuthentication(() => {
|
requireAuthentication(() => {
|
||||||
// TODO: implement bookmark clicked
|
// 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" class={styles.icon} />
|
||||||
<Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} />
|
<Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
@mixin search-filter-control {
|
@mixin search-filter-control {
|
||||||
|
@include font-size(1.4rem);
|
||||||
|
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
padding: 0 2rem;
|
padding: 0 2rem;
|
||||||
background: rgb(64 64 64 / 50%);
|
background: rgb(64 64 64 / 50%);
|
||||||
|
@ -7,8 +9,6 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
@include font-size(1.4rem);
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #404040;
|
background: #404040;
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInput {
|
.searchInput {
|
||||||
|
@include font-size(4.8rem);
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 0 0.5rem;
|
padding: 0 0 0.5rem;
|
||||||
background: none;
|
background: none;
|
||||||
|
@ -32,8 +34,6 @@
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
@include font-size(4.8rem);
|
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: rgb(255 255 255 / 32%);
|
color: rgb(255 255 255 / 32%);
|
||||||
}
|
}
|
||||||
|
@ -60,10 +60,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchDescription {
|
.searchDescription {
|
||||||
|
@include font-size(1.6rem);
|
||||||
|
|
||||||
margin-bottom: 44px;
|
margin-bottom: 44px;
|
||||||
color: rgb(255 255 255 / 64%);
|
color: rgb(255 255 255 / 64%);
|
||||||
|
|
||||||
@include font-size(1.6rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topicsList {
|
.topicsList {
|
||||||
|
|
|
@ -44,7 +44,7 @@ export const FullTopic = (props: Props) => {
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (follows?.topics?.length !== 0) {
|
if (follows?.topics?.length ?? true) {
|
||||||
const items = follows.topics || []
|
const items = follows.topics || []
|
||||||
setFollowed(items.some((x: Topic) => x?.slug === props.topic?.slug))
|
setFollowed(items.some((x: Topic) => x?.slug === props.topic?.slug))
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ export const RandomTopics = () => {
|
||||||
const [randomTopics, setRandomTopics] = createSignal<Topic[]>([])
|
const [randomTopics, setRandomTopics] = createSignal<Topic[]>([])
|
||||||
createEffect(
|
createEffect(
|
||||||
on(sortedTopics, (ttt: Topic[]) => {
|
on(sortedTopics, (ttt: Topic[]) => {
|
||||||
if (ttt?.length) {
|
if (ttt?.length > 0) {
|
||||||
setRandomTopics(getRandomItemsFromArray(ttt))
|
setRandomTopics(getRandomItemsFromArray(ttt))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -56,7 +56,7 @@ export const AllAuthors = (props: Props) => {
|
||||||
|
|
||||||
// store by first char
|
// store by first char
|
||||||
const byLetterFiltered = createMemo<{ [letter: string]: Author[] }>(() => {
|
const byLetterFiltered = createMemo<{ [letter: string]: Author[] }>(() => {
|
||||||
if (!(filteredAuthors()?.length > 0)) return {}
|
if (!filteredAuthors()) return {}
|
||||||
console.debug('[components.AllAuthors] update byLetterFiltered', filteredAuthors()?.length)
|
console.debug('[components.AllAuthors] update byLetterFiltered', filteredAuthors()?.length)
|
||||||
return (
|
return (
|
||||||
filteredAuthors()?.reduce(
|
filteredAuthors()?.reduce(
|
||||||
|
|
|
@ -175,7 +175,7 @@ export const AuthorView = (props: AuthorViewProps) => {
|
||||||
const [loadMoreCommentsHidden, setLoadMoreCommentsHidden] = createSignal(
|
const [loadMoreCommentsHidden, setLoadMoreCommentsHidden] = createSignal(
|
||||||
Boolean(props.author?.stat && props.author?.stat?.comments === 0)
|
Boolean(props.author?.stat && props.author?.stat?.comments === 0)
|
||||||
)
|
)
|
||||||
const { commentsByAuthor, addReactions } = useReactions()
|
const { commentsByAuthor, addShoutReactions } = useReactions()
|
||||||
const loadMoreComments = async () => {
|
const loadMoreComments = async () => {
|
||||||
if (!author()) return [] as LoadMoreItems
|
if (!author()) return [] as LoadMoreItems
|
||||||
saveScrollPosition()
|
saveScrollPosition()
|
||||||
|
@ -189,7 +189,7 @@ export const AuthorView = (props: AuthorViewProps) => {
|
||||||
offset: commentsByAuthor()[aid]?.length || 0
|
offset: commentsByAuthor()[aid]?.length || 0
|
||||||
})
|
})
|
||||||
const result = await authorCommentsFetcher()
|
const result = await authorCommentsFetcher()
|
||||||
result && addReactions(result)
|
result && addShoutReactions(result)
|
||||||
restoreScrollPosition()
|
restoreScrollPosition()
|
||||||
return result as LoadMoreItems
|
return result as LoadMoreItems
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { createSignal } from 'solid-js'
|
import { Show, createSignal } from 'solid-js'
|
||||||
import { Show } from 'solid-js/web'
|
|
||||||
import { useLocalize } from '~/context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
|
|
||||||
export const ConnectView = () => {
|
export const ConnectView = () => {
|
||||||
|
|
|
@ -130,7 +130,7 @@ export const EditView = (props: Props) => {
|
||||||
draft,
|
draft,
|
||||||
(d) => {
|
(d) => {
|
||||||
if (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)
|
setForm(draftForm)
|
||||||
console.debug('draft from localstorage: ', draftForm)
|
console.debug('draft from localstorage: ', draftForm)
|
||||||
}
|
}
|
||||||
|
|
|
@ -118,7 +118,7 @@ export const FeedView = (props: FeedProps) => {
|
||||||
<Placeholder type={loc?.pathname} mode="feed" />
|
<Placeholder type={loc?.pathname} mode="feed" />
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={(session() || loc?.pathname === 'feed') && props.shouts?.length}>
|
<Show when={(session() || loc?.pathname === 'feed') && props.shouts}>
|
||||||
<div class={styles.filtersContainer}>
|
<div class={styles.filtersContainer}>
|
||||||
<ul class={clsx('view-switcher', styles.feedFilter)}>
|
<ul class={clsx('view-switcher', styles.feedFilter)}>
|
||||||
<li class={clsx({ 'view-switcher__item--selected': !props.order })}>
|
<li class={clsx({ 'view-switcher__item--selected': !props.order })}>
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
onMount
|
onMount
|
||||||
} from 'solid-js'
|
} from 'solid-js'
|
||||||
import { createStore } from 'solid-js/store'
|
import { createStore } from 'solid-js/store'
|
||||||
|
import SimplifiedEditor from '~/components/Editor/SimplifiedEditor'
|
||||||
import { useLocalize } from '~/context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useProfile } from '~/context/profile'
|
import { useProfile } from '~/context/profile'
|
||||||
import { useSession } from '~/context/session'
|
import { useSession } from '~/context/session'
|
||||||
|
@ -34,7 +35,7 @@ import { SocialNetworkInput } from '../../_shared/SocialNetworkInput'
|
||||||
import styles from './Settings.module.scss'
|
import styles from './Settings.module.scss'
|
||||||
import { profileSocialLinks } from './profileSocialLinks'
|
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'))
|
const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
|
||||||
|
|
||||||
function filterNulls(arr: InputMaybe<string>[]): string[] {
|
function filterNulls(arr: InputMaybe<string>[]): string[] {
|
||||||
|
@ -56,11 +57,11 @@ export const ProfileSettings = () => {
|
||||||
const [slugError, setSlugError] = createSignal<string>()
|
const [slugError, setSlugError] = createSignal<string>()
|
||||||
const [nameError, setNameError] = createSignal<string>()
|
const [nameError, setNameError] = createSignal<string>()
|
||||||
const { form, submit, updateFormField, setForm } = useProfile()
|
const { form, submit, updateFormField, setForm } = useProfile()
|
||||||
|
const [about, setAbout] = createSignal(form.about)
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
const { loadSession, session } = useSession()
|
const { loadSession, session } = useSession()
|
||||||
const [prevForm, setPrevForm] = createStore<ProfileInput>()
|
const [prevForm, setPrevForm] = createStore<ProfileInput>()
|
||||||
const { showConfirm } = useUI()
|
const { showConfirm } = useUI()
|
||||||
const [clearAbout, setClearAbout] = createSignal(false)
|
|
||||||
const { showModal, hideModal } = useUI()
|
const { showModal, hideModal } = useUI()
|
||||||
const [loading, setLoading] = createSignal(true)
|
const [loading, setLoading] = createSignal(true)
|
||||||
|
|
||||||
|
@ -111,6 +112,7 @@ export const ProfileSettings = () => {
|
||||||
try {
|
try {
|
||||||
await submit(form)
|
await submit(form)
|
||||||
setPrevForm(clone(form))
|
setPrevForm(clone(form))
|
||||||
|
setAbout(form.about)
|
||||||
showSnackbar({ body: t('Profile successfully saved') })
|
showSnackbar({ body: t('Profile successfully saved') })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.toString().search('duplicate_slug')) {
|
if (error?.toString().search('duplicate_slug')) {
|
||||||
|
@ -132,11 +134,7 @@ export const ProfileSettings = () => {
|
||||||
confirmButtonVariant: 'primary',
|
confirmButtonVariant: 'primary',
|
||||||
declineButtonVariant: 'secondary'
|
declineButtonVariant: 'secondary'
|
||||||
})
|
})
|
||||||
if (isConfirmed) {
|
isConfirmed && setForm(clone(prevForm))
|
||||||
setClearAbout(true)
|
|
||||||
setForm(clone(prevForm))
|
|
||||||
setClearAbout(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCropAvatar = () => {
|
const handleCropAvatar = () => {
|
||||||
|
@ -254,7 +252,7 @@ export const ProfileSettings = () => {
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* @@TODO inspect popover below. onClick causes page refreshing */}
|
{/* @@TODO inspect popover below. onClick causes page refreshing */}
|
||||||
{/* <Popover content={t('Upload userpic')}>
|
<Popover content={t('Upload userpic')}>
|
||||||
{(triggerRef: (el: HTMLElement) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
|
@ -264,7 +262,7 @@ export const ProfileSettings = () => {
|
||||||
<Icon name="user-image-black" />
|
<Icon name="user-image-black" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Popover> */}
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={!form.pic}>
|
<Match when={!form.pic}>
|
||||||
|
@ -284,18 +282,20 @@ export const ProfileSettings = () => {
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div class="pretty-form__item">
|
<div class="pretty-form__item">
|
||||||
<input
|
<label for="nameOfUser">
|
||||||
type="text"
|
<input
|
||||||
name="nameOfUser"
|
type="text"
|
||||||
id="nameOfUser"
|
name="nameOfUser"
|
||||||
data-lpignore="true"
|
id="nameOfUser"
|
||||||
autocomplete="one-time-code"
|
data-lpignore="true"
|
||||||
placeholder={t('Name')}
|
autocomplete="one-time-code"
|
||||||
onInput={(event) => updateFormField('name', event.currentTarget.value)}
|
placeholder={t('Name')}
|
||||||
value={form.name || ''}
|
onInput={(event) => updateFormField('name', event.currentTarget.value)}
|
||||||
ref={(el) => (nameInputRef = el)}
|
value={form.name || ''}
|
||||||
/>
|
ref={(el) => (nameInputRef = el)}
|
||||||
<label for="nameOfUser">{t('Name')}</label>
|
/>
|
||||||
|
{t('Name')}
|
||||||
|
</label>
|
||||||
<Show when={nameError()}>
|
<Show when={nameError()}>
|
||||||
<div
|
<div
|
||||||
style={{ position: 'absolute', 'margin-top': '-4px' }}
|
style={{ position: 'absolute', 'margin-top': '-4px' }}
|
||||||
|
@ -341,16 +341,16 @@ export const ProfileSettings = () => {
|
||||||
|
|
||||||
<h4>{t('About')}</h4>
|
<h4>{t('About')}</h4>
|
||||||
<SimplifiedEditor
|
<SimplifiedEditor
|
||||||
resetToInitial={clearAbout()}
|
resetToInitial={true}
|
||||||
noLimits={true}
|
noLimits={true}
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onlyBubbleControls={true}
|
onlyBubbleControls={true}
|
||||||
smallHeight={true}
|
smallHeight={true}
|
||||||
placeholder={t('About')}
|
|
||||||
label={t('About')}
|
label={t('About')}
|
||||||
initialContent={form.about || ''}
|
initialContent={about() || ''}
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
onChange={(value) => updateFormField('about', value)}
|
onChange={setAbout}
|
||||||
|
placeholder={t('About')}
|
||||||
/>
|
/>
|
||||||
<div class={clsx(styles.multipleControls, 'pretty-form__item')}>
|
<div class={clsx(styles.multipleControls, 'pretty-form__item')}>
|
||||||
<div class={styles.multipleControlsHeader}>
|
<div class={styles.multipleControlsHeader}>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
|
import { useNavigate } from '@solidjs/router'
|
||||||
import { clsx } from 'clsx'
|
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 { createStore } from 'solid-js/store'
|
||||||
|
|
||||||
import { Button } from '~/components/_shared/Button'
|
import { Button } from '~/components/_shared/Button'
|
||||||
import { Icon } from '~/components/_shared/Icon'
|
import { Icon } from '~/components/_shared/Icon'
|
||||||
import { Image } from '~/components/_shared/Image'
|
import { Image } from '~/components/_shared/Image'
|
||||||
|
@ -11,11 +11,10 @@ import { useSession } from '~/context/session'
|
||||||
import { useTopics } from '~/context/topics'
|
import { useTopics } from '~/context/topics'
|
||||||
import { useSnackbar, useUI } from '~/context/ui'
|
import { useSnackbar, useUI } from '~/context/ui'
|
||||||
import { Topic } from '~/graphql/schema/core.gen'
|
import { Topic } from '~/graphql/schema/core.gen'
|
||||||
|
import { UploadedFile } from '~/types/upload'
|
||||||
import { TopicSelect, UploadModalContent } from '../../Editor'
|
import { TopicSelect, UploadModalContent } from '../../Editor'
|
||||||
import { Modal } from '../../_shared/Modal'
|
import { Modal } from '../../_shared/Modal'
|
||||||
|
|
||||||
import { useNavigate } from '@solidjs/router'
|
|
||||||
import { UploadedFile } from '~/types/upload'
|
|
||||||
import stylesBeside from '../../Feed/Beside.module.scss'
|
import stylesBeside from '../../Feed/Beside.module.scss'
|
||||||
import styles from './PublishSettings.module.scss'
|
import styles from './PublishSettings.module.scss'
|
||||||
|
|
||||||
|
@ -77,7 +76,7 @@ export const PublishSettings = (props: Props) => {
|
||||||
return props.form.description
|
return props.form.description
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialData = createMemo(() => {
|
const initialData = () => {
|
||||||
return {
|
return {
|
||||||
coverImageUrl: props.form?.coverImageUrl,
|
coverImageUrl: props.form?.coverImageUrl,
|
||||||
mainTopic: props.form?.mainTopic || EMPTY_TOPIC,
|
mainTopic: props.form?.mainTopic || EMPTY_TOPIC,
|
||||||
|
@ -87,7 +86,7 @@ export const PublishSettings = (props: Props) => {
|
||||||
description: composeDescription() || '',
|
description: composeDescription() || '',
|
||||||
selectedTopics: []
|
selectedTopics: []
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
const [settingsForm, setSettingsForm] = createStore<FormConfig>(emptyConfig)
|
const [settingsForm, setSettingsForm] = createStore<FormConfig>(emptyConfig)
|
||||||
|
|
||||||
|
@ -240,8 +239,16 @@ export const PublishSettings = (props: Props) => {
|
||||||
|
|
||||||
<h4>{t('Slug')}</h4>
|
<h4>{t('Slug')}</h4>
|
||||||
<div class="pretty-form__item">
|
<div class="pretty-form__item">
|
||||||
<input type="text" name="slug" id="slug" value={settingsForm.slug} onInput={removeSpecial} />
|
<label for="slug">
|
||||||
<label for="slug">{t('Slug')}</label>
|
<input
|
||||||
|
type="text"
|
||||||
|
name="slug"
|
||||||
|
id="slug"
|
||||||
|
value={settingsForm.slug}
|
||||||
|
onInput={removeSpecial}
|
||||||
|
/>
|
||||||
|
{t('Slug')}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>{t('Topics')}</h4>
|
<h4>{t('Topics')}</h4>
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { Meta, Title } from '@solidjs/meta'
|
||||||
import { useLocation } from '@solidjs/router'
|
import { useLocation } from '@solidjs/router'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import type { JSX } from 'solid-js'
|
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 { useLocalize } from '~/context/localize'
|
||||||
import { Shout } from '~/graphql/schema/core.gen'
|
import { Shout } from '~/graphql/schema/core.gen'
|
||||||
import enKeywords from '~/intl/locales/en/keywords.json'
|
import enKeywords from '~/intl/locales/en/keywords.json'
|
||||||
|
@ -27,7 +27,6 @@ type PageLayoutProps = {
|
||||||
class?: string
|
class?: string
|
||||||
withPadding?: boolean
|
withPadding?: boolean
|
||||||
zeroBottomPadding?: boolean
|
zeroBottomPadding?: boolean
|
||||||
scrollToComments?: (value: boolean) => void
|
|
||||||
key?: string
|
key?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,12 +47,10 @@ export const PageLayout = (props: PageLayoutProps) => {
|
||||||
: imageUrl
|
: imageUrl
|
||||||
)
|
)
|
||||||
const description = createMemo(() => props.desc || (props.article && descFromBody(props.article.body)))
|
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(() => {
|
||||||
const keywords = createMemo(
|
const keypath = (props.key || loc?.pathname.split('/')[0]) as keyof typeof ruKeywords
|
||||||
() => props.keywords || (lang() === 'ru' ? ruKeywords[keypath()] : enKeywords[keypath()])
|
return props.keywords || lang() === 'ru' ? ruKeywords[keypath] : enKeywords[keypath]
|
||||||
)
|
})
|
||||||
const [scrollToComments, setScrollToComments] = createSignal<boolean>(false)
|
|
||||||
createEffect(() => props.scrollToComments?.(scrollToComments()))
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>{props.article?.title || t(props.title)}</Title>
|
<Title>{props.article?.title || t(props.title)}</Title>
|
||||||
|
@ -63,7 +60,6 @@ export const PageLayout = (props: PageLayoutProps) => {
|
||||||
desc={props.desc}
|
desc={props.desc}
|
||||||
cover={imageUrl}
|
cover={imageUrl}
|
||||||
isHeaderFixed={isHeaderFixed}
|
isHeaderFixed={isHeaderFixed}
|
||||||
scrollToComments={(value) => setScrollToComments(value)}
|
|
||||||
/>
|
/>
|
||||||
<Meta name="descprition" content={description() || ''} />
|
<Meta name="descprition" content={description() || ''} />
|
||||||
<Meta name="keywords" content={keywords()} />
|
<Meta name="keywords" content={keywords()} />
|
||||||
|
|
|
@ -97,8 +97,10 @@ export const ShareLinks = (props: Props) => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form class={clsx('pretty-form__item', styles.linkInput)}>
|
<form class={clsx('pretty-form__item', styles.linkInput)}>
|
||||||
<input type="text" name="link" readonly value={props.shareUrl} />
|
<label for="link">
|
||||||
<label for="link">{t('Copy link')}</label>
|
<input type="text" name="link" readonly value={props.shareUrl} />
|
||||||
|
{t('Copy link')}
|
||||||
|
</label>
|
||||||
|
|
||||||
<Popover content={t('Copy link')}>
|
<Popover content={t('Copy link')}>
|
||||||
{(triggerRef: (el: HTMLElement) => void) => (
|
{(triggerRef: (el: HTMLElement) => void) => (
|
||||||
|
|
|
@ -17,8 +17,9 @@ type Props = {
|
||||||
articleView?: boolean
|
articleView?: boolean
|
||||||
}
|
}
|
||||||
const watchPattern = /watch=(\w+)/
|
const watchPattern = /watch=(\w+)/
|
||||||
const ytPattern = /(youtu.be)\/(\w+)/
|
const ytPattern = /youtu.be\/(\w+)/
|
||||||
const vimeoPattern = /vimeo.com\/(\d+)/
|
const vimeoPattern = /vimeo.com\/(\d+)/
|
||||||
|
|
||||||
export const VideoPlayer = (props: Props) => {
|
export const VideoPlayer = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [videoId, setVideoId] = createSignal<string | undefined>()
|
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)
|
console.debug('[context.authors] storing new authors:', newAuthors)
|
||||||
setAuthors((prevAuthors) => {
|
setAuthors((prevAuthors) => {
|
||||||
const updatedAuthors = { ...prevAuthors }
|
const updatedAuthors = { ...prevAuthors }
|
||||||
newAuthors.forEach((author) => {
|
Array.isArray(newAuthors) &&
|
||||||
updatedAuthors[author.slug] = author
|
newAuthors.forEach((author) => {
|
||||||
})
|
updatedAuthors[author.slug] = author
|
||||||
|
})
|
||||||
return updatedAuthors
|
return updatedAuthors
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import type { Accessor, JSX } from 'solid-js'
|
import type { Accessor, JSX } from 'solid-js'
|
||||||
|
|
||||||
import { createContext, createMemo, createSignal, onCleanup, useContext } from 'solid-js'
|
import { createContext, createMemo, createSignal, onCleanup, useContext } from 'solid-js'
|
||||||
import { createStore, reconcile } from 'solid-js/store'
|
|
||||||
import { coreApiUrl } from '~/config'
|
import { coreApiUrl } from '~/config'
|
||||||
import { loadReactions } from '~/graphql/api/public'
|
import { loadReactions } from '~/graphql/api/public'
|
||||||
import createReactionMutation from '~/graphql/mutation/core/reaction-create'
|
import createReactionMutation from '~/graphql/mutation/core/reaction-create'
|
||||||
|
@ -20,14 +18,14 @@ import { useSession } from './session'
|
||||||
import { useSnackbar } from './ui'
|
import { useSnackbar } from './ui'
|
||||||
|
|
||||||
type ReactionsContextType = {
|
type ReactionsContextType = {
|
||||||
reactionEntities: Record<number, Reaction>
|
reactionEntities: Accessor<Record<number, Reaction>>
|
||||||
reactionsByShout: Record<number, Reaction[]>
|
reactionsByShout: Accessor<Record<number, Reaction[]>>
|
||||||
commentsByAuthor: Accessor<Record<number, Reaction[]>>
|
commentsByAuthor: Accessor<Record<number, Reaction[]>>
|
||||||
loadReactionsBy: (args: QueryLoad_Reactions_ByArgs) => Promise<Reaction[]>
|
loadReactionsBy: (args: QueryLoad_Reactions_ByArgs) => Promise<Reaction[]>
|
||||||
createReaction: (reaction: MutationCreate_ReactionArgs) => Promise<void>
|
createShoutReaction: (reaction: MutationCreate_ReactionArgs) => Promise<void>
|
||||||
updateReaction: (reaction: MutationUpdate_ReactionArgs) => Promise<Reaction>
|
updateShoutReaction: (reaction: MutationUpdate_ReactionArgs) => Promise<Reaction>
|
||||||
deleteReaction: (id: number) => Promise<{ error: string } | null>
|
deleteShoutReaction: (id: number) => Promise<{ error: string } | null>
|
||||||
addReactions: (rrr: Reaction[]) => void
|
addShoutReactions: (rrr: Reaction[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReactionsContext = createContext<ReactionsContextType>({} as ReactionsContextType)
|
const ReactionsContext = createContext<ReactionsContextType>({} as ReactionsContextType)
|
||||||
|
@ -37,29 +35,29 @@ export function useReactions() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReactionsProvider = (props: { children: JSX.Element }) => {
|
export const ReactionsProvider = (props: { children: JSX.Element }) => {
|
||||||
const [reactionEntities, setReactionEntities] = createStore<Record<number, Reaction>>({})
|
const [reactionEntities, setReactionEntities] = createSignal<Record<number, Reaction>>({})
|
||||||
const [reactionsByShout, setReactionsByShout] = createStore<Record<number, Reaction[]>>({})
|
const [reactionsByShout, setReactionsByShout] = createSignal<Record<number, Reaction[]>>({})
|
||||||
const [reactionsByAuthor, setReactionsByAuthor] = createStore<Record<number, Reaction[]>>({})
|
const [reactionsByAuthor, setReactionsByAuthor] = createSignal<Record<number, Reaction[]>>({})
|
||||||
const [commentsByAuthor, setCommentsByAuthor] = createSignal<Record<number, Reaction[]>>({})
|
const [commentsByAuthor, setCommentsByAuthor] = createSignal<Record<number, Reaction[]>>({})
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { showSnackbar } = useSnackbar()
|
const { showSnackbar } = useSnackbar()
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||||
|
|
||||||
const addReactions = (rrr: Reaction[]) => {
|
const addShoutReactions = (rrr: Reaction[]) => {
|
||||||
const newReactionsByShout: Record<number, Reaction[]> = { ...reactionsByShout }
|
const newReactionEntities = { ...reactionEntities() }
|
||||||
const newReactionsByAuthor: Record<number, Reaction[]> = { ...reactionsByAuthor }
|
const newReactionsByShout = { ...reactionsByShout() }
|
||||||
const newReactionEntities = rrr.reduce(
|
const newReactionsByAuthor = { ...reactionsByAuthor() }
|
||||||
(acc: { [reaction_id: number]: Reaction }, reaction: Reaction) => {
|
|
||||||
acc[reaction.id] = reaction
|
rrr.forEach((reaction) => {
|
||||||
if (!newReactionsByShout[reaction.shout.id]) newReactionsByShout[reaction.shout.id] = []
|
newReactionEntities[reaction.id] = reaction
|
||||||
newReactionsByShout[reaction.shout.id].push(reaction)
|
|
||||||
if (!newReactionsByAuthor[reaction.created_by.id]) newReactionsByAuthor[reaction.created_by.id] = []
|
if (!newReactionsByShout[reaction.shout.id]) newReactionsByShout[reaction.shout.id] = []
|
||||||
newReactionsByAuthor[reaction.created_by.id].push(reaction)
|
newReactionsByShout[reaction.shout.id].push(reaction)
|
||||||
return acc
|
|
||||||
},
|
if (!newReactionsByAuthor[reaction.created_by.id]) newReactionsByAuthor[reaction.created_by.id] = []
|
||||||
{ ...reactionEntities }
|
newReactionsByAuthor[reaction.created_by.id].push(reaction)
|
||||||
)
|
})
|
||||||
|
|
||||||
setReactionEntities(newReactionEntities)
|
setReactionEntities(newReactionEntities)
|
||||||
setReactionsByShout(newReactionsByShout)
|
setReactionsByShout(newReactionsByShout)
|
||||||
|
@ -68,7 +66,7 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
|
||||||
const newCommentsByAuthor = Object.fromEntries(
|
const newCommentsByAuthor = Object.fromEntries(
|
||||||
Object.entries(newReactionsByAuthor).map(([authorId, reactions]) => [
|
Object.entries(newReactionsByAuthor).map(([authorId, reactions]) => [
|
||||||
authorId,
|
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[]> => {
|
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 fetcher = await loadReactions(opts)
|
||||||
const result = (await fetcher()) || []
|
const result = (await fetcher()) || []
|
||||||
console.debug('[context.reactions] loaded', result)
|
console.debug('[context.reactions] loaded', result)
|
||||||
result && addReactions(result)
|
if (result) addShoutReactions(result)
|
||||||
return 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 resp = await client()?.mutation(createReactionMutation, input).toPromise()
|
||||||
const { error, reaction } = resp?.data?.create_reaction || {}
|
const { error, reaction } = resp?.data?.create_reaction || {}
|
||||||
if (error) await showSnackbar({ type: 'error', body: t(error) })
|
if (error) await showSnackbar({ type: 'error', body: t(error) })
|
||||||
if (!reaction) return
|
if (!reaction) return
|
||||||
const changes = {
|
addShoutReactions([reaction])
|
||||||
[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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteReaction = async (
|
const deleteShoutReaction = async (
|
||||||
reaction_id: number
|
reaction_id: number
|
||||||
): Promise<{ error: string; reaction?: string } | null> => {
|
): Promise<{ error: string; reaction?: string } | null> => {
|
||||||
if (reaction_id) {
|
if (reaction_id) {
|
||||||
const resp = await client()?.mutation(destroyReactionMutation, { reaction_id }).toPromise()
|
const resp = await client()?.mutation(destroyReactionMutation, { reaction_id }).toPromise()
|
||||||
const result = resp?.data?.destroy_reaction
|
const result = resp?.data?.destroy_reaction
|
||||||
|
|
||||||
if (!result.error) {
|
if (!result.error) {
|
||||||
setReactionEntities({
|
const reactionToDelete = reactionEntities()[reaction_id]
|
||||||
[reaction_id]: undefined
|
|
||||||
})
|
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 result
|
||||||
}
|
}
|
||||||
return null
|
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 resp = await client()?.mutation(updateReactionMutation, input).toPromise()
|
||||||
const result = resp?.data?.update_reaction
|
const result = resp?.data?.update_reaction
|
||||||
if (!result) throw new Error('cannot update reaction')
|
if (!result) throw new Error('cannot update reaction')
|
||||||
const { error, reaction } = result
|
const { error, reaction } = result
|
||||||
if (error) await showSnackbar({ type: 'error', body: t(error) })
|
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
|
return reaction
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(() => setReactionEntities(reconcile({})))
|
onCleanup(() => setReactionEntities({}))
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
loadReactionsBy,
|
loadReactionsBy,
|
||||||
createReaction,
|
createShoutReaction,
|
||||||
updateReaction,
|
updateShoutReaction,
|
||||||
deleteReaction,
|
deleteShoutReaction,
|
||||||
addReactions
|
addShoutReactions
|
||||||
}
|
}
|
||||||
|
|
||||||
const value: ReactionsContextType = { reactionEntities, reactionsByShout, commentsByAuthor, ...actions }
|
const value: ReactionsContextType = {
|
||||||
|
reactionEntities,
|
||||||
|
reactionsByShout,
|
||||||
|
commentsByAuthor,
|
||||||
|
...actions
|
||||||
|
}
|
||||||
|
|
||||||
return <ReactionsContext.Provider value={value}>{props.children}</ReactionsContext.Provider>
|
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 { RouteDefinition, RouteSectionProps, createAsync, useLocation } from '@solidjs/router'
|
||||||
import { HttpStatusCode } from '@solidjs/start'
|
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 { FourOuFourView } from '~/components/Views/FourOuFour'
|
||||||
import { Loading } from '~/components/_shared/Loading'
|
import { Loading } from '~/components/_shared/Loading'
|
||||||
import { gaIdentity } from '~/config'
|
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
|
article?: Shout
|
||||||
comments?: Reaction[]
|
comments?: Reaction[]
|
||||||
votes?: Reaction[]
|
votes?: Reaction[]
|
||||||
|
@ -38,7 +43,7 @@ type SlugPageProps = {
|
||||||
topics: Topic[]
|
topics: Topic[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (props: RouteSectionProps<SlugPageProps>) => {
|
export default function ArticlePage(props: RouteSectionProps<SlugPageProps>) {
|
||||||
if (props.params.slug.startsWith('@')) {
|
if (props.params.slug.startsWith('@')) {
|
||||||
console.debug('[routes] [slug]/[...tab] starts with @, render as author page')
|
console.debug('[routes] [slug]/[...tab] starts with @, render as author page')
|
||||||
const patchedProps = {
|
const patchedProps = {
|
||||||
|
@ -66,7 +71,6 @@ export default (props: RouteSectionProps<SlugPageProps>) => {
|
||||||
function ArticlePage(props: RouteSectionProps<ArticlePageProps>) {
|
function ArticlePage(props: RouteSectionProps<ArticlePageProps>) {
|
||||||
const loc = useLocation()
|
const loc = useLocation()
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const [scrollToComments, setScrollToComments] = createSignal<boolean>(false)
|
|
||||||
const data = createAsync(async () => props.data?.article || (await fetchShout(props.params.slug)))
|
const data = createAsync(async () => props.data?.article || (await fetchShout(props.params.slug)))
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
@ -114,10 +118,9 @@ export default (props: RouteSectionProps<SlugPageProps>) => {
|
||||||
headerTitle={data()?.title || ''}
|
headerTitle={data()?.title || ''}
|
||||||
slug={data()?.slug}
|
slug={data()?.slug}
|
||||||
cover={data()?.cover || ''}
|
cover={data()?.cover || ''}
|
||||||
scrollToComments={(value) => setScrollToComments(value)}
|
|
||||||
>
|
>
|
||||||
<ReactionsProvider>
|
<ReactionsProvider>
|
||||||
<FullArticle article={data() as Shout} scrollToComments={scrollToComments()} />
|
<FullArticle article={data() as Shout} />
|
||||||
</ReactionsProvider>
|
</ReactionsProvider>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</Show>
|
</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 { useTopics } from '~/context/topics'
|
||||||
import { loadShouts } from '~/graphql/api/public'
|
import { loadShouts } from '~/graphql/api/public'
|
||||||
import { LoadShoutsOptions, Shout, Topic } from '~/graphql/schema/core.gen'
|
import { LoadShoutsOptions, Shout, Topic } from '~/graphql/schema/core.gen'
|
||||||
|
import { FromPeriod, getFromDate } from '~/lib/fromPeriod'
|
||||||
import { SHOUTS_PER_PAGE } from '../(main)'
|
import { SHOUTS_PER_PAGE } from '../(main)'
|
||||||
|
|
||||||
const paramPattern = /^(hot|likes)$/
|
|
||||||
|
|
||||||
export type FeedPeriod = 'week' | 'month' | 'year'
|
|
||||||
|
|
||||||
export type PeriodItem = {
|
export type PeriodItem = {
|
||||||
value: FeedPeriod
|
value: FromPeriod
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeedSearchParams = {
|
export type FeedSearchParams = {
|
||||||
period: FeedPeriod
|
period: FromPeriod
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedLoader = async (options: Partial<LoadShoutsOptions>, _client?: Client) => {
|
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[] }>) => {
|
export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>) => {
|
||||||
const [searchParams] = useSearchParams<FeedSearchParams>() // ?period=month
|
const [searchParams] = useSearchParams<FeedSearchParams>() // ?period=month
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
@ -90,7 +68,7 @@ export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>)
|
||||||
// ?period=month - time period filter
|
// ?period=month - time period filter
|
||||||
if (searchParams?.period) {
|
if (searchParams?.period) {
|
||||||
const period = searchParams?.period || 'month'
|
const period = searchParams?.period || 'month'
|
||||||
options.filters = { after: getFromDate(period as FeedPeriod) }
|
options.filters = { after: getFromDate(period as FromPeriod) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const loaded = await feedLoader(options)
|
const loaded = await feedLoader(options)
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from '~/graphql/api/private'
|
} from '~/graphql/api/private'
|
||||||
import { graphqlClientCreate } from '~/graphql/client'
|
import { graphqlClientCreate } from '~/graphql/client'
|
||||||
import { LoadShoutsOptions, Shout, Topic } from '~/graphql/schema/core.gen'
|
import { LoadShoutsOptions, Shout, Topic } from '~/graphql/schema/core.gen'
|
||||||
|
import { FromPeriod, getFromDate } from '~/lib/fromPeriod'
|
||||||
|
|
||||||
const feeds = {
|
const feeds = {
|
||||||
followed: loadFollowedShouts,
|
followed: loadFollowedShouts,
|
||||||
|
@ -26,35 +27,13 @@ const feeds = {
|
||||||
coauthored: loadCoauthoredShouts,
|
coauthored: loadCoauthoredShouts,
|
||||||
unrated: loadUnratedShouts
|
unrated: loadUnratedShouts
|
||||||
}
|
}
|
||||||
|
export type FeedSearchParams = { period?: FromPeriod }
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// /feed/my/followed/hot
|
// /feed/my/followed/hot
|
||||||
|
|
||||||
|
const paramModePattern = /^(followed|discussed|liked|coauthored|unrated)$/
|
||||||
|
const paramOrderPattern = /^(hot|likes)$/
|
||||||
|
|
||||||
export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>) => {
|
export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>) => {
|
||||||
const [searchParams] = useSearchParams<FeedSearchParams>() // ?period=month
|
const [searchParams] = useSearchParams<FeedSearchParams>() // ?period=month
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
@ -75,7 +54,7 @@ export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>)
|
||||||
|
|
||||||
const order = createMemo(() => {
|
const order = createMemo(() => {
|
||||||
return (
|
return (
|
||||||
(paramPattern.test(props.params.order)
|
(paramOrderPattern.test(props.params.order)
|
||||||
? props.params.order === 'hot'
|
? props.params.order === 'hot'
|
||||||
? 'last_comment'
|
? 'last_comment'
|
||||||
: props.params.order
|
: props.params.order
|
||||||
|
@ -97,7 +76,7 @@ export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>)
|
||||||
// ?period=month - time period filter
|
// ?period=month - time period filter
|
||||||
if (searchParams?.period) {
|
if (searchParams?.period) {
|
||||||
const period = searchParams?.period || 'month'
|
const period = searchParams?.period || 'month'
|
||||||
options.filters = { after: getFromDate(period as FeedPeriod) }
|
options.filters = { after: getFromDate(period as FromPeriod) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const shoutsLoader = gqlHandler(client(), options)
|
const shoutsLoader = gqlHandler(client(), options)
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { type Page, expect, test } from '@playwright/test'
|
||||||
/* Global starting test config */
|
/* Global starting test config */
|
||||||
|
|
||||||
let page: Page
|
let page: Page
|
||||||
|
|
||||||
function httpsGet(url: string): Promise<void> {
|
function httpsGet(url: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
https
|
https
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
|
// biome-ignore lint/correctness/noNodejsModules: <explanation>
|
||||||
|
import path from 'node:path'
|
||||||
import { CSSOptions } from 'vite'
|
import { CSSOptions } from 'vite'
|
||||||
// import { visualizer } from 'rollup-plugin-visualizer'
|
|
||||||
import mkcert from 'vite-plugin-mkcert'
|
import mkcert from 'vite-plugin-mkcert'
|
||||||
import { PolyfillOptions, nodePolyfills } from 'vite-plugin-node-polyfills'
|
import { PolyfillOptions, nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||||
import sassDts from 'vite-plugin-sass-dts'
|
import sassDts from 'vite-plugin-sass-dts'
|
||||||
|
// import { visualizer } from 'rollup-plugin-visualizer'
|
||||||
|
|
||||||
const isVercel = Boolean(process?.env.VERCEL)
|
const isDev = process.env.NODE_ENV !== 'production'
|
||||||
const isNetlify = Boolean(process?.env.NETLIFY)
|
console.log(`[vite.config] development mode: ${isDev}`)
|
||||||
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 polyfillOptions = {
|
const polyfillOptions = {
|
||||||
include: ['path', 'stream', 'util'],
|
include: ['path', 'stream', 'util'],
|
||||||
|
@ -23,8 +22,13 @@ const polyfillOptions = {
|
||||||
} as PolyfillOptions
|
} as PolyfillOptions
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~': path.resolve(__dirname, './src')
|
||||||
|
}
|
||||||
|
},
|
||||||
envPrefix: 'PUBLIC_',
|
envPrefix: 'PUBLIC_',
|
||||||
plugins: [!isVercel && mkcert(), nodePolyfills(polyfillOptions), sassDts()],
|
plugins: [isDev && mkcert(), nodePolyfills(polyfillOptions), sassDts()],
|
||||||
css: {
|
css: {
|
||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
scss: {
|
scss: {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user