Merge branch 'dev' into hotfix/sv-author-empty
This commit is contained in:
commit
b91a1be989
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -25,9 +25,9 @@ bun.lockb
|
|||
/plawright-report/
|
||||
target
|
||||
.github/dependabot.yml
|
||||
|
||||
.output
|
||||
.vinxi
|
||||
*.pem
|
||||
edge.*
|
||||
.vscode/settings.json
|
||||
storybook-static
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
import type { TestRunnerConfig } from '@storybook/test-runner'
|
||||
import { checkA11y, injectAxe } from 'axe-playwright'
|
||||
|
||||
|
@ -5,11 +6,11 @@ import { checkA11y, injectAxe } from 'axe-playwright'
|
|||
* See https://storybook.js.org/docs/react/writing-tests/test-runner#test-hook-api-experimental
|
||||
* to learn more about the test-runner hooks API.
|
||||
*/
|
||||
const a11yConfig: TestRunnerConfig = {
|
||||
async preRender(page) {
|
||||
const a11yConfig = {
|
||||
async preRender(page: Page) {
|
||||
await injectAxe(page)
|
||||
},
|
||||
async postRender(page) {
|
||||
async postRender(page: Page) {
|
||||
await checkA11y(page, '#storybook-root', {
|
||||
detailedReport: true,
|
||||
detailedReportOptions: {
|
||||
|
@ -17,6 +18,6 @@ const a11yConfig: TestRunnerConfig = {
|
|||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} as TestRunnerConfig
|
||||
|
||||
module.exports = a11yConfig
|
||||
|
|
|
@ -1,2 +1,6 @@
|
|||
.vercel/
|
||||
node_modules
|
||||
dist/
|
||||
storybook-static
|
||||
.output
|
||||
.vinxi
|
||||
.vercel
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { SolidStartInlineConfig, defineConfig } from '@solidjs/start/config'
|
||||
import viteConfig from './vite.config'
|
||||
import viteConfig, { isDev } from './vite.config'
|
||||
|
||||
const isVercel = Boolean(process?.env.VERCEL)
|
||||
const isNetlify = Boolean(process?.env.NETLIFY)
|
||||
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}!`)
|
||||
const preset = isNetlify ? 'netlify' : isVercel ? 'vercel_edge' : isBun ? 'bun' : 'node'
|
||||
console.info(`[app.config] solid-start preset {> ${preset} <}`)
|
||||
|
||||
export default defineConfig({
|
||||
nitro: {
|
||||
|
@ -14,10 +14,10 @@ export default defineConfig({
|
|||
},
|
||||
ssr: true,
|
||||
server: {
|
||||
preset: runtime,
|
||||
preset,
|
||||
port: 3000,
|
||||
https: true
|
||||
},
|
||||
devOverlay: true,
|
||||
devOverlay: isDev,
|
||||
vite: viteConfig
|
||||
} as SolidStartInlineConfig)
|
||||
|
|
42
package-lock.json
generated
42
package-lock.json
generated
|
@ -77,7 +77,7 @@
|
|||
"@tiptap/starter-kit": "^2.7.2",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/cookie-signature": "^1.1.2",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/node": "^22.6.0",
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@urql/core": "^5.0.6",
|
||||
"axe-playwright": "^2.0.2",
|
||||
|
@ -100,7 +100,7 @@
|
|||
"prosemirror-view": "^1.34.3",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "1.77.6",
|
||||
"solid-js": "^1.8.22",
|
||||
"solid-js": "^1.8.23",
|
||||
"solid-popper": "^0.3.0",
|
||||
"solid-tiptap": "0.7.0",
|
||||
"solid-transition-group": "^0.2.3",
|
||||
|
@ -124,7 +124,7 @@
|
|||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vite-plugin-sass-dts": "^1.3.29",
|
||||
"y-prosemirror": "1.2.12",
|
||||
"yjs": "13.6.18"
|
||||
"yjs": "13.6.19"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
|
@ -5254,9 +5254,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-node-resolve": {
|
||||
"version": "15.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.4.tgz",
|
||||
"integrity": "sha512-wnKAGisav1m2vgVK2/2mNowK5DCqff7kpz76cY1pECVE0qRQTCAIcWP5xmdGDi8X8K9SYeeC98i6cD3fk6qkDg==",
|
||||
"version": "15.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz",
|
||||
"integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -5324,9 +5324,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.1.tgz",
|
||||
"integrity": "sha512-bVRmQqBIyGD+VMihdEV2IBurfIrdW9tD9yzJUL3CBRDbyPBVzQnBSMSgyUZHl1E335rpMRj7r4o683fXLYw8iw==",
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.2.tgz",
|
||||
"integrity": "sha512-/FIdS3PyZ39bjZlwqFnWqCOVnW7o963LtKMwQOD0NhQqw22gSr2YY1afu3FxRip4ZCZNsD5jq6Aaz6QV3D/Njw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -7740,9 +7740,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz",
|
||||
"integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==",
|
||||
"version": "4.17.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.9.tgz",
|
||||
"integrity": "sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
@ -7771,9 +7771,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz",
|
||||
"integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==",
|
||||
"version": "22.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.6.1.tgz",
|
||||
"integrity": "sha512-V48tCfcKb/e6cVUigLAaJDAILdMP0fUW6BidkPK4GpGjXcfbnoHasCZDwz3N3yVt5we2RHm4XTQCpv0KJz9zqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -22932,9 +22932,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/solid-js": {
|
||||
"version": "1.8.22",
|
||||
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.8.22.tgz",
|
||||
"integrity": "sha512-VBzN5j+9Y4rqIKEnK301aBk+S7fvFSTs9ljg+YEdFxjNjH0hkjXPiQRcws9tE5fUzMznSS6KToL5hwMfHDgpLA==",
|
||||
"version": "1.8.23",
|
||||
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.8.23.tgz",
|
||||
"integrity": "sha512-0jKzMgxmU/b3k4iJmIZJW2BIArrHN+Mug0n7m7MeHvGHWiS57ZdyTmnqNMSbGRvE73QBnTiGFJc90cPPieawaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -26804,9 +26804,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.18",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.18.tgz",
|
||||
"integrity": "sha512-GBTjO4QCmv2HFKFkYIJl7U77hIB1o22vSCSQD1Ge8ZxWbIbn8AltI4gyXbtL+g5/GJep67HCMq3Y5AmNwDSyEg==",
|
||||
"version": "13.6.19",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.19.tgz",
|
||||
"integrity": "sha512-GNKw4mEUn5yWU2QPHRx8jppxmCm9KzbBhB4qJLUJFiiYD0g/tDVgXQ7aPkyh01YO28kbs2J/BEbWBagjuWyejw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
@ -84,7 +84,7 @@
|
|||
"@tiptap/starter-kit": "^2.7.2",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/cookie-signature": "^1.1.2",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/node": "^22.6.1",
|
||||
"@types/throttle-debounce": "^5.0.2",
|
||||
"@urql/core": "^5.0.6",
|
||||
"axe-playwright": "^2.0.2",
|
||||
|
@ -107,7 +107,7 @@
|
|||
"prosemirror-view": "^1.34.3",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "1.77.6",
|
||||
"solid-js": "^1.8.22",
|
||||
"solid-js": "^1.8.23",
|
||||
"solid-popper": "^0.3.0",
|
||||
"solid-tiptap": "0.7.0",
|
||||
"solid-transition-group": "^0.2.3",
|
||||
|
@ -131,12 +131,12 @@
|
|||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vite-plugin-sass-dts": "^1.3.29",
|
||||
"y-prosemirror": "1.2.12",
|
||||
"yjs": "13.6.18"
|
||||
"yjs": "13.6.19"
|
||||
},
|
||||
"overrides": {
|
||||
"sass": "1.77.6",
|
||||
"vite": "5.3.5",
|
||||
"yjs": "13.6.18",
|
||||
"yjs": "13.6.19",
|
||||
"y-prosemirror": "1.2.12"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
@ -9,7 +9,7 @@ import { SharePopup, getShareUrl } from '../SharePopup'
|
|||
|
||||
import styles from './AudioPlayer.module.scss'
|
||||
|
||||
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
||||
const MicroEditor = lazy(() => import('../../Editor/MicroEditor/MicroEditor'))
|
||||
const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
|
||||
|
||||
type Props = {
|
||||
|
@ -171,10 +171,9 @@ export const PlayerPlaylist = (props: Props) => {
|
|||
}
|
||||
>
|
||||
<div class={styles.descriptionBlock}>
|
||||
<SimplifiedEditor
|
||||
initialContent={mi.body}
|
||||
<MicroEditor
|
||||
content={mi.body}
|
||||
placeholder={`${t('Description')}...`}
|
||||
smallHeight={true}
|
||||
onChange={(value) => handleMediaItemFieldChange('body', value)}
|
||||
/>
|
||||
<GrowingTextarea
|
||||
|
|
|
@ -3,12 +3,10 @@ import { clsx } from 'clsx'
|
|||
import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js'
|
||||
import { Icon } from '~/components/_shared/Icon'
|
||||
import { ShowIfAuthenticated } from '~/components/_shared/ShowIfAuthenticated'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useReactions } from '~/context/reactions'
|
||||
import { useSession } from '~/context/session'
|
||||
import { useSnackbar, useUI } from '~/context/ui'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import deleteReactionMutation from '~/graphql/mutation/core/reaction-destroy'
|
||||
import {
|
||||
Author,
|
||||
|
@ -23,7 +21,7 @@ import { CommentDate } from '../CommentDate'
|
|||
import { CommentRatingControl } from '../CommentRatingControl'
|
||||
import styles from './Comment.module.scss'
|
||||
|
||||
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
||||
const MiniEditor = lazy(() => import('../../Editor/MiniEditor/MiniEditor'))
|
||||
|
||||
type Props = {
|
||||
comment: Reaction
|
||||
|
@ -43,14 +41,12 @@ export const Comment = (props: Props) => {
|
|||
const [isReplyVisible, setIsReplyVisible] = createSignal(false)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [editMode, setEditMode] = createSignal(false)
|
||||
const [clearEditor, setClearEditor] = createSignal(false)
|
||||
const [editedBody, setEditedBody] = createSignal<string>()
|
||||
const { session } = useSession()
|
||||
const { session, client } = useSession()
|
||||
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||
const { createShoutReaction, updateShoutReaction } = useReactions()
|
||||
const { showConfirm } = useUI()
|
||||
const { showSnackbar } = useSnackbar()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const canEdit = createMemo(
|
||||
() =>
|
||||
Boolean(author()?.id) &&
|
||||
|
@ -107,13 +103,11 @@ export const Comment = (props: Props) => {
|
|||
shout: props.comment.shout.id
|
||||
}
|
||||
} as MutationCreate_ReactionArgs)
|
||||
setClearEditor(true)
|
||||
setIsReplyVisible(false)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
console.error('[handleCreate reaction]:', error)
|
||||
}
|
||||
setClearEditor(false)
|
||||
}
|
||||
|
||||
const toggleEditMode = () => {
|
||||
|
@ -192,16 +186,11 @@ export const Comment = (props: Props) => {
|
|||
<div class={styles.commentBody}>
|
||||
<Show when={editMode()} fallback={<div innerHTML={body()} />}>
|
||||
<Suspense fallback={<p>{t('Loading')}</p>}>
|
||||
<SimplifiedEditor
|
||||
initialContent={editedBody() || props.comment.body || ''}
|
||||
submitButtonText={t('Save')}
|
||||
quoteEnabled={true}
|
||||
imageEnabled={true}
|
||||
<MiniEditor
|
||||
content={editedBody() || props.comment.body || ''}
|
||||
placeholder={t('Write a comment...')}
|
||||
onSubmit={(value) => handleUpdate(value)}
|
||||
submitByCtrlEnter={true}
|
||||
onCancel={() => setEditMode(false)}
|
||||
setClear={clearEditor()}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
@ -261,12 +250,9 @@ export const Comment = (props: Props) => {
|
|||
|
||||
<Show when={isReplyVisible() && props.clickedReplyId === props.comment.id}>
|
||||
<Suspense fallback={<p>{t('Loading')}</p>}>
|
||||
<SimplifiedEditor
|
||||
quoteEnabled={true}
|
||||
imageEnabled={true}
|
||||
<MiniEditor
|
||||
placeholder={t('Write a comment...')}
|
||||
onSubmit={(value) => handleCreate(value)}
|
||||
submitByCtrlEnter={true}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
|
|
@ -9,11 +9,12 @@ import { Author, Reaction, ReactionKind, ReactionSort } from '~/graphql/schema/c
|
|||
import { SortFunction } from '~/types/common'
|
||||
import { byCreated, byStat } from '~/utils/sort'
|
||||
import { Button } from '../_shared/Button'
|
||||
import { Loading } from '../_shared/Loading'
|
||||
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
|
||||
import styles from './Article.module.scss'
|
||||
import { Comment } from './Comment'
|
||||
|
||||
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
|
||||
const MiniEditor = lazy(() => import('../Editor/MiniEditor/MiniEditor'))
|
||||
|
||||
type Props = {
|
||||
articleAuthors: Author[]
|
||||
|
@ -27,7 +28,6 @@ export const CommentsTree = (props: Props) => {
|
|||
const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest)
|
||||
const [onlyNew, setOnlyNew] = createSignal(false)
|
||||
const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
|
||||
const [clearEditor, setClearEditor] = createSignal(false)
|
||||
const [clickedReplyId, setClickedReplyId] = createSignal<number>()
|
||||
const { reactionEntities, createShoutReaction, loadReactionsBy } = useReactions()
|
||||
|
||||
|
@ -70,6 +70,7 @@ export const CommentsTree = (props: Props) => {
|
|||
setCookie()
|
||||
}
|
||||
})
|
||||
|
||||
const [posting, setPosting] = createSignal(false)
|
||||
const handleSubmitComment = async (value: string) => {
|
||||
setPosting(true)
|
||||
|
@ -81,12 +82,10 @@ export const CommentsTree = (props: Props) => {
|
|||
shout: props.shoutId
|
||||
}
|
||||
})
|
||||
setClearEditor(true)
|
||||
await loadReactionsBy({ by: { shout: props.shoutSlug } })
|
||||
} catch (error) {
|
||||
console.error('[handleCreate reaction]:', error)
|
||||
}
|
||||
setClearEditor(false)
|
||||
setPosting(false)
|
||||
}
|
||||
|
||||
|
@ -155,16 +154,10 @@ export const CommentsTree = (props: Props) => {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<SimplifiedEditor
|
||||
quoteEnabled={true}
|
||||
imageEnabled={true}
|
||||
autoFocus={false}
|
||||
submitByCtrlEnter={true}
|
||||
placeholder={t('Write a comment...')}
|
||||
onSubmit={(value) => handleSubmitComment(value)}
|
||||
setClear={clearEditor()}
|
||||
isPosting={posting()}
|
||||
/>
|
||||
<MiniEditor placeholder={t('Write a comment...')} onSubmit={handleSubmitComment} />
|
||||
<Show when={posting()}>
|
||||
<Loading />
|
||||
</Show>
|
||||
</ShowIfAuthenticated>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { AuthToken } from '@authorizerdev/authorizer-js'
|
||||
import { createPopper } from '@popperjs/core'
|
||||
import { Link } from '@solidjs/meta'
|
||||
import { A, useSearchParams } from '@solidjs/router'
|
||||
|
@ -73,7 +74,6 @@ export const FullArticle = (props: Props) => {
|
|||
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
|
||||
const { t, formatDate, lang } = useLocalize()
|
||||
const { session, requireAuthentication } = useSession()
|
||||
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
|
||||
const { addSeen } = useFeed()
|
||||
const formattedDate = createMemo(() => formatDate(new Date((props.article.published_at || 0) * 1000)))
|
||||
|
||||
|
@ -100,12 +100,19 @@ export const FullArticle = (props: Props) => {
|
|||
)
|
||||
)
|
||||
|
||||
const canEdit = createMemo(
|
||||
() =>
|
||||
Boolean(author()?.id) &&
|
||||
(props.article.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
|
||||
props.article.created_by?.id === author().id ||
|
||||
session()?.user?.roles?.includes('editor'))
|
||||
const [canEdit, setCanEdit] = createSignal<boolean>(false)
|
||||
createEffect(
|
||||
on(
|
||||
() => session(),
|
||||
(s?: AuthToken) => {
|
||||
const profile = s?.user?.app_data?.profile
|
||||
if (!profile) return
|
||||
const isEditor = s?.user?.roles?.includes('editor')
|
||||
const isCreator = props.article.created_by?.id === profile.id
|
||||
const fit = (a: Maybe<Author>) => a?.id === profile.id || isCreator || isEditor
|
||||
setCanEdit((_: boolean) => Boolean(props.article.authors?.some(fit)))
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const mainTopic = createMemo(() => {
|
||||
|
@ -534,7 +541,7 @@ export const FullArticle = (props: Props) => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Show when={author()?.id && !canEdit()}>
|
||||
<Show when={session()?.access_token && !canEdit()}>
|
||||
<div class={styles.help}>
|
||||
<button class="button">{t('Cooperate')}</button>
|
||||
</div>
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import type { Author } from '~/graphql/schema/core.gen'
|
||||
|
||||
import { clsx } from 'clsx'
|
||||
import { Show, createMemo, createSignal } from 'solid-js'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { Show, createSignal } from 'solid-js'
|
||||
import { useSession } from '~/context/session'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import rateAuthorMutation from '~/graphql/mutation/core/author-rate'
|
||||
import styles from './AuthorRatingControl.module.scss'
|
||||
|
||||
|
@ -17,8 +15,7 @@ export const AuthorRatingControl = (props: AuthorRatingControlProps) => {
|
|||
const isUpvoted = false
|
||||
const isDownvoted = false
|
||||
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const { client } = useSession()
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
const handleRatingChange = async (isUpvote: boolean) => {
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
import { renderUploadedImage } from '~/components/Editor/renderUploadedImage'
|
||||
import { renderUploadedImage } from '~/components/Upload/renderUploadedImage'
|
||||
import { Icon } from '~/components/_shared/Icon'
|
||||
import { Popover } from '~/components/_shared/Popover'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { UploadedFile } from '~/types/upload'
|
||||
import { Modal } from '../../_shared/Modal'
|
||||
import { UploadModalContent } from '../UploadModalContent'
|
||||
|
||||
import { useUI } from '~/context/ui'
|
||||
import { UploadedFile } from '~/types/upload'
|
||||
import { UploadModalContent } from '../../Upload/UploadModalContent'
|
||||
import { Modal } from '../../_shared/Modal'
|
||||
|
||||
import styles from './BubbleMenu.module.scss'
|
||||
|
||||
type Props = {
|
||||
|
@ -20,8 +19,8 @@ export const FigureBubbleMenu = (props: Props) => {
|
|||
const { t } = useLocalize()
|
||||
const { hideModal } = useUI()
|
||||
|
||||
const handleUpload = (image: UploadedFile) => {
|
||||
renderUploadedImage(props.editor, image)
|
||||
const handleUpload = (image?: UploadedFile) => {
|
||||
image && renderUploadedImage(props.editor, image)
|
||||
hideModal()
|
||||
}
|
||||
|
||||
|
@ -81,11 +80,7 @@ export const FigureBubbleMenu = (props: Props) => {
|
|||
</Popover>
|
||||
|
||||
<Modal variant="narrow" name="uploadImage">
|
||||
<UploadModalContent
|
||||
onClose={(value) => {
|
||||
handleUpload(value as UploadedFile)
|
||||
}}
|
||||
/>
|
||||
<UploadModalContent onClose={handleUpload} />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
import { clsx } from 'clsx'
|
||||
import { Match, Show, Switch, createEffect, createSignal, lazy, onCleanup, onMount } from 'solid-js'
|
||||
import { createEditorTransaction } from 'solid-tiptap'
|
||||
|
||||
import { Icon } from '~/components/_shared/Icon'
|
||||
import { Popover } from '~/components/_shared/Popover'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { InsertLinkForm } from '../InsertLinkForm'
|
||||
import { InsertLinkForm } from '../EditorToolbar/InsertLinkForm'
|
||||
|
||||
import styles from './TextBubbleMenu.module.scss'
|
||||
|
||||
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
||||
const MiniEditor = lazy(() => import('../MiniEditor/MiniEditor'))
|
||||
|
||||
type BubbleMenuProps = {
|
||||
editor: Editor
|
||||
|
@ -146,18 +144,13 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
|
|||
<InsertLinkForm editor={props.editor} onClose={handleCloseLinkForm} />
|
||||
</Match>
|
||||
<Match when={footnoteEditorOpen()}>
|
||||
<SimplifiedEditor
|
||||
maxHeight={180}
|
||||
controlsAlwaysVisible={true}
|
||||
imageEnabled={true}
|
||||
<MiniEditor
|
||||
placeholder={t('Enter footnote text')}
|
||||
onSubmit={(value) => handleAddFootnote(value)}
|
||||
variant={'bordered'}
|
||||
initialContent={footNote()}
|
||||
onSubmit={(value: string) => handleAddFootnote(value)}
|
||||
content={footNote()}
|
||||
onCancel={() => {
|
||||
setFootnoteEditorOpen(false)
|
||||
}}
|
||||
submitButtonText={t('Send')}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!(linkEditorOpen() && footnoteEditorOpen())}>
|
|
@ -1,105 +1,28 @@
|
|||
import { Editor, EditorOptions } from '@tiptap/core'
|
||||
import { createSignal } from 'solid-js'
|
||||
import { createStore } from 'solid-js/store'
|
||||
import { Meta, StoryObj } from 'storybook-solidjs'
|
||||
import { EditorContext, EditorContextType, ShoutForm } from '~/context/editor'
|
||||
import { LocalizeContext, LocalizeContextType } from '~/context/localize'
|
||||
import { SessionContext, SessionContextType } from '~/context/session'
|
||||
import { SnackbarContext, SnackbarContextType } from '~/context/ui'
|
||||
import { EditorComponent, EditorComponentProps } from './Editor'
|
||||
|
||||
// Mock data
|
||||
const mockSession = {
|
||||
session: () => ({
|
||||
user: {
|
||||
app_data: {
|
||||
profile: {
|
||||
name: 'Test User',
|
||||
slug: 'test-user'
|
||||
}
|
||||
}
|
||||
},
|
||||
access_token: 'mock-access-token'
|
||||
})
|
||||
}
|
||||
|
||||
const mockLocalize = {
|
||||
t: (key: string) => key,
|
||||
lang: () => 'en'
|
||||
}
|
||||
|
||||
const [_form, setForm] = createStore<ShoutForm>({
|
||||
body: '',
|
||||
slug: '',
|
||||
shoutId: 0,
|
||||
title: '',
|
||||
selectedTopics: []
|
||||
})
|
||||
const [_formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>)
|
||||
const [editor, setEditor] = createSignal<Editor | undefined>()
|
||||
|
||||
const mockEditorContext: EditorContextType = {
|
||||
countWords: () => 0,
|
||||
isEditorPanelVisible: () => false,
|
||||
wordCounter: () => ({ characters: 0, words: 0 }),
|
||||
form: _form,
|
||||
formErrors: _formErrors,
|
||||
createEditor: (opts?: Partial<EditorOptions>) => {
|
||||
const newEditor = new Editor(opts)
|
||||
setEditor(newEditor)
|
||||
return newEditor
|
||||
},
|
||||
editor,
|
||||
saveShout: async (_form: ShoutForm) => {
|
||||
// Simulate save
|
||||
},
|
||||
saveDraft: async (_form: ShoutForm) => {
|
||||
// Simulate save draft
|
||||
},
|
||||
saveDraftToLocalStorage: (_form: ShoutForm) => {
|
||||
// Simulate save to local storage
|
||||
},
|
||||
getDraftFromLocalStorage: (_shoutId: number): ShoutForm => _form,
|
||||
publishShout: async (_form: ShoutForm) => {
|
||||
// Simulate publish
|
||||
},
|
||||
publishShoutById: async (_shoutId: number) => {
|
||||
// Simulate publish by ID
|
||||
},
|
||||
deleteShout: async (_shoutId: number): Promise<boolean> => true,
|
||||
toggleEditorPanel: () => {
|
||||
// Simulate toggle
|
||||
},
|
||||
setForm,
|
||||
setFormErrors
|
||||
}
|
||||
|
||||
const mockSnackbarContext = {
|
||||
showSnackbar: console.log
|
||||
}
|
||||
import { EditorComponent } from './Editor'
|
||||
|
||||
const meta: Meta<typeof EditorComponent> = {
|
||||
title: 'Components/Editor',
|
||||
component: EditorComponent,
|
||||
argTypes: {
|
||||
shoutId: {
|
||||
control: 'number',
|
||||
description: 'Unique identifier for the shout (document)',
|
||||
defaultValue: 1
|
||||
},
|
||||
initialContent: {
|
||||
content: {
|
||||
control: 'text',
|
||||
description: 'Initial content for the editor',
|
||||
defaultValue: ''
|
||||
},
|
||||
onChange: {
|
||||
action: 'contentChanged',
|
||||
description: 'Callback when the content changes'
|
||||
limit: {
|
||||
control: 'number',
|
||||
description: 'Character limit for the editor',
|
||||
defaultValue: 500
|
||||
},
|
||||
disableCollaboration: {
|
||||
control: 'boolean',
|
||||
description: 'Disable collaboration features for Storybook',
|
||||
defaultValue: true
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text when the editor is empty',
|
||||
defaultValue: 'Start typing here...'
|
||||
},
|
||||
onChange: {
|
||||
action: 'changed',
|
||||
description: 'Callback when the content changes'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -109,38 +32,33 @@ export default meta
|
|||
type Story = StoryObj<typeof EditorComponent>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (props: EditorComponentProps) => {
|
||||
const [_content, setContent] = createSignal(props.initialContent || '')
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={mockSession as SessionContextType}>
|
||||
<LocalizeContext.Provider value={mockLocalize as LocalizeContextType}>
|
||||
<SnackbarContext.Provider value={mockSnackbarContext as SnackbarContextType}>
|
||||
<EditorContext.Provider value={mockEditorContext as EditorContextType}>
|
||||
<EditorComponent
|
||||
{...props}
|
||||
onChange={(text: string) => {
|
||||
props.onChange(text)
|
||||
setContent(text)
|
||||
}}
|
||||
/>
|
||||
</EditorContext.Provider>
|
||||
</SnackbarContext.Provider>
|
||||
</LocalizeContext.Provider>
|
||||
</SessionContext.Provider>
|
||||
)
|
||||
},
|
||||
args: {
|
||||
shoutId: 1,
|
||||
initialContent: '',
|
||||
disableCollaboration: true
|
||||
content: '',
|
||||
limit: 500,
|
||||
placeholder: 'Start typing here...'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithInitialContent: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
initialContent: '<p>This is some initial content in the editor.</p>'
|
||||
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...'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,14 +4,7 @@ import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
|||
import { CharacterCount } from '@tiptap/extension-character-count'
|
||||
import { Collaboration } from '@tiptap/extension-collaboration'
|
||||
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
|
||||
import { Dropcursor } from '@tiptap/extension-dropcursor'
|
||||
import { FloatingMenu } from '@tiptap/extension-floating-menu'
|
||||
import Focus from '@tiptap/extension-focus'
|
||||
import { Gapcursor } from '@tiptap/extension-gapcursor'
|
||||
import { HardBreak } from '@tiptap/extension-hard-break'
|
||||
import { Highlight } from '@tiptap/extension-highlight'
|
||||
import { HorizontalRule } from '@tiptap/extension-horizontal-rule'
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { Placeholder } from '@tiptap/extension-placeholder'
|
||||
import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js'
|
||||
import uniqolor from 'uniqolor'
|
||||
|
@ -21,23 +14,14 @@ import { useLocalize } from '~/context/localize'
|
|||
import { useSession } from '~/context/session'
|
||||
import { useSnackbar } from '~/context/ui'
|
||||
import { Author } from '~/graphql/schema/core.gen'
|
||||
import { base, custom, extended } from '~/lib/editorExtensions'
|
||||
import { handleImageUpload } from '~/lib/handleImageUpload'
|
||||
import { renderUploadedImage } from '../Upload/renderUploadedImage'
|
||||
import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
|
||||
import { TextBubbleMenu } from './BubbleMenu/TextBubbleMenu'
|
||||
import { EditorFloatingMenu } from './EditorFloatingMenu'
|
||||
import { TextBubbleMenu } from './TextBubbleMenu'
|
||||
import { ArticleNode } from './extensions/Article'
|
||||
import { CustomBlockquote } from './extensions/CustomBlockquote'
|
||||
import { Figcaption } from './extensions/Figcaption'
|
||||
import { Figure } from './extensions/Figure'
|
||||
import { Footnote } from './extensions/Footnote'
|
||||
import { Iframe } from './extensions/Iframe'
|
||||
import { Span } from './extensions/Span'
|
||||
import { ToggleTextWrap } from './extensions/ToggleTextWrap'
|
||||
import { TrailingNode } from './extensions/TrailingNode'
|
||||
import { renderUploadedImage } from './renderUploadedImage'
|
||||
|
||||
import './Prosemirror.scss'
|
||||
import { base } from '~/lib/editorOptions'
|
||||
import './Editor.module.scss'
|
||||
|
||||
export type EditorComponentProps = {
|
||||
shoutId: number
|
||||
|
@ -118,26 +102,11 @@ export const EditorComponent = (props: EditorComponentProps) => {
|
|||
},
|
||||
extensions: [
|
||||
...base,
|
||||
...custom,
|
||||
...extended,
|
||||
|
||||
HorizontalRule.configure({ HTMLAttributes: { class: 'horizontalRule' } }),
|
||||
Dropcursor,
|
||||
CustomBlockquote,
|
||||
Span,
|
||||
ToggleTextWrap,
|
||||
Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }),
|
||||
Focus,
|
||||
Gapcursor,
|
||||
HardBreak,
|
||||
Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }),
|
||||
Image,
|
||||
Iframe,
|
||||
Figure,
|
||||
Figcaption,
|
||||
Footnote,
|
||||
ToggleTextWrap,
|
||||
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
|
||||
TrailingNode,
|
||||
ArticleNode,
|
||||
|
||||
// menus
|
||||
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
import type { Editor } from '@tiptap/core'
|
||||
import { Show, createEffect, createSignal } from 'solid-js'
|
||||
|
||||
import { renderUploadedImage } from '~/components/Editor/renderUploadedImage'
|
||||
import { renderUploadedImage } from '~/components/Upload/renderUploadedImage'
|
||||
import { Icon } from '~/components/_shared/Icon'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useUI } from '~/context/ui'
|
||||
import { useOutsideClickHandler } from '~/lib/useOutsideClickHandler'
|
||||
import { UploadedFile } from '~/types/upload'
|
||||
import { UploadModalContent } from '../../Upload/UploadModalContent'
|
||||
import { InlineForm } from '../../_shared/InlineForm'
|
||||
import { Modal } from '../../_shared/Modal'
|
||||
import { InlineForm } from '../InlineForm'
|
||||
import { UploadModalContent } from '../UploadModalContent'
|
||||
import { Menu } from './Menu'
|
||||
import type { MenuItem } from './Menu/Menu'
|
||||
|
||||
|
|
|
@ -1,133 +0,0 @@
|
|||
import clsx from 'clsx'
|
||||
import { Show } from 'solid-js'
|
||||
import { createEditorTransaction, useEditorHTML, useEditorIsEmpty } from 'solid-tiptap'
|
||||
import { useEditorContext } from '~/context/editor'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useUI } from '~/context/ui'
|
||||
import { Button } from '../_shared/Button'
|
||||
import { Icon } from '../_shared/Icon'
|
||||
import { Loading } from '../_shared/Loading'
|
||||
import { Popover } from '../_shared/Popover'
|
||||
import { SimplifiedEditorProps } from './SimplifiedEditor'
|
||||
|
||||
import styles from './SimplifiedEditor.module.scss'
|
||||
|
||||
export const ToolbarControls = (
|
||||
props: SimplifiedEditorProps & { setShouldShowLinkBubbleMenu: (x: boolean) => void }
|
||||
) => {
|
||||
const { t } = useLocalize()
|
||||
const { showModal } = useUI()
|
||||
const { editor } = useEditorContext()
|
||||
const isActive = (name: string) => createEditorTransaction(editor, (ed) => ed?.isActive(name))
|
||||
const isBold = isActive('bold')
|
||||
const isItalic = isActive('italic')
|
||||
const isLink = isActive('link')
|
||||
const isBlockquote = isActive('blockquote')
|
||||
const isEmpty = useEditorIsEmpty(editor)
|
||||
const html = useEditorHTML(editor)
|
||||
|
||||
const handleClear = () => {
|
||||
props.onCancel?.()
|
||||
editor()?.commands.clearContent(true)
|
||||
}
|
||||
|
||||
const handleShowLinkBubble = () => {
|
||||
editor()?.chain().focus().run()
|
||||
props.setShouldShowLinkBubbleMenu(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={!props.hideToolbar}>
|
||||
{/* Only show controls if 'hideToolbar' is false */}
|
||||
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
|
||||
<div class={styles.actions}>
|
||||
{/* Bold button */}
|
||||
<Popover content={t('Bold')}>
|
||||
{(triggerRef: (el: HTMLElement) => void) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
class={clsx(styles.actionButton, { [styles.active]: isBold() })}
|
||||
onClick={() => editor()?.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<Icon name="editor-bold" />
|
||||
</button>
|
||||
)}
|
||||
</Popover>
|
||||
{/* Italic button */}
|
||||
<Popover content={t('Italic')}>
|
||||
{(triggerRef) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
class={clsx(styles.actionButton, { [styles.active]: isItalic() })}
|
||||
onClick={() => editor()?.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<Icon name="editor-italic" />
|
||||
</button>
|
||||
)}
|
||||
</Popover>
|
||||
{/* Link button */}
|
||||
<Popover content={t('Add url')}>
|
||||
{(triggerRef) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={handleShowLinkBubble}
|
||||
class={clsx(styles.actionButton, { [styles.active]: isLink() })}
|
||||
>
|
||||
<Icon name="editor-link" />
|
||||
</button>
|
||||
)}
|
||||
</Popover>
|
||||
{/* Blockquote button (optional) */}
|
||||
<Show when={props.quoteEnabled}>
|
||||
<Popover content={t('Add blockquote')}>
|
||||
{(triggerRef) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => editor()?.chain().focus().toggleBlockquote().run()}
|
||||
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
|
||||
>
|
||||
<Icon name="editor-quote" />
|
||||
</button>
|
||||
)}
|
||||
</Popover>
|
||||
</Show>
|
||||
{/* Image button (optional) */}
|
||||
<Show when={props.imageEnabled}>
|
||||
<Popover content={t('Add image')}>
|
||||
{(triggerRef) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => showModal('simplifiedEditorUploadImage')}
|
||||
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
|
||||
>
|
||||
<Icon name="editor-image-dd-full" />
|
||||
</button>
|
||||
)}
|
||||
</Popover>
|
||||
</Show>
|
||||
</div>
|
||||
{/* Cancel and submit buttons */}
|
||||
<Show when={!props.onChange}>
|
||||
<div class={styles.buttons}>
|
||||
<Show when={props.isCancelButtonVisible}>
|
||||
<Button value={t('Cancel')} variant="secondary" onClick={handleClear} />
|
||||
</Show>
|
||||
<Show when={!props.isPosting} fallback={<Loading />}>
|
||||
<Button
|
||||
value={props.submitButtonText ?? t('Send')}
|
||||
variant="primary"
|
||||
disabled={isEmpty()}
|
||||
onClick={() => props.onSubmit?.(html() || '')}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
138
src/components/Editor/EditorToolbar/EditorToolbar.tsx
Normal file
138
src/components/Editor/EditorToolbar/EditorToolbar.tsx
Normal file
|
@ -0,0 +1,138 @@
|
|||
import { Editor } from '@tiptap/core'
|
||||
import { Accessor, Show, createEffect, createSignal, on } from 'solid-js'
|
||||
import { Portal } from 'solid-js/web'
|
||||
import { createEditorTransaction } from 'solid-tiptap'
|
||||
import { UploadModalContent } from '~/components/Upload/UploadModalContent/UploadModalContent'
|
||||
import { renderUploadedImage } from '~/components/Upload/renderUploadedImage'
|
||||
import { Icon } from '~/components/_shared/Icon/Icon'
|
||||
import { Modal } from '~/components/_shared/Modal/Modal'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useUI } from '~/context/ui'
|
||||
import { UploadedFile } from '~/types/upload'
|
||||
import { InsertLinkForm } from './InsertLinkForm'
|
||||
import { ToolbarControl as Control } from './ToolbarControl'
|
||||
|
||||
import styles from '../MiniEditor/MiniEditor.module.scss'
|
||||
|
||||
interface EditorToolbarProps {
|
||||
editor: Accessor<Editor | undefined>
|
||||
mode?: 'micro' | 'mini'
|
||||
}
|
||||
|
||||
export const EditorToolbar = (props: EditorToolbarProps) => {
|
||||
const { t } = useLocalize()
|
||||
const { showModal } = useUI()
|
||||
|
||||
// show / hide for link input
|
||||
const [showLinkInput, setShowLinkInput] = createSignal(false)
|
||||
|
||||
// focus on link input when it shows up
|
||||
createEffect(on(showLinkInput, (x?: boolean) => x && props.editor()?.chain().focus().run()))
|
||||
|
||||
const selection = createEditorTransaction(props.editor, (instance) => instance?.state.selection)
|
||||
|
||||
// change visibility on selection if not in link input mode
|
||||
const [showSimpleMenu, setShowSimpleMenu] = createSignal(false)
|
||||
createEffect(
|
||||
on([selection, showLinkInput], ([s, l]) => props.mode === 'micro' && !l && setShowSimpleMenu(!s?.empty))
|
||||
)
|
||||
|
||||
const [storedSelection, setStoredSelection] = createSignal<Editor['state']['selection']>()
|
||||
const recoverSelection = () => {
|
||||
if (!storedSelection()?.empty) {
|
||||
createEditorTransaction(props.editor, (instance?: Editor) => {
|
||||
const r = selection()
|
||||
if (instance && r) {
|
||||
instance.state.selection.from === r.from
|
||||
instance.state.selection.to === r.to
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
const storeSelection = () => {
|
||||
const selection = props.editor()?.state.selection
|
||||
if (!selection?.empty) {
|
||||
setStoredSelection(selection)
|
||||
}
|
||||
}
|
||||
const toggleShowLink = () => {
|
||||
if (showLinkInput()) {
|
||||
props.editor()?.chain().focus().run()
|
||||
recoverSelection()
|
||||
} else {
|
||||
storeSelection()
|
||||
}
|
||||
setShowLinkInput(!showLinkInput())
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ 'background-color': 'white', display: 'inline-flex' }}>
|
||||
<Show
|
||||
when={((props.mode === 'micro' && showSimpleMenu()) || props.mode !== 'micro') && props.editor()}
|
||||
keyed
|
||||
>
|
||||
{(instance) => (
|
||||
<div class={styles.controls}>
|
||||
<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={toggleShowLink}
|
||||
title={t('Add url')}
|
||||
isActive={showLinkInput}
|
||||
>
|
||||
<Icon name="editor-link" />
|
||||
</Control>
|
||||
<Show when={props.mode !== 'micro'}>
|
||||
<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>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={showLinkInput()}>
|
||||
<InsertLinkForm editor={instance} onClose={toggleShowLink} />
|
||||
</Show>
|
||||
|
||||
<Portal>
|
||||
<Modal variant="narrow" name="simplifiedEditorUploadImage">
|
||||
<UploadModalContent
|
||||
onClose={(image) => renderUploadedImage(instance as Editor, image as UploadedFile)}
|
||||
/>
|
||||
</Modal>
|
||||
</Portal>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,9 +1,8 @@
|
|||
import { Editor } from '@tiptap/core'
|
||||
import { createEditorTransaction } from 'solid-tiptap'
|
||||
|
||||
import { createEffect, createSignal, onCleanup } from 'solid-js'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { validateUrl } from '~/utils/validate'
|
||||
import { InlineForm } from '../InlineForm'
|
||||
import { InlineForm } from '../../_shared/InlineForm'
|
||||
|
||||
type Props = {
|
||||
editor: Editor
|
||||
|
@ -21,12 +20,22 @@ export const checkUrl = (url: string) => {
|
|||
|
||||
export const InsertLinkForm = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const currentUrl = createEditorTransaction(
|
||||
() => props.editor,
|
||||
(ed) => {
|
||||
return ed?.getAttributes('link').href || ''
|
||||
const [currentUrl, setCurrentUrl] = createSignal('')
|
||||
|
||||
createEffect(() => {
|
||||
const url = props.editor.getAttributes('link').href
|
||||
setCurrentUrl(url || '')
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const updateListener = () => {
|
||||
const url = props.editor.getAttributes('link').href
|
||||
setCurrentUrl(url || '')
|
||||
}
|
||||
)
|
||||
props.editor.on('update', updateListener)
|
||||
onCleanup(() => props.editor.off('update', updateListener))
|
||||
})
|
||||
|
||||
const handleClearLinkForm = () => {
|
||||
if (currentUrl()) {
|
||||
props.editor?.chain().focus().unsetLink().run()
|
||||
|
@ -39,7 +48,9 @@ export const InsertLinkForm = (props: Props) => {
|
|||
.focus()
|
||||
.setLink({ href: checkUrl(value) })
|
||||
.run()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InlineForm
|
40
src/components/Editor/EditorToolbar/ToolbarControl.tsx
Normal file
40
src/components/Editor/EditorToolbar/ToolbarControl.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { Editor } from '@tiptap/core'
|
||||
import clsx from 'clsx'
|
||||
import { JSX } from 'solid-js'
|
||||
import { Popover } from '~/components/_shared/Popover'
|
||||
|
||||
import styles from '../MiniEditor/MiniEditor.module.scss'
|
||||
|
||||
interface ControlProps {
|
||||
editor: Editor
|
||||
title: string
|
||||
key: string
|
||||
onChange: () => void
|
||||
isActive?: (editor: Editor) => boolean
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
export const ToolbarControl = (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>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolbarControl
|
|
@ -1 +0,0 @@
|
|||
export { InsertLinkForm } from './InsertLinkForm'
|
|
@ -1,4 +0,0 @@
|
|||
.LinkBubbleMenu {
|
||||
background: var(--editor-bubble-menu-background);
|
||||
box-shadow: 0 4px 10px rgba(#000, 0.25);
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
import { InsertLinkForm } from '../InsertLinkForm'
|
||||
|
||||
import styles from './LinkBubbleMenu.module.scss'
|
||||
|
||||
type Props = {
|
||||
editor: Editor
|
||||
ref: (el: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const LinkBubbleMenuModule = (props: Props) => {
|
||||
return (
|
||||
<div ref={props.ref} class={styles.LinkBubbleMenu}>
|
||||
<InsertLinkForm editor={props.editor} onClose={props.onClose} />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { LinkBubbleMenuModule } from './LinkBubbleMenu.module'
|
51
src/components/Editor/MicroEditor/MicroEditor.stories.tsx
Normal file
51
src/components/Editor/MicroEditor/MicroEditor.stories.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { Meta, StoryObj } from 'storybook-solidjs'
|
||||
import { MicroEditor } from './MicroEditor'
|
||||
|
||||
const meta: Meta<typeof MicroEditor> = {
|
||||
title: 'Components/MicroEditor',
|
||||
component: MicroEditor,
|
||||
argTypes: {
|
||||
content: {
|
||||
control: 'text',
|
||||
description: 'Initial content for the editor',
|
||||
defaultValue: ''
|
||||
},
|
||||
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 MicroEditor>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
content: '',
|
||||
placeholder: 'Start typing here...',
|
||||
onChange: (content: string) => console.log('Content changed:', content)
|
||||
}
|
||||
}
|
||||
|
||||
export const WithInitialContent: Story = {
|
||||
args: {
|
||||
content: 'This is some initial content.',
|
||||
placeholder: 'Start typing here...',
|
||||
onChange: (content: string) => console.log('Content changed:', content)
|
||||
}
|
||||
}
|
||||
|
||||
export const WithCustomPlaceholder: Story = {
|
||||
args: {
|
||||
content: '',
|
||||
placeholder: 'Type your text here...',
|
||||
onChange: (content: string) => console.log('Content changed:', content)
|
||||
}
|
||||
}
|
48
src/components/Editor/MicroEditor/MicroEditor.tsx
Normal file
48
src/components/Editor/MicroEditor/MicroEditor.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import clsx from 'clsx'
|
||||
import { type JSX, createEffect, createSignal, on } from 'solid-js'
|
||||
import { createTiptapEditor, useEditorHTML, useEditorIsFocused } from 'solid-tiptap'
|
||||
import { minimal } from '~/lib/editorExtensions'
|
||||
import { EditorToolbar } from '../EditorToolbar/EditorToolbar'
|
||||
|
||||
import styles from '../MiniEditor/MiniEditor.module.scss'
|
||||
|
||||
interface MicroEditorProps {
|
||||
content?: string
|
||||
onChange?: (content: string) => void
|
||||
onSubmit?: (content: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export const MicroEditor = (props: MicroEditorProps): JSX.Element => {
|
||||
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
|
||||
|
||||
const editor = createTiptapEditor(() => ({
|
||||
element: editorElement()!,
|
||||
extensions: [
|
||||
...minimal,
|
||||
Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder })
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: styles.simplifiedEditorField
|
||||
}
|
||||
},
|
||||
content: props.content || ''
|
||||
}))
|
||||
|
||||
const isFocused = useEditorIsFocused(editor)
|
||||
const html = useEditorHTML(editor)
|
||||
createEffect(on(html, (c?: string) => c && props.onChange?.(c)))
|
||||
|
||||
return (
|
||||
<div class={clsx(styles.MiniEditor, styles.bordered, { [styles.isFocused]: isFocused() })}>
|
||||
<div>
|
||||
<EditorToolbar editor={editor} mode={'micro'} />
|
||||
<div id="micro-editor" ref={setEditorElement} style={styles.minimal} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MicroEditor
|
|
@ -1,4 +1,4 @@
|
|||
.SimplifiedEditor {
|
||||
.MiniEditor {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
|
@ -1,71 +1,28 @@
|
|||
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 { type JSX, Show, createEffect, createSignal, on } from 'solid-js'
|
||||
import { createEditorTransaction, createTiptapEditor, useEditorHTML } from 'solid-tiptap'
|
||||
import { Button } from '~/components/_shared/Button'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useUI } from '~/context/ui'
|
||||
import { base } from '~/lib/editorOptions'
|
||||
import { InsertLinkForm } from '../InsertLinkForm/InsertLinkForm'
|
||||
import { base } from '~/lib/editorExtensions'
|
||||
import { EditorToolbar } from '../EditorToolbar/EditorToolbar'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
import styles from './MiniEditor.module.scss'
|
||||
|
||||
interface MiniEditorProps {
|
||||
content?: string
|
||||
onChange?: (content: string) => void
|
||||
onSubmit?: (content: string) => void
|
||||
onCancel?: () => void
|
||||
limit?: number
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export default function MiniEditor(props: MiniEditorProps): JSX.Element {
|
||||
const { t } = useLocalize()
|
||||
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()!,
|
||||
|
@ -82,12 +39,11 @@ export default function MiniEditor(props: MiniEditorProps): JSX.Element {
|
|||
content: props.content || ''
|
||||
}))
|
||||
|
||||
const isEmpty = useEditorIsEmpty(editor)
|
||||
const isFocused = useEditorIsFocused(editor)
|
||||
const isTextSelection = createEditorTransaction(editor, (instance) => !instance?.state.selection.empty)
|
||||
const isFocused = createEditorTransaction(editor, (instance) => instance?.isFocused)
|
||||
const isEmpty = createEditorTransaction(editor, (instance) => instance?.isEmpty)
|
||||
const html = useEditorHTML(editor)
|
||||
|
||||
createEffect(() => setShowSimpleMenu(isTextSelection()))
|
||||
createEffect(on(html, (c?: string) => c && props.onChange?.(c)))
|
||||
|
||||
createEffect(() => {
|
||||
const textLength = editor()?.getText().length || 0
|
||||
|
@ -96,88 +52,28 @@ export default function MiniEditor(props: MiniEditorProps): JSX.Element {
|
|||
content && props.onChange?.(content)
|
||||
})
|
||||
|
||||
const handleLinkClick = () => {
|
||||
setShowLinkInput(!showLinkInput())
|
||||
editor()?.chain().focus().run()
|
||||
const handleSubmit = () => {
|
||||
html() && props.onSubmit?.(html() || '')
|
||||
editor()?.commands.clearContent(true)
|
||||
}
|
||||
|
||||
// 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 class={clsx(styles.MiniEditor, styles.bordered, { [styles.isFocused]: 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} />
|
||||
|
||||
<EditorToolbar editor={editor} mode={'mini'} />
|
||||
|
||||
<div class={styles.buttons}>
|
||||
<Button
|
||||
value={t('Cancel')}
|
||||
disabled={isEmpty()}
|
||||
variant="secondary"
|
||||
onClick={() => editor()?.commands.clearContent()}
|
||||
/>
|
||||
<Button value={t('Send')} variant="primary" disabled={isEmpty()} onClick={handleSubmit} />
|
||||
</div>
|
||||
|
||||
<Show when={counter() > 0}>
|
||||
<small class={styles.limit}>
|
||||
{counter()} / {props.limit || '∞'}
|
||||
|
|
|
@ -1,99 +0,0 @@
|
|||
import { Meta, StoryObj } from 'storybook-solidjs'
|
||||
import SimplifiedEditor from './SimplifiedEditor'
|
||||
|
||||
const meta: Meta<typeof SimplifiedEditor> = {
|
||||
title: 'Components/SimplifiedEditor',
|
||||
component: SimplifiedEditor,
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text when the editor is empty',
|
||||
defaultValue: 'Type something...'
|
||||
},
|
||||
initialContent: {
|
||||
control: 'text',
|
||||
description: 'Initial content for the editor',
|
||||
defaultValue: ''
|
||||
},
|
||||
maxLength: {
|
||||
control: 'number',
|
||||
description: 'Character limit for the editor',
|
||||
defaultValue: 400
|
||||
},
|
||||
quoteEnabled: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the blockquote feature is enabled',
|
||||
defaultValue: true
|
||||
},
|
||||
imageEnabled: {
|
||||
control: 'boolean',
|
||||
description: 'Whether the image feature is enabled',
|
||||
defaultValue: true
|
||||
},
|
||||
submitButtonText: {
|
||||
control: 'text',
|
||||
description: 'Text for the submit button',
|
||||
defaultValue: 'Submit'
|
||||
},
|
||||
onSubmit: {
|
||||
action: 'submitted',
|
||||
description: 'Callback when the form is submitted'
|
||||
},
|
||||
onCancel: {
|
||||
action: 'cancelled',
|
||||
description: 'Callback when the editor is cleared'
|
||||
},
|
||||
onChange: {
|
||||
action: 'changed',
|
||||
description: 'Callback when the content changes'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof SimplifiedEditor>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
initialContent: '',
|
||||
maxLength: 400,
|
||||
quoteEnabled: true,
|
||||
imageEnabled: true,
|
||||
submitButtonText: 'Submit'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithInitialContent: Story = {
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
initialContent: 'This is some initial content',
|
||||
maxLength: 400,
|
||||
quoteEnabled: true,
|
||||
imageEnabled: true,
|
||||
submitButtonText: 'Submit'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithCharacterLimit: Story = {
|
||||
args: {
|
||||
placeholder: 'You have a 50 character limit...',
|
||||
initialContent: '',
|
||||
maxLength: 50,
|
||||
quoteEnabled: true,
|
||||
imageEnabled: true,
|
||||
submitButtonText: 'Submit'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithCustomPlaceholder: Story = {
|
||||
args: {
|
||||
placeholder: 'Custom placeholder here...',
|
||||
initialContent: '',
|
||||
maxLength: 400,
|
||||
quoteEnabled: true,
|
||||
imageEnabled: true,
|
||||
submitButtonText: 'Submit'
|
||||
}
|
||||
}
|
|
@ -1,244 +0,0 @@
|
|||
import { Editor, FocusPosition } from '@tiptap/core'
|
||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
||||
import { CharacterCount } from '@tiptap/extension-character-count'
|
||||
import { Placeholder } from '@tiptap/extension-placeholder'
|
||||
import { clsx } from 'clsx'
|
||||
import { Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
|
||||
import { Portal } from 'solid-js/web'
|
||||
import { createEditorTransaction, useEditorHTML, useEditorIsEmpty, useEditorIsFocused } from 'solid-tiptap'
|
||||
import { useEditorContext } from '~/context/editor'
|
||||
import { useUI } from '~/context/ui'
|
||||
import { base, custom } from '~/lib/editorOptions'
|
||||
import { useEscKeyDownHandler } from '~/lib/useEscKeyDownHandler'
|
||||
import { UploadedFile } from '~/types/upload'
|
||||
import { Modal } from '../_shared/Modal/Modal'
|
||||
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||
import { ToolbarControls } from './EditorToolbar'
|
||||
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
|
||||
import { TextBubbleMenu } from './TextBubbleMenu'
|
||||
import { UploadModalContent } from './UploadModalContent'
|
||||
import { renderUploadedImage } from './renderUploadedImage'
|
||||
|
||||
import styles from './SimplifiedEditor.module.scss'
|
||||
|
||||
export type SimplifiedEditorProps = {
|
||||
placeholder: string
|
||||
initialContent?: string
|
||||
label?: string
|
||||
onSubmit?: (text: string) => void
|
||||
onCancel?: () => void
|
||||
onChange?: (text: string) => void
|
||||
variant?: 'minimal' | 'bordered'
|
||||
maxLength?: number
|
||||
noLimits?: boolean
|
||||
maxHeight?: number
|
||||
submitButtonText?: string
|
||||
quoteEnabled?: boolean
|
||||
imageEnabled?: boolean
|
||||
setClear?: boolean
|
||||
resetToInitial?: boolean
|
||||
smallHeight?: boolean
|
||||
submitByCtrlEnter?: boolean
|
||||
hideToolbar?: boolean
|
||||
controlsAlwaysVisible?: boolean
|
||||
autoFocus?: boolean
|
||||
isCancelButtonVisible?: boolean
|
||||
isPosting?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_LENGTH = 400
|
||||
|
||||
const SimplifiedEditor = (props: SimplifiedEditorProps) => {
|
||||
// local signals
|
||||
const [counter, setCounter] = createSignal<number>(0)
|
||||
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
|
||||
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
|
||||
const [editorElement, setEditorElement] = createSignal<HTMLDivElement | undefined>()
|
||||
const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||
const [linkBubbleMenuRef, setLinkBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||
|
||||
// contexts
|
||||
const { hideModal } = useUI()
|
||||
const { editor, createEditor } = useEditorContext()
|
||||
|
||||
const initEditor = (element?: HTMLElement) => {
|
||||
if (element instanceof HTMLElement && editor()?.options.element !== element) {
|
||||
const opts = {
|
||||
element,
|
||||
extensions: [
|
||||
// common extensions
|
||||
...base,
|
||||
...custom,
|
||||
|
||||
// setup from component props
|
||||
Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }),
|
||||
CharacterCount.configure({ limit: props.noLimits ? undefined : props.maxLength }),
|
||||
|
||||
// bubble menu 1
|
||||
BubbleMenu.configure({
|
||||
pluginKey: 'bubble-menu',
|
||||
element: textBubbleMenuRef(),
|
||||
shouldShow: ({ view }) => view.hasFocus() && shouldShowTextBubbleMenu()
|
||||
}),
|
||||
|
||||
// bubble menu 2
|
||||
BubbleMenu.configure({
|
||||
pluginKey: 'bubble-link-input',
|
||||
element: linkBubbleMenuRef(),
|
||||
shouldShow: ({ state }) => !state.selection.empty && shouldShowLinkBubbleMenu(),
|
||||
tippyOptions: { placement: 'bottom' }
|
||||
})
|
||||
],
|
||||
editorProps: {
|
||||
attributes: { class: styles.simplifiedEditorField }
|
||||
},
|
||||
content: props.initialContent || '',
|
||||
onCreate: () => console.info('[SimplifiedEditor] created'),
|
||||
onContentError: console.error,
|
||||
autofocus: (props.autoFocus && 'end') as FocusPosition | undefined,
|
||||
editable: true,
|
||||
enableCoreExtensions: true,
|
||||
enableContentCheck: true,
|
||||
injectNonce: undefined, // TODO: can be useful copyright/copyleft mark
|
||||
parseOptions: undefined // see: https://prosemirror.net/docs/ref/#model.ParseOptions
|
||||
}
|
||||
|
||||
createEditor(opts)
|
||||
}
|
||||
}
|
||||
|
||||
// editor observers
|
||||
const isEmpty = useEditorIsEmpty(editor)
|
||||
const isFocused = useEditorIsFocused(editor)
|
||||
const selection = createEditorTransaction(editor, (ed) => ed?.state.selection)
|
||||
const html = useEditorHTML(editor)
|
||||
|
||||
/// EFFECTS ///
|
||||
|
||||
// Mount event listeners for handling key events and clean up on component unmount
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
editor()?.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
// watch changes
|
||||
createEffect(on(editorElement, initEditor, { defer: true })) // element -> editorOptions -> set editor
|
||||
createEffect(
|
||||
on(selection, (s?: Editor['state']['selection']) => s && setShouldShowTextBubbleMenu(!s?.empty))
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => props.setClear,
|
||||
(x?: boolean) => x && editor()?.commands.clearContent(true)
|
||||
)
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
() => props.resetToInitial,
|
||||
(x?: boolean) => x && editor()?.commands.setContent(props.initialContent || '')
|
||||
)
|
||||
)
|
||||
createEffect(on([html, () => props.onChange], ([c, handler]) => c && handler && handler(c))) // onChange
|
||||
createEffect(on(html, (c?: string) => c && setCounter(editor()?.storage.characterCount.characters()))) //counter
|
||||
|
||||
/// HANDLERS ///
|
||||
|
||||
const handleImageRender = (image?: UploadedFile) => {
|
||||
image && renderUploadedImage(editor() as Editor, image)
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
isFocused() &&
|
||||
!isEmpty() &&
|
||||
event.code === 'Enter' &&
|
||||
props.submitByCtrlEnter &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
props.onSubmit?.(html() || '')
|
||||
}
|
||||
}
|
||||
|
||||
const handleHideLinkBubble = () => {
|
||||
editor()?.commands.focus()
|
||||
setShouldShowLinkBubbleMenu(false)
|
||||
}
|
||||
|
||||
useEscKeyDownHandler(handleHideLinkBubble)
|
||||
|
||||
return (
|
||||
<ShowOnlyOnClient>
|
||||
<div
|
||||
class={clsx(styles.SimplifiedEditor, {
|
||||
[styles.smallHeight]: props.smallHeight,
|
||||
[styles.minimal]: props.variant === 'minimal',
|
||||
[styles.bordered]: props.variant === 'bordered',
|
||||
[styles.isFocused]: isFocused() || !isEmpty(),
|
||||
[styles.labelVisible]: props.label && counter() > 0
|
||||
})}
|
||||
>
|
||||
{/* Display label when applicable */}
|
||||
<Show when={props.label && counter() > 0}>
|
||||
<div class={styles.label}>{props.label}</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={props.hideToolbar}
|
||||
fallback={
|
||||
<ToolbarControls {...props} setShouldShowLinkBubbleMenu={setShouldShowLinkBubbleMenu} />
|
||||
}
|
||||
>
|
||||
<TextBubbleMenu
|
||||
editor={editor() as Editor}
|
||||
ref={setTextBubbleMenuRef}
|
||||
shouldShow={shouldShowTextBubbleMenu()}
|
||||
isCommonMarkup={true}
|
||||
/>
|
||||
|
||||
{/* Link bubble menu */}
|
||||
<Show when={shouldShowLinkBubbleMenu()}>
|
||||
<LinkBubbleMenuModule
|
||||
editor={editor() as Editor}
|
||||
ref={setLinkBubbleMenuRef}
|
||||
onClose={handleHideLinkBubble}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* editor element */}
|
||||
<div
|
||||
style={
|
||||
props.maxHeight
|
||||
? {
|
||||
overflow: 'auto',
|
||||
'max-height': `${props.maxHeight}px`
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
ref={setEditorElement}
|
||||
/>
|
||||
|
||||
{/* Display character limit if maxLength is provided */}
|
||||
<Show when={props.maxLength && editor()}>
|
||||
<div class={styles.limit}>{(props.maxLength || DEFAULT_MAX_LENGTH) - counter()}</div>
|
||||
</Show>
|
||||
|
||||
{/* Image upload modal (show/hide) */}
|
||||
<Show when={props.imageEnabled}>
|
||||
<Portal>
|
||||
<Modal variant="narrow" name="simplifiedEditorUploadImage">
|
||||
<UploadModalContent onClose={handleImageRender} />
|
||||
</Modal>
|
||||
</Portal>
|
||||
</Show>
|
||||
</div>
|
||||
</ShowOnlyOnClient>
|
||||
)
|
||||
}
|
||||
|
||||
export default SimplifiedEditor // Export component for lazy loading
|
|
@ -1 +0,0 @@
|
|||
export { TextBubbleMenu } from './TextBubbleMenu'
|
|
@ -1,33 +0,0 @@
|
|||
.selectedItem {
|
||||
cursor: pointer;
|
||||
|
||||
&.mainTopic {
|
||||
cursor: default;
|
||||
|
||||
&,
|
||||
+ :global(.solid-select-multi-value-remove) {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: #000;
|
||||
content: '';
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.TopicSelect .solid-select-list {
|
||||
background: #fff;
|
||||
position: relative;
|
||||
z-index: 13;
|
||||
}
|
||||
|
||||
.TopicSelect .solid-select-option[data-disabled='true'] {
|
||||
display: none;
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export { EditorComponent as Editor } from './Editor'
|
||||
export { Panel } from './Panel'
|
||||
export { TopicSelect } from './TopicSelect'
|
||||
export { UploadModalContent } from './UploadModalContent'
|
73
src/components/TopicSelect/TopicSelect.module.scss
Normal file
73
src/components/TopicSelect/TopicSelect.module.scss
Normal file
|
@ -0,0 +1,73 @@
|
|||
.selectedItem {
|
||||
cursor: pointer;
|
||||
|
||||
&.mainTopic {
|
||||
cursor: default;
|
||||
|
||||
&,
|
||||
+ :global(.solid-select-multi-value-remove) {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: #000;
|
||||
content: '';
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.TopicSelect .solid-select-list {
|
||||
background: #fff;
|
||||
position: relative;
|
||||
z-index: 13;
|
||||
}
|
||||
|
||||
.TopicSelect .solid-select-option[data-disabled='true'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.selectedTopics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.selectedTopic {
|
||||
background-color: #f0f0f0;
|
||||
margin: 4px;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.selectWrapper {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.options {
|
||||
margin-top: 10px;
|
||||
background-color: white;
|
||||
box-shadow: 0 4px 8px rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: gray;
|
||||
pointer-events: none;
|
||||
}
|
|
@ -10,7 +10,7 @@ import { useSession } from '~/context/session'
|
|||
import { useUI } from '~/context/ui'
|
||||
import { handleImageUpload } from '~/lib/handleImageUpload'
|
||||
import { UploadedFile } from '~/types/upload'
|
||||
import { InlineForm } from '../InlineForm'
|
||||
import { InlineForm } from '../../_shared/InlineForm'
|
||||
|
||||
import styles from './UploadModalContent.module.scss'
|
||||
|
|
@ -3,7 +3,6 @@ import { clsx } from 'clsx'
|
|||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js'
|
||||
import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper'
|
||||
import { Loading } from '~/components/_shared/Loading'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { useAuthors } from '~/context/authors'
|
||||
import { SHOUTS_PER_PAGE, useFeed } from '~/context/feed'
|
||||
import { useFollowing } from '~/context/following'
|
||||
|
@ -11,7 +10,6 @@ import { useLocalize } from '~/context/localize'
|
|||
import { useReactions } from '~/context/reactions'
|
||||
import { useSession } from '~/context/session'
|
||||
import { loadReactions, loadShouts } from '~/graphql/api/public'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import getAuthorFollowersQuery from '~/graphql/query/core/author-followers'
|
||||
import getAuthorFollowsQuery from '~/graphql/query/core/author-follows'
|
||||
import type { Author, Reaction, Shout, Topic } from '~/graphql/schema/core.gen'
|
||||
|
@ -45,8 +43,7 @@ export const AuthorView = (props: AuthorViewProps) => {
|
|||
const params = useParams()
|
||||
const [currentTab, setCurrentTab] = createSignal<string>(params.tab)
|
||||
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const { session, client } = useSession()
|
||||
|
||||
const { loadAuthor, authorsEntities } = useAuthors()
|
||||
const { followers: myFollowers, follows: myFollows } = useFollowing()
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
import { useNavigate } from '@solidjs/router'
|
||||
import { clsx } from 'clsx'
|
||||
import { For, Show, createMemo, createSignal } from 'solid-js'
|
||||
import { For, Show, createSignal } from 'solid-js'
|
||||
import { Draft } from '~/components/Draft'
|
||||
import { Loading } from '~/components/_shared/Loading'
|
||||
import { useEditorContext } from '~/context/editor'
|
||||
import { useSession } from '~/context/session'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { Shout } from '~/graphql/schema/core.gen'
|
||||
import styles from './DraftsView.module.scss'
|
||||
|
||||
export const DraftsView = (props: { drafts: Shout[] }) => {
|
||||
const [drafts, setDrafts] = createSignal<Shout[]>(props.drafts || [])
|
||||
const { session } = useSession()
|
||||
const authorized = createMemo<boolean>(() => Boolean(session()?.access_token))
|
||||
const navigate = useNavigate()
|
||||
const { publishShoutById, deleteShout } = useEditorContext()
|
||||
const handleDraftDelete = async (shout: Shout) => {
|
||||
|
@ -26,13 +23,19 @@ export const DraftsView = (props: { drafts: Shout[] }) => {
|
|||
setTimeout(() => navigate('/feed'), 2000)
|
||||
}
|
||||
|
||||
const { t } = useLocalize()
|
||||
|
||||
return (
|
||||
<div class={clsx(styles.DraftsView)}>
|
||||
<Show when={authorized()} fallback={<Loading />}>
|
||||
<div class="wide-container">
|
||||
<div class="row offset-md-5">
|
||||
<h2>{t('Drafts')}</h2>
|
||||
</div>
|
||||
<Show when={drafts()} fallback={t('No drafts')}>
|
||||
{(ddd) => (
|
||||
<div class="row">
|
||||
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
|
||||
<For each={drafts()}>
|
||||
<For each={ddd()}>
|
||||
{(draft) => (
|
||||
<Draft
|
||||
class={styles.draft}
|
||||
|
@ -44,8 +47,9 @@ export const DraftsView = (props: { drafts: Shout[] }) => {
|
|||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,20 +1,18 @@
|
|||
import { clsx } from 'clsx'
|
||||
import deepEqual from 'fast-deep-equal'
|
||||
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
|
||||
import { Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
|
||||
import { createStore } from 'solid-js/store'
|
||||
import { debounce } from 'throttle-debounce'
|
||||
import { Panel } from '~/components/Editor/Panel/Panel'
|
||||
import { Icon } from '~/components/_shared/Icon'
|
||||
import { InviteMembers } from '~/components/_shared/InviteMembers'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { ShoutForm, useEditorContext } from '~/context/editor'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useSession } from '~/context/session'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import getMyShoutQuery from '~/graphql/query/core/article-my'
|
||||
import type { Shout, Topic } from '~/graphql/schema/core.gen'
|
||||
import { isDesktop } from '~/lib/mediaQuery'
|
||||
import { clone } from '~/utils/clone'
|
||||
import { Panel } from '../../Editor'
|
||||
import { AutoSaveNotice } from '../../Editor/AutoSaveNotice'
|
||||
import { Modal } from '../../_shared/Modal'
|
||||
import { TableOfContents } from '../../_shared/TableOfContents'
|
||||
|
@ -44,8 +42,7 @@ const handleScrollTopButtonClick = (ev: MouseEvent | TouchEvent) => {
|
|||
export const EditSettingsView = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const [isScrolled, setIsScrolled] = createSignal(false)
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const { client } = useSession()
|
||||
const { form, setForm, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage } = useEditorContext()
|
||||
const [shoutTopics, setShoutTopics] = createSignal<Topic[]>([])
|
||||
const [draft, setDraft] = createSignal()
|
||||
|
|
|
@ -1,29 +1,19 @@
|
|||
import { clsx } from 'clsx'
|
||||
import deepEqual from 'fast-deep-equal'
|
||||
import {
|
||||
Accessor,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
lazy,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount
|
||||
} from 'solid-js'
|
||||
import { Show, createEffect, createSignal, lazy, on, onCleanup, onMount } from 'solid-js'
|
||||
import { createStore } from 'solid-js/store'
|
||||
import { debounce } from 'throttle-debounce'
|
||||
import { EditorComponent } from '~/components/Editor/Editor'
|
||||
import { Panel } from '~/components/Editor/Panel/Panel'
|
||||
import { DropArea } from '~/components/_shared/DropArea'
|
||||
import { Icon } from '~/components/_shared/Icon'
|
||||
import { InviteMembers } from '~/components/_shared/InviteMembers'
|
||||
import { Loading } from '~/components/_shared/Loading'
|
||||
import { Popover } from '~/components/_shared/Popover'
|
||||
import { EditorSwiper } from '~/components/_shared/SolidSwiper'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { ShoutForm, useEditorContext } from '~/context/editor'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useSession } from '~/context/session'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import getMyShoutQuery from '~/graphql/query/core/article-my'
|
||||
import type { Shout, Topic } from '~/graphql/schema/core.gen'
|
||||
import { slugify } from '~/intl/translit'
|
||||
|
@ -32,15 +22,14 @@ import { isDesktop } from '~/lib/mediaQuery'
|
|||
import { LayoutType } from '~/types/common'
|
||||
import { MediaItem } from '~/types/mediaitem'
|
||||
import { clone } from '~/utils/clone'
|
||||
import { Editor as EditorComponent, Panel } from '../../Editor'
|
||||
import { AudioUploader } from '../../Editor/AudioUploader'
|
||||
import { AutoSaveNotice } from '../../Editor/AutoSaveNotice'
|
||||
import { VideoUploader } from '../../Editor/VideoUploader'
|
||||
import { AudioUploader } from '../../Upload/AudioUploader'
|
||||
import { VideoUploader } from '../../Upload/VideoUploader'
|
||||
import { Modal } from '../../_shared/Modal'
|
||||
import { TableOfContents } from '../../_shared/TableOfContents'
|
||||
import styles from './EditView.module.scss'
|
||||
|
||||
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
||||
const MicroEditor = lazy(() => import('../../Editor/MicroEditor/MicroEditor'))
|
||||
const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
|
||||
|
||||
type Props = {
|
||||
|
@ -65,10 +54,7 @@ const handleScrollTopButtonClick = (ev: MouseEvent | TouchEvent) => {
|
|||
|
||||
export const EditView = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const [isScrolled, setIsScrolled] = createSignal(false)
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
|
||||
const { client } = useSession()
|
||||
const {
|
||||
form,
|
||||
formErrors,
|
||||
|
@ -78,14 +64,18 @@ export const EditView = (props: Props) => {
|
|||
saveDraftToLocalStorage,
|
||||
getDraftFromLocalStorage
|
||||
} = useEditorContext()
|
||||
const [shoutTopics, setShoutTopics] = createSignal<Topic[]>([])
|
||||
const [draft, setDraft] = createSignal()
|
||||
let subtitleInput: HTMLTextAreaElement | null
|
||||
|
||||
const [subtitleInput, setSubtitleInput] = createSignal<HTMLTextAreaElement | undefined>()
|
||||
const [prevForm, setPrevForm] = createStore<ShoutForm>(clone(form))
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
const [isSubtitleVisible, setIsSubtitleVisible] = createSignal(Boolean(form.subtitle))
|
||||
const [isLeadVisible, setIsLeadVisible] = createSignal(Boolean(form.lead))
|
||||
const mediaItems: Accessor<MediaItem[]> = createMemo(() => JSON.parse(form.media || '[]'))
|
||||
const [isScrolled, setIsScrolled] = createSignal(false)
|
||||
const [shoutTopics, setShoutTopics] = createSignal<Topic[]>([])
|
||||
const [draft, setDraft] = createSignal<Shout>(props.shout)
|
||||
const [mediaItems, setMediaItems] = createSignal<MediaItem[]>([])
|
||||
|
||||
createEffect(() => setMediaItems(JSON.parse(form.media || '[]')))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
|
@ -97,7 +87,7 @@ export const EditView = (props: Props) => {
|
|||
const stored = getDraftFromLocalStorage(shout.id)
|
||||
if (stored) {
|
||||
// console.info(`[EditView] got stored shout: ${stored}`)
|
||||
setDraft(stored)
|
||||
setDraft((old) => ({ ...old, ...stored }) as Shout)
|
||||
} else {
|
||||
if (!shout.slug) {
|
||||
console.warn(`[EditView] shout has no slug! ${shout}`)
|
||||
|
@ -131,7 +121,7 @@ export const EditView = (props: Props) => {
|
|||
(d) => {
|
||||
if (d) {
|
||||
const draftForm = Object.keys(d) ? d : { shoutId: props.shout.id }
|
||||
setForm(draftForm)
|
||||
setForm(draftForm as ShoutForm)
|
||||
console.debug('draft from localstorage: ', draftForm)
|
||||
}
|
||||
},
|
||||
|
@ -267,7 +257,7 @@ export const EditView = (props: Props) => {
|
|||
|
||||
const showSubtitleInput = () => {
|
||||
setIsSubtitleVisible(true)
|
||||
subtitleInput?.focus()
|
||||
subtitleInput()?.focus()
|
||||
}
|
||||
|
||||
const showLeadInput = () => {
|
||||
|
@ -359,7 +349,7 @@ export const EditView = (props: Props) => {
|
|||
<Show when={props.shout.layout !== 'audio'}>
|
||||
<Show when={isSubtitleVisible()}>
|
||||
<GrowingTextarea
|
||||
textAreaRef={(el) => (subtitleInput = el)}
|
||||
textAreaRef={setSubtitleInput}
|
||||
allowEnterKey={false}
|
||||
value={(value) => handleInputChange('subtitle', value || '')}
|
||||
class={styles.subtitleInput}
|
||||
|
@ -369,13 +359,10 @@ export const EditView = (props: Props) => {
|
|||
/>
|
||||
</Show>
|
||||
<Show when={isLeadVisible()}>
|
||||
<SimplifiedEditor
|
||||
variant="minimal"
|
||||
hideToolbar={true}
|
||||
smallHeight={true}
|
||||
<MicroEditor
|
||||
placeholder={t('A short introduction to keep the reader interested')}
|
||||
initialContent={form.lead}
|
||||
onChange={(value) => handleInputChange('lead', value)}
|
||||
content={form.lead}
|
||||
onChange={(value: string) => handleInputChange('lead', value)}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
|
@ -455,7 +442,7 @@ export const EditView = (props: Props) => {
|
|||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={form?.shoutId} fallback={<Loading />}>
|
||||
<Show when={draft()?.id} fallback={<Loading />}>
|
||||
<EditorComponent
|
||||
shoutId={form.shoutId}
|
||||
initialContent={form.body}
|
||||
|
|
|
@ -3,12 +3,4 @@
|
|||
background: #fef2f2;
|
||||
padding: 0 0 4rem;
|
||||
min-height: 100vh;
|
||||
|
||||
.showMore {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 4rem 0 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,13 @@
|
|||
import { A } from '@solidjs/router'
|
||||
import { clsx } from 'clsx'
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
|
||||
import { ConditionalWrapper } from '~/components/_shared/ConditionalWrapper'
|
||||
import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper'
|
||||
import { For, Show, createEffect, createSignal, on } from 'solid-js'
|
||||
|
||||
import { Loading } from '~/components/_shared/Loading'
|
||||
import { ArticleCardSwiper } from '~/components/_shared/SolidSwiper/ArticleCardSwiper'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { EXPO_LAYOUTS, SHOUTS_PER_PAGE, useFeed } from '~/context/feed'
|
||||
import { EXPO_LAYOUTS, SHOUTS_PER_PAGE } from '~/context/feed'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useSession } from '~/context/session'
|
||||
import { loadShouts } from '~/graphql/api/public'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import getRandomTopShoutsQuery from '~/graphql/query/core/articles-load-random-top'
|
||||
import { LoadShoutsFilters, LoadShoutsOptions, Shout } from '~/graphql/schema/core.gen'
|
||||
import { LayoutType } from '~/types/common'
|
||||
import { LoadShoutsOptions, Shout } from '~/graphql/schema/core.gen'
|
||||
import { ExpoLayoutType } from '~/types/common'
|
||||
import { getUnixtime } from '~/utils/date'
|
||||
import { ArticleCard } from '../../Feed/ArticleCard'
|
||||
|
||||
|
@ -21,39 +15,20 @@ import styles from './Expo.module.scss'
|
|||
|
||||
type Props = {
|
||||
shouts: Shout[]
|
||||
topMonthShouts?: Shout[]
|
||||
topRatedShouts?: Shout[]
|
||||
layout?: LayoutType
|
||||
layout: ExpoLayoutType
|
||||
}
|
||||
|
||||
export const PRERENDERED_ARTICLES_COUNT = 36
|
||||
const LOAD_MORE_PAGE_SIZE = 12
|
||||
|
||||
export const Expo = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
|
||||
const { client } = useSession()
|
||||
const [favoriteTopArticles, setFavoriteTopArticles] = createSignal<Shout[]>([])
|
||||
const [reactedTopMonthArticles, setReactedTopMonthArticles] = createSignal<Shout[]>([])
|
||||
const [expoShouts, setExpoShouts] = createSignal<Shout[]>([])
|
||||
const { feedByLayout, expoFeed, setExpoFeed } = useFeed()
|
||||
const layouts = createMemo<LayoutType[]>(() => (props.layout ? [props.layout] : EXPO_LAYOUTS))
|
||||
|
||||
const loadMoreFiltered = async () => {
|
||||
const limit = SHOUTS_PER_PAGE
|
||||
const offset = (props.layout ? feedByLayout()[props.layout] : expoFeed())?.length
|
||||
const filters: LoadShoutsFilters = { layouts: layouts(), featured: true }
|
||||
const options: LoadShoutsOptions = { filters, limit, offset }
|
||||
const shoutsFetcher = loadShouts(options)
|
||||
const result = await shoutsFetcher()
|
||||
result && setExpoFeed(result)
|
||||
return result as LoadMoreItems
|
||||
}
|
||||
|
||||
// Функция загрузки случайных избранных статей
|
||||
const loadRandomTopArticles = async () => {
|
||||
const layouts = props.layout ? [props.layout] : EXPO_LAYOUTS
|
||||
const options: LoadShoutsOptions = {
|
||||
filters: { layouts: layouts(), featured: true },
|
||||
filters: { layouts, featured: true },
|
||||
limit: 10,
|
||||
random_limit: 100
|
||||
}
|
||||
|
@ -61,11 +36,13 @@ export const Expo = (props: Props) => {
|
|||
setFavoriteTopArticles(resp?.data?.load_shouts_random_top || [])
|
||||
}
|
||||
|
||||
// Функция загрузки популярных статей за последний месяц
|
||||
const loadRandomTopMonthArticles = async () => {
|
||||
const layouts = props.layout ? [props.layout] : EXPO_LAYOUTS
|
||||
const now = new Date()
|
||||
const after = getUnixtime(new Date(now.setMonth(now.getMonth() - 1)))
|
||||
const options: LoadShoutsOptions = {
|
||||
filters: { layouts: layouts(), after, reacted: true },
|
||||
filters: { layouts, after, reacted: true },
|
||||
limit: 10,
|
||||
random_limit: 10
|
||||
}
|
||||
|
@ -73,127 +50,46 @@ export const Expo = (props: Props) => {
|
|||
setReactedTopMonthArticles(resp?.data?.load_shouts_random_top || [])
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadRandomTopArticles()
|
||||
loadRandomTopMonthArticles()
|
||||
})
|
||||
|
||||
// Эффект для загрузки random top при изменении layout
|
||||
createEffect(
|
||||
on(
|
||||
() => props.layout,
|
||||
() => {
|
||||
setExpoShouts([])
|
||||
setFavoriteTopArticles([])
|
||||
setReactedTopMonthArticles([])
|
||||
loadRandomTopArticles()
|
||||
loadRandomTopMonthArticles()
|
||||
async (_layout?: ExpoLayoutType) => {
|
||||
await loadRandomTopArticles()
|
||||
await loadRandomTopMonthArticles()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
setExpoShouts([])
|
||||
})
|
||||
const ExpoTabs = () => (
|
||||
<div class="wide-container">
|
||||
<ul class={clsx('view-switcher')}>
|
||||
<li class={clsx({ 'view-switcher__item--selected': !props.layout })}>
|
||||
<A href={'/expo'}>
|
||||
<span class={clsx('linkReplacement')}>{t('All')}</span>
|
||||
</A>
|
||||
</li>
|
||||
<li class={clsx({ 'view-switcher__item--selected': props.layout === 'literature' })}>
|
||||
<ConditionalWrapper
|
||||
condition={props.layout !== 'literature'}
|
||||
wrapper={(children) => <A href={'/expo/literature'}>{children}</A>}
|
||||
>
|
||||
<span class={clsx('linkReplacement')}>{t('Literature')}</span>
|
||||
</ConditionalWrapper>
|
||||
</li>
|
||||
<li class={clsx({ 'view-switcher__item--selected': props.layout === 'audio' })}>
|
||||
<ConditionalWrapper
|
||||
condition={props.layout !== 'audio'}
|
||||
wrapper={(children) => <A href={'/expo/audio'}>{children}</A>}
|
||||
>
|
||||
<span class={clsx('linkReplacement')}>{t('Music')}</span>
|
||||
</ConditionalWrapper>
|
||||
</li>
|
||||
<li class={clsx({ 'view-switcher__item--selected': props.layout === 'image' })}>
|
||||
<ConditionalWrapper
|
||||
condition={props.layout !== 'image'}
|
||||
wrapper={(children) => <A href={'/expo/image'}>{children}</A>}
|
||||
>
|
||||
<span class={clsx('linkReplacement')}>{t('Gallery')}</span>
|
||||
</ConditionalWrapper>
|
||||
</li>
|
||||
<li class={clsx({ 'view-switcher__item--selected': props.layout === 'video' })}>
|
||||
<ConditionalWrapper
|
||||
condition={props.layout !== 'video'}
|
||||
wrapper={(children) => <A href={'/expo/video'}>{children}</A>}
|
||||
>
|
||||
<span class={clsx('cursorPointer linkReplacement')}>{t('Video')}</span>
|
||||
</ConditionalWrapper>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
const ExpoGrid = () => (
|
||||
<div class="wide-container">
|
||||
<div class="row">
|
||||
<For each={props.shouts.slice(0, LOAD_MORE_PAGE_SIZE)}>
|
||||
{(shout) => (
|
||||
<div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
|
||||
<ArticleCard
|
||||
article={shout}
|
||||
settings={{ nodate: true, nosubtitle: true, noAuthorLink: true }}
|
||||
desktopCoverSize="XS"
|
||||
withAspectRatio={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={reactedTopMonthArticles()?.length > 0} keyed={true}>
|
||||
<ArticleCardSwiper title={t('Top month')} slides={reactedTopMonthArticles()} />
|
||||
</Show>
|
||||
<For each={(props.topMonthShouts || []).slice(LOAD_MORE_PAGE_SIZE, LOAD_MORE_PAGE_SIZE * 2)}>
|
||||
{(shout) => (
|
||||
<div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
|
||||
<ArticleCard
|
||||
article={shout}
|
||||
settings={{ nodate: true, nosubtitle: true, noAuthorLink: true }}
|
||||
desktopCoverSize="XS"
|
||||
withAspectRatio={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={favoriteTopArticles()?.length > 0} keyed={true}>
|
||||
<ArticleCardSwiper title={t('Favorite')} slides={favoriteTopArticles()} />
|
||||
</Show>
|
||||
<For each={props.topRatedShouts?.slice(LOAD_MORE_PAGE_SIZE * 2, expoShouts().length)}>
|
||||
{(shout) => (
|
||||
<div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
|
||||
<ArticleCard
|
||||
article={shout}
|
||||
settings={{ nodate: true, nosubtitle: true, noAuthorLink: true }}
|
||||
desktopCoverSize="XS"
|
||||
withAspectRatio={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class={styles.Expo}>
|
||||
<ExpoTabs />
|
||||
<Show when={props.shouts} fallback={<Loading />} keyed>
|
||||
{(feed: Shout[]) => (
|
||||
<div class="wide-container">
|
||||
<div class="row">
|
||||
<For each={feed.slice(0, SHOUTS_PER_PAGE) || []}>
|
||||
{(shout) => (
|
||||
<div id={`shout-${shout.id}`} class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
|
||||
<ArticleCard
|
||||
article={shout}
|
||||
settings={{ nodate: true, nosubtitle: true, noAuthorLink: true }}
|
||||
desktopCoverSize="XS"
|
||||
withAspectRatio={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={expoShouts().length > 0} fallback={<Loading />}>
|
||||
<LoadMoreWrapper loadFunction={loadMoreFiltered} pageSize={LOAD_MORE_PAGE_SIZE}>
|
||||
<ExpoGrid />
|
||||
</LoadMoreWrapper>
|
||||
<Show when={reactedTopMonthArticles()?.length > 0}>
|
||||
<ArticleCardSwiper title={t('Top month')} slides={reactedTopMonthArticles()} />
|
||||
</Show>
|
||||
|
||||
<Show when={favoriteTopArticles()?.length > 0}>
|
||||
<ArticleCardSwiper title={t('Favorite')} slides={favoriteTopArticles()} />
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
|
34
src/components/Views/Expo/ExpoNav.tsx
Normal file
34
src/components/Views/Expo/ExpoNav.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { A } from '@solidjs/router'
|
||||
import { clsx } from 'clsx'
|
||||
import { For } from 'solid-js'
|
||||
|
||||
import { ConditionalWrapper } from '~/components/_shared/ConditionalWrapper'
|
||||
import { EXPO_LAYOUTS, EXPO_TITLES } from '~/context/feed'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { ExpoLayoutType } from '~/types/common'
|
||||
|
||||
export const ExpoNav = (props: { layout: ExpoLayoutType | '' }) => {
|
||||
const { t } = useLocalize()
|
||||
return (
|
||||
<div class="wide-container">
|
||||
<ul class={clsx('view-switcher')}>
|
||||
<For each={[...EXPO_LAYOUTS, '']}>
|
||||
{(layoutKey) => (
|
||||
<li class={clsx({ 'view-switcher__item--selected': props.layout === layoutKey })}>
|
||||
<ConditionalWrapper
|
||||
condition={props.layout !== layoutKey}
|
||||
wrapper={(children) => <A href={`/expo/${layoutKey}`}>{children}</A>}
|
||||
>
|
||||
<span class="linkReplacement">
|
||||
{layoutKey in EXPO_TITLES ? t(EXPO_TITLES[layoutKey as ExpoLayoutType]) : t('All')}
|
||||
</span>
|
||||
</ConditionalWrapper>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExpoNav
|
|
@ -7,7 +7,6 @@ import { Icon } from '~/components/_shared/Icon'
|
|||
import { InviteMembers } from '~/components/_shared/InviteMembers'
|
||||
import { Loading } from '~/components/_shared/Loading'
|
||||
import { ShareModal } from '~/components/_shared/ShareModal'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { useAuthors } from '~/context/authors'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useReactions } from '~/context/reactions'
|
||||
|
@ -15,7 +14,6 @@ import { useSession } from '~/context/session'
|
|||
import { useTopics } from '~/context/topics'
|
||||
import { useUI } from '~/context/ui'
|
||||
import { loadUnratedShouts } from '~/graphql/api/private'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import type { Author, Reaction, Shout } from '~/graphql/schema/core.gen'
|
||||
import { FeedSearchParams } from '~/routes/feed/[...order]'
|
||||
import { byCreated } from '~/utils/sort'
|
||||
|
@ -49,11 +47,10 @@ const PERIODS = {
|
|||
export const FeedView = (props: FeedProps) => {
|
||||
const { t } = useLocalize()
|
||||
const loc = useLocation()
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const { client, session } = useSession()
|
||||
|
||||
const unrated = createAsync(async () => {
|
||||
if (client) {
|
||||
if (client()) {
|
||||
const shoutsLoader = loadUnratedShouts(client(), { limit: 5 })
|
||||
return await shoutsLoader()
|
||||
}
|
||||
|
@ -218,7 +215,7 @@ export const FeedView = (props: FeedProps) => {
|
|||
<div class={styles.comment}>
|
||||
<div class={clsx('text-truncate', styles.commentBody)}>
|
||||
<A
|
||||
href={`article/${comment.shout.slug}?commentId=${comment.id}`}
|
||||
href={`/${comment.shout.slug}?commentId=${comment.id}`}
|
||||
innerHTML={comment.body || ''}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useNavigate } from '@solidjs/router'
|
||||
import { clsx } from 'clsx'
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
|
||||
import { For, Show, createEffect, createMemo, createSignal, lazy, on, onMount } from 'solid-js'
|
||||
import QuotedMessage from '~/components/Inbox/QuotedMessage'
|
||||
import { Icon } from '~/components/_shared/Icon'
|
||||
import { InviteMembers } from '~/components/_shared/InviteMembers'
|
||||
|
@ -17,7 +17,6 @@ import type {
|
|||
} from '~/graphql/schema/chat.gen'
|
||||
import type { Author } from '~/graphql/schema/core.gen'
|
||||
import { getShortDate } from '~/utils/date'
|
||||
import SimplifiedEditor from '../../Editor/SimplifiedEditor'
|
||||
import DialogCard from '../../Inbox/DialogCard'
|
||||
import DialogHeader from '../../Inbox/DialogHeader'
|
||||
import { Message } from '../../Inbox/Message'
|
||||
|
@ -26,6 +25,8 @@ import Search from '../../Inbox/Search'
|
|||
import { Modal } from '../../_shared/Modal'
|
||||
import styles from './Inbox.module.scss'
|
||||
|
||||
const MiniEditor = lazy(() => import('../../Editor/MiniEditor/MiniEditor'))
|
||||
|
||||
const userSearch = (array: Author[], keyword: string) => {
|
||||
return array.filter((value) => new RegExp(keyword.trim(), 'gi').test(value.name || ''))
|
||||
}
|
||||
|
@ -38,7 +39,6 @@ export const InboxView = (props: { authors: Author[]; chat?: Chat }) => {
|
|||
const [sortByPerToPer, setSortByPerToPer] = createSignal(false)
|
||||
const [currentDialog, setCurrentDialog] = createSignal<Chat>()
|
||||
const [messageToReply, setMessageToReply] = createSignal<MessageType | null>(null)
|
||||
const [isClear, setClear] = createSignal(false)
|
||||
const [isScrollToNewVisible, setIsScrollToNewVisible] = createSignal(false)
|
||||
const { session } = useSession()
|
||||
const authorId = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
|
||||
|
@ -77,11 +77,9 @@ export const InboxView = (props: { authors: Author[]; chat?: Chat }) => {
|
|||
reply_to: messageToReply()?.id,
|
||||
chat_id: currentDialog()?.id || ''
|
||||
} as MutationCreate_MessageArgs)
|
||||
setClear(true)
|
||||
setMessageToReply(null)
|
||||
if (messagesContainerRef)
|
||||
(messagesContainerRef as HTMLDivElement).scrollTop = messagesContainerRef?.scrollHeight || 0
|
||||
setClear(false)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
|
@ -291,15 +289,7 @@ export const InboxView = (props: { authors: Author[]; chat?: Chat }) => {
|
|||
/>
|
||||
</Show>
|
||||
<div class={styles.wrapper}>
|
||||
<SimplifiedEditor
|
||||
smallHeight={true}
|
||||
imageEnabled={true}
|
||||
isCancelButtonVisible={false}
|
||||
placeholder={t('New message')}
|
||||
setClear={isClear()}
|
||||
onSubmit={(message) => handleSubmit(message)}
|
||||
submitByCtrlEnter={true}
|
||||
/>
|
||||
<MiniEditor placeholder={t('New message')} onSubmit={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
|
@ -14,7 +14,6 @@ import {
|
|||
onMount
|
||||
} from 'solid-js'
|
||||
import { createStore } from 'solid-js/store'
|
||||
import SimplifiedEditor from '~/components/Editor/SimplifiedEditor'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useProfile } from '~/context/profile'
|
||||
import { useSession } from '~/context/session'
|
||||
|
@ -35,7 +34,7 @@ import { SocialNetworkInput } from '../../_shared/SocialNetworkInput'
|
|||
import styles from './Settings.module.scss'
|
||||
import { profileSocialLinks } from './profileSocialLinks'
|
||||
|
||||
// const SimplifiedEditor = lazy(() => import('~/components/Editor/SimplifiedEditor'))
|
||||
const MicroEditor = lazy(() => import('../../Editor/MicroEditor/MicroEditor'))
|
||||
const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
|
||||
|
||||
function filterNulls(arr: InputMaybe<string>[]): string[] {
|
||||
|
@ -340,18 +339,7 @@ export const ProfileSettings = () => {
|
|||
/>
|
||||
|
||||
<h4>{t('About')}</h4>
|
||||
<SimplifiedEditor
|
||||
resetToInitial={true}
|
||||
noLimits={true}
|
||||
variant="bordered"
|
||||
hideToolbar={true}
|
||||
smallHeight={true}
|
||||
label={t('About')}
|
||||
initialContent={about() || ''}
|
||||
autoFocus={false}
|
||||
onChange={setAbout}
|
||||
placeholder={t('About')}
|
||||
/>
|
||||
<MicroEditor content={about() || ''} onChange={setAbout} placeholder={t('About')} />
|
||||
<div class={clsx(styles.multipleControls, 'pretty-form__item')}>
|
||||
<div class={styles.multipleControlsHeader}>
|
||||
<h4>{t('Social networks')}</h4>
|
||||
|
|
|
@ -12,13 +12,14 @@ import { useTopics } from '~/context/topics'
|
|||
import { useSnackbar, useUI } from '~/context/ui'
|
||||
import { Topic } from '~/graphql/schema/core.gen'
|
||||
import { UploadedFile } from '~/types/upload'
|
||||
import { TopicSelect, UploadModalContent } from '../../Editor'
|
||||
import { Modal } from '../../_shared/Modal'
|
||||
|
||||
import { TopicSelect } from '~/components/TopicSelect/TopicSelect'
|
||||
import { UploadModalContent } from '~/components/Upload/UploadModalContent/UploadModalContent'
|
||||
import stylesBeside from '../../Feed/Beside.module.scss'
|
||||
import styles from './PublishSettings.module.scss'
|
||||
|
||||
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
||||
const MicroEditor = lazy(() => import('../../Editor/MicroEditor/MicroEditor'))
|
||||
const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
|
||||
const DESCRIPTION_MAX_LENGTH = 400
|
||||
|
||||
|
@ -224,16 +225,10 @@ export const PublishSettings = (props: Props) => {
|
|||
allowEnterKey={false}
|
||||
maxLength={100}
|
||||
/>
|
||||
<SimplifiedEditor
|
||||
variant="bordered"
|
||||
hideToolbar={true}
|
||||
smallHeight={true}
|
||||
<MicroEditor
|
||||
placeholder={t('Write a short introduction')}
|
||||
label={t('Description')}
|
||||
initialContent={composeDescription()}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
onChange={(value: any) => setForm('description', value)}
|
||||
maxLength={DESCRIPTION_MAX_LENGTH}
|
||||
content={composeDescription()}
|
||||
onChange={(value?: string) => value && setForm('description', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { clsx } from 'clsx'
|
||||
import { createSignal, onMount } from 'solid-js'
|
||||
import { createEffect, createSignal, onMount } from 'solid-js'
|
||||
|
||||
import { Icon } from '~/components/_shared/Icon'
|
||||
import { Popover } from '~/components/_shared/Popover'
|
||||
|
@ -15,20 +15,24 @@ type Props = {
|
|||
initialValue?: string
|
||||
showInput?: boolean
|
||||
placeholder: string
|
||||
onFocus?: (event: FocusEvent) => void
|
||||
}
|
||||
|
||||
export const InlineForm = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const [formValue, setFormValue] = createSignal(props.initialValue || '')
|
||||
const [formValueError, setFormValueError] = createSignal<string | undefined>()
|
||||
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
|
||||
const handleFormInput = (e: { currentTarget: HTMLInputElement; target: HTMLInputElement }) => {
|
||||
const value = (e.currentTarget || e.target).value
|
||||
setFormValueError()
|
||||
setFormValue(value)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
setFormValue(props.initialValue || '')
|
||||
})
|
||||
|
||||
const handleSaveButtonClick = async () => {
|
||||
if (props.validate) {
|
||||
const errorMessage = await props.validate(formValue())
|
||||
|
@ -56,23 +60,23 @@ export const InlineForm = (props: Props) => {
|
|||
}
|
||||
|
||||
const handleClear = () => {
|
||||
props.initialValue ? props.onClear?.() : props.onClose()
|
||||
props.initialValue && props.onClear?.()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
inputRef?.focus()
|
||||
})
|
||||
onMount(() => inputRef()?.focus())
|
||||
|
||||
return (
|
||||
<div class={styles.InlineForm}>
|
||||
<div class={styles.form}>
|
||||
<input
|
||||
ref={(el) => (inputRef = el)}
|
||||
ref={setInputRef}
|
||||
type="text"
|
||||
value={props.initialValue ?? ''}
|
||||
value={formValue()}
|
||||
placeholder={props.placeholder}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleFormInput}
|
||||
onFocus={props.onFocus}
|
||||
/>
|
||||
<Popover content={t('Add link')}>
|
||||
{(triggerRef: (el: HTMLElement) => void) => (
|
|
@ -33,6 +33,7 @@ export const LoadMoreWrapper = (props: LoadMoreProps) => {
|
|||
)
|
||||
|
||||
const loadItems = async () => {
|
||||
// console.debug('LoadMoreWrapper.loadItems offset:', offset())
|
||||
setIsLoading(true)
|
||||
saveScrollPosition()
|
||||
const newItems = await props.loadFunction(offset())
|
||||
|
@ -47,6 +48,7 @@ export const LoadMoreWrapper = (props: LoadMoreProps) => {
|
|||
)
|
||||
setIsLoading(false)
|
||||
restoreScrollPosition()
|
||||
// console.debug('LoadMoreWrapper.loadItems loaded:', newItems.length)
|
||||
}
|
||||
|
||||
onMount(loadItems)
|
||||
|
@ -54,6 +56,7 @@ export const LoadMoreWrapper = (props: LoadMoreProps) => {
|
|||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<div>
|
||||
<Show when={isLoadMoreButtonVisible() && !props.hidden}>
|
||||
<div class="load-more-container">
|
||||
<Button
|
||||
|
@ -64,6 +67,7 @@ export const LoadMoreWrapper = (props: LoadMoreProps) => {
|
|||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ import { clsx } from 'clsx'
|
|||
import { For, Show, createEffect, createSignal, lazy, on, onMount } from 'solid-js'
|
||||
import SwiperCore from 'swiper'
|
||||
import { Manipulation, Navigation, Pagination } from 'swiper/modules'
|
||||
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useSnackbar } from '~/context/ui'
|
||||
import { composeMediaItems } from '~/lib/composeMediaItems'
|
||||
|
@ -23,7 +22,7 @@ import { MediaItem } from '~/types/mediaitem'
|
|||
import { UploadedFile } from '~/types/upload'
|
||||
import styles from './Swiper.module.scss'
|
||||
|
||||
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
|
||||
const MicroEditor = lazy(() => import('../../Editor/MicroEditor/MicroEditor'))
|
||||
|
||||
type Props = {
|
||||
images: MediaItem[]
|
||||
|
@ -316,9 +315,8 @@ export const EditorSwiper = (props: Props) => {
|
|||
value={props.images[slideIndex()]?.source}
|
||||
onChange={(event) => handleSlideDescriptionChange(slideIndex(), 'source', event.target.value)}
|
||||
/>
|
||||
<SimplifiedEditor
|
||||
initialContent={props.images[slideIndex()]?.body}
|
||||
smallHeight={true}
|
||||
<MicroEditor
|
||||
content={props.images[slideIndex()]?.body}
|
||||
placeholder={t('Enter image description')}
|
||||
onChange={(value) => setSlideBody(value)}
|
||||
/>
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
export const isDev = import.meta.env.MODE === 'development'
|
||||
export const cdnUrl = 'https://cdn.discours.io'
|
||||
export const thumborUrl = import.meta.env.PUBLIC_THUMBOR_URL || 'https://images.discours.io'
|
||||
export const reportDsn = import.meta.env.PUBLIC_GLITCHTIP_DSN || import.meta.env.PUBLIC_SENTRY_DSN || ''
|
||||
export const coreApiUrl = import.meta.env.PUBLIC_CORE_API || 'https://core.discours.io'
|
||||
export const chatApiUrl = import.meta.env.PUBLIC_CHAT_API || 'https://inbox.discours.io'
|
||||
export const authApiUrl = import.meta.env.PUBLIC_AUTH_API || 'https://auth.discours.io/graphql'
|
||||
export const sseUrl = import.meta.env.PUBLIC_REALTIME_EVENTS || 'https://connect.discours.io'
|
||||
export const gaIdentity = import.meta.env.PUBLIC_GA_IDENTITY || '' // 'G-LQ4B87H8C2'
|
||||
export const gaIdentity = import.meta.env.PUBLIC_GA_IDENTITY || 'G-LQ4B87H8C2'
|
||||
export const authorizerClientId =
|
||||
import.meta.env.PUBLIC_AUTHORIZER_CLIENT_ID || 'b9038a34-ca59-41ae-a105-c7fbea603e24'
|
||||
export const authorizerRedirectUrl =
|
||||
import.meta.env.PUBLIC_AUTHORIZER_REDIRECT_URL || 'https://testing.discours.io'
|
||||
|
||||
// devmode only
|
||||
export const reportDsn = import.meta.env.PUBLIC_GLITCHTIP_DSN
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
import { useMatch, useNavigate } from '@solidjs/router'
|
||||
import { Editor, EditorOptions } from '@tiptap/core'
|
||||
import type { JSX } from 'solid-js'
|
||||
import { Accessor, createContext, createMemo, createSignal, useContext } from 'solid-js'
|
||||
import { Accessor, createContext, createSignal, useContext } from 'solid-js'
|
||||
import { SetStoreFunction, createStore } from 'solid-js/store'
|
||||
import { createTiptapEditor } from 'solid-tiptap'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { useSnackbar } from '~/context/ui'
|
||||
import deleteShoutQuery from '~/graphql/mutation/core/article-delete'
|
||||
import updateShoutQuery from '~/graphql/mutation/core/article-update'
|
||||
import { Topic, TopicInput } from '~/graphql/schema/core.gen'
|
||||
import { slugify } from '~/intl/translit'
|
||||
import { useFeed } from '../context/feed'
|
||||
import { graphqlClientCreate } from '../graphql/client'
|
||||
import { useLocalize } from './localize'
|
||||
import { useSession } from './session'
|
||||
|
||||
|
@ -85,8 +83,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
|||
const navigate = useNavigate()
|
||||
const matchEdit = useMatch(() => '/edit')
|
||||
const matchEditSettings = useMatch(() => '/editSettings')
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const { client } = useSession()
|
||||
const [editor, setEditor] = createSignal<Editor | undefined>()
|
||||
const { addFeed } = useFeed()
|
||||
const snackbar = useSnackbar()
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { createLazyMemo } from '@solid-primitives/memo'
|
||||
import { makePersisted } from '@solid-primitives/storage'
|
||||
import { Accessor, JSX, Setter, createContext, createMemo, createSignal, useContext } from 'solid-js'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { Accessor, JSX, Setter, createContext, createSignal, useContext } from 'solid-js'
|
||||
import { loadFollowedShouts } from '~/graphql/api/private'
|
||||
import { loadShoutsSearch as fetchShoutsSearch, getShout, loadShouts } from '~/graphql/api/public'
|
||||
import {
|
||||
|
@ -11,14 +10,20 @@ import {
|
|||
Shout,
|
||||
Topic
|
||||
} from '~/graphql/schema/core.gen'
|
||||
import { LayoutType } from '~/types/common'
|
||||
import { graphqlClientCreate } from '../graphql/client'
|
||||
import { ExpoLayoutType } from '~/types/common'
|
||||
import { byStat } from '../utils/sort'
|
||||
import { useSession } from './session'
|
||||
|
||||
export const PRERENDERED_ARTICLES_COUNT = 5
|
||||
export const SHOUTS_PER_PAGE = 20
|
||||
export const EXPO_LAYOUTS = ['audio', 'literature', 'video', 'image'] as LayoutType[]
|
||||
export const EXPO_LAYOUTS = ['audio', 'literature', 'video', 'image'] as ExpoLayoutType[]
|
||||
export const EXPO_TITLES: Record<ExpoLayoutType | '', string> = {
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
image: 'Artworks',
|
||||
literature: 'Literature',
|
||||
'': 'All'
|
||||
}
|
||||
|
||||
type FeedContextType = {
|
||||
sortedFeed: Accessor<Shout[]>
|
||||
|
@ -176,8 +181,7 @@ export const FeedProvider = (props: { children: JSX.Element }) => {
|
|||
addFeed(result)
|
||||
return { hasMore, newShouts: result }
|
||||
}
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const { client } = useSession()
|
||||
|
||||
// Load the user's feed based on the provided options and update the articleEntities and sortedFeed state
|
||||
const loadMyFeed = async (
|
||||
|
|
|
@ -1,21 +1,10 @@
|
|||
import {
|
||||
Accessor,
|
||||
JSX,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
useContext
|
||||
} from 'solid-js'
|
||||
import { Accessor, JSX, createContext, createEffect, createSignal, on, useContext } from 'solid-js'
|
||||
import { createStore } from 'solid-js/store'
|
||||
|
||||
import { coreApiUrl } from '~/config'
|
||||
import followMutation from '~/graphql/mutation/core/follow'
|
||||
import unfollowMutation from '~/graphql/mutation/core/unfollow'
|
||||
import loadAuthorFollowers from '~/graphql/query/core/author-followers'
|
||||
import { Author, Community, FollowingEntity, Topic } from '~/graphql/schema/core.gen'
|
||||
import { graphqlClientCreate } from '../graphql/client'
|
||||
import { useSession } from './session'
|
||||
|
||||
export type FollowsFilter = 'all' | 'authors' | 'topics' | 'communities'
|
||||
|
@ -70,9 +59,7 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
|
|||
const [loading, setLoading] = createSignal<boolean>(false)
|
||||
const [followers, setFollowers] = createSignal<Author[]>([] as Author[])
|
||||
const [follows, setFollows] = createStore<AuthorFollowsResult>(EMPTY_SUBSCRIPTIONS)
|
||||
const { session } = useSession()
|
||||
const authorized = createMemo<boolean>(() => Boolean(session()?.access_token))
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const { session, client } = useSession()
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
|
@ -96,7 +83,7 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
|
|||
const [following, setFollowing] = createSignal<FollowingData>(defaultFollowing)
|
||||
|
||||
const follow = async (what: FollowingEntity, slug: string) => {
|
||||
if (!authorized()) return
|
||||
if (!session()?.access_token) return
|
||||
setFollowing({ slug, type: 'follow' })
|
||||
try {
|
||||
const resp = await client()?.mutation(followMutation, { what, slug }).toPromise()
|
||||
|
@ -115,7 +102,7 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
|
|||
}
|
||||
|
||||
const unfollow = async (what: FollowingEntity, slug: string) => {
|
||||
if (!authorized()) return
|
||||
if (!session()?.access_token) return
|
||||
setFollowing({ slug: slug, type: 'unfollow' })
|
||||
try {
|
||||
const resp = await client()?.mutation(unfollowMutation, { what, slug }).toPromise()
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import type { Accessor, JSX } from 'solid-js'
|
||||
import { createContext, createMemo, createSignal, useContext } from 'solid-js'
|
||||
import { chatApiUrl } from '~/config'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import { createContext, createSignal, useContext } from 'solid-js'
|
||||
import createChatMutation from '~/graphql/mutation/chat/chat-create'
|
||||
import createMessageMutation from '~/graphql/mutation/chat/chat-message-create'
|
||||
import loadChatMessagesQuery from '~/graphql/query/chat/chat-messages-load-by'
|
||||
|
@ -38,8 +36,7 @@ export const InboxProvider = (props: { children: JSX.Element }) => {
|
|||
const [chats, setChats] = createSignal<Chat[]>([])
|
||||
const [messages, setMessages] = createSignal<Message[]>([])
|
||||
const { authorsSorted } = useAuthors()
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(chatApiUrl, session()?.access_token))
|
||||
const { client } = useSession()
|
||||
|
||||
const handleMessage = (sseMessage: SSEMessage) => {
|
||||
// handling all action types: create update delete join left seen
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import { makePersisted } from '@solid-primitives/storage'
|
||||
import type { Accessor, JSX } from 'solid-js'
|
||||
|
||||
import { createContext, createMemo, createSignal, onMount, useContext } from 'solid-js'
|
||||
import { createStore } from 'solid-js/store'
|
||||
import { Portal } from 'solid-js/web'
|
||||
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import markSeenMutation from '~/graphql/mutation/notifier/mark-seen'
|
||||
import markSeenAfterMutation from '~/graphql/mutation/notifier/mark-seen-after'
|
||||
import markSeenThreadMutation from '~/graphql/mutation/notifier/mark-seen-thread'
|
||||
|
@ -47,13 +44,11 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
|
|||
const [unreadNotificationsCount, setUnreadNotificationsCount] = createSignal(0)
|
||||
const [totalNotificationsCount, setTotalNotificationsCount] = createSignal(0)
|
||||
const [notificationEntities, setNotificationEntities] = createStore<Record<string, NotificationGroup>>({})
|
||||
const { session } = useSession()
|
||||
const authorized = createMemo<boolean>(() => Boolean(session()?.access_token))
|
||||
const { session, client } = useSession()
|
||||
const { addHandler } = useConnect()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
|
||||
const loadNotificationsGrouped = async (options: QueryLoad_NotificationsArgs) => {
|
||||
if (authorized()) {
|
||||
if (session()?.access_token) {
|
||||
const resp = await client()?.query(getNotifications, options).toPromise()
|
||||
const result = resp?.data?.get_notifications
|
||||
const groups = result?.notifications || []
|
||||
|
@ -87,7 +82,7 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
|
|||
|
||||
onMount(() => {
|
||||
addHandler((data: SSEMessage) => {
|
||||
if (data.entity === 'reaction' && authorized()) {
|
||||
if (data.entity === 'reaction' && session()?.access_token) {
|
||||
console.info('[context.notifications] event', data)
|
||||
loadNotificationsGrouped({
|
||||
after: after() || now,
|
||||
|
@ -107,14 +102,14 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
|
|||
}
|
||||
|
||||
const markSeenAll = async () => {
|
||||
if (authorized()) {
|
||||
if (session()?.access_token) {
|
||||
const _resp = await client()?.mutation(markSeenAfterMutation, { after: after() }).toPromise()
|
||||
await loadNotificationsGrouped({ after: after() || now, limit: loadedNotificationsCount() })
|
||||
}
|
||||
}
|
||||
|
||||
const markSeen = async (notification_id: number) => {
|
||||
if (authorized()) {
|
||||
if (session()?.access_token) {
|
||||
await client()?.mutation(markSeenMutation, { notification_id }).toPromise()
|
||||
await loadNotificationsGrouped({ after: after() || now, limit: loadedNotificationsCount() })
|
||||
}
|
||||
|
|
|
@ -1,20 +1,9 @@
|
|||
import type { Author, ProfileInput } from '~/graphql/schema/core.gen'
|
||||
|
||||
import { AuthToken } from '@authorizerdev/authorizer-js'
|
||||
import {
|
||||
Accessor,
|
||||
JSX,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
useContext
|
||||
} from 'solid-js'
|
||||
import { Accessor, JSX, createContext, createEffect, createSignal, on, useContext } from 'solid-js'
|
||||
import { createStore } from 'solid-js/store'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import updateAuthorMuatation from '~/graphql/mutation/core/author-update'
|
||||
import { graphqlClientCreate } from '../graphql/client'
|
||||
import { useAuthors } from './authors'
|
||||
import { useSession } from './session'
|
||||
|
||||
|
@ -41,8 +30,7 @@ const userpicUrl = (userpic: string) => {
|
|||
}
|
||||
|
||||
export const ProfileProvider = (props: { children: JSX.Element }) => {
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const { session, client } = useSession()
|
||||
const { addAuthor } = useAuthors()
|
||||
const [form, setForm] = createStore<ProfileInput>({} as ProfileInput)
|
||||
const [author, setAuthor] = createSignal<Author>({} as Author)
|
||||
|
@ -66,7 +54,7 @@ export const ProfileProvider = (props: { children: JSX.Element }) => {
|
|||
|
||||
const submit = async (profile: ProfileInput) => {
|
||||
const response = await client()?.mutation(updateAuthorMuatation, profile).toPromise()
|
||||
if (response.error) {
|
||||
if (response?.error) {
|
||||
console.error(response.error)
|
||||
throw response.error
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type { Accessor, JSX } from 'solid-js'
|
||||
import { createContext, createMemo, createSignal, onCleanup, useContext } from 'solid-js'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { createContext, createSignal, onCleanup, useContext } from 'solid-js'
|
||||
import { loadReactions } from '~/graphql/api/public'
|
||||
import createReactionMutation from '~/graphql/mutation/core/reaction-create'
|
||||
import destroyReactionMutation from '~/graphql/mutation/core/reaction-destroy'
|
||||
|
@ -12,7 +11,6 @@ import {
|
|||
Reaction,
|
||||
ReactionKind
|
||||
} from '~/graphql/schema/core.gen'
|
||||
import { graphqlClientCreate } from '../graphql/client'
|
||||
import { useLocalize } from './localize'
|
||||
import { useSession } from './session'
|
||||
import { useSnackbar } from './ui'
|
||||
|
@ -41,8 +39,7 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
|
|||
const [commentsByAuthor, setCommentsByAuthor] = createSignal<Record<number, Reaction[]>>({})
|
||||
const { t } = useLocalize()
|
||||
const { showSnackbar } = useSnackbar()
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const { client } = useSession()
|
||||
|
||||
const addShoutReactions = (rrr: Reaction[]) => {
|
||||
const newReactionEntities = { ...reactionEntities() }
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
VerifyEmailInput
|
||||
} from '@authorizerdev/authorizer-js'
|
||||
import { useSearchParams } from '@solidjs/router'
|
||||
import { Client } from '@urql/core'
|
||||
import type { Accessor, JSX, Resource } from 'solid-js'
|
||||
import {
|
||||
createContext,
|
||||
|
@ -25,13 +26,14 @@ import {
|
|||
useContext
|
||||
} from 'solid-js'
|
||||
import { type AuthModalSource, useSnackbar, useUI } from '~/context/ui'
|
||||
import { authApiUrl } from '../config'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import { authApiUrl, authorizerClientId, authorizerRedirectUrl, coreApiUrl } from '../config'
|
||||
import { useLocalize } from './localize'
|
||||
|
||||
const defaultConfig: ConfigType = {
|
||||
authorizerURL: authApiUrl.replace('/graphql', ''),
|
||||
redirectURL: 'https://testing.discours.io',
|
||||
clientID: 'b9038a34-ca59-41ae-a105-c7fbea603e24'
|
||||
redirectURL: authorizerRedirectUrl,
|
||||
clientID: authorizerClientId
|
||||
}
|
||||
|
||||
export type SessionContextType = {
|
||||
|
@ -51,20 +53,22 @@ export type SessionContextType = {
|
|||
signOut: () => Promise<boolean>
|
||||
oauth: (provider: string) => Promise<void>
|
||||
forgotPassword: (params: ForgotPasswordInput) => Promise<string>
|
||||
changePassword: (password: string, token: string) => void
|
||||
changePassword: (password: string, token: string) => Promise<boolean>
|
||||
confirmEmail: (input: VerifyEmailInput) => Promise<void>
|
||||
setIsSessionLoaded: (loaded: boolean) => void
|
||||
authorizer: () => Authorizer
|
||||
isRegistered: (email: string) => Promise<string>
|
||||
resendVerifyEmail: (params: ResendVerifyEmailInput) => Promise<boolean>
|
||||
client: Accessor<Client | undefined>
|
||||
}
|
||||
|
||||
const noop = () => null
|
||||
|
||||
const metaRes = {
|
||||
data: {
|
||||
meta: {
|
||||
version: 'latest',
|
||||
client_id: 'b9038a34-ca59-41ae-a105-c7fbea603e24',
|
||||
client_id: authorizerClientId,
|
||||
is_google_login_enabled: true,
|
||||
is_facebook_login_enabled: true,
|
||||
is_github_login_enabled: true,
|
||||
|
@ -86,12 +90,21 @@ const metaRes = {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session context to manage authentication state and provide authentication functions.
|
||||
*/
|
||||
export const SessionContext = createContext<SessionContextType>({} as SessionContextType)
|
||||
|
||||
export function useSession() {
|
||||
return useContext(SessionContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionProvider component that wraps its children with session context.
|
||||
* It handles session management, authentication, and provides related functions.
|
||||
* @param props - The props containing an onStateChangeCallback function and children elements.
|
||||
* @returns A JSX Element wrapping the children with session context.
|
||||
*/
|
||||
export const SessionProvider = (props: {
|
||||
onStateChangeCallback(state: AuthToken): unknown
|
||||
children: JSX.Element
|
||||
|
@ -113,45 +126,55 @@ export const SessionProvider = (props: {
|
|||
const authorizer = createMemo(() => new Authorizer(config()))
|
||||
const [oauthState, setOauthState] = createSignal<string>()
|
||||
|
||||
// load
|
||||
let minuteLater: NodeJS.Timeout | null
|
||||
// Session expiration timer
|
||||
let minuteLater: ReturnType<typeof setTimeout> | null = null
|
||||
const [isSessionLoaded, setIsSessionLoaded] = createSignal(false)
|
||||
const [authError, setAuthError] = createSignal<string>('')
|
||||
const { showModal } = useUI()
|
||||
|
||||
// handle auth state callback from outside
|
||||
// Handle auth state callback from outside
|
||||
onMount(() => {
|
||||
const params = searchParams
|
||||
if (params?.state) {
|
||||
setOauthState((_s) => params?.state)
|
||||
const scope = params?.scope ? params?.scope?.toString().split(' ') : ['openid', 'profile', 'email']
|
||||
setOauthState(params.state)
|
||||
const scope = params.scope ? params.scope.toString().split(' ') : ['openid', 'profile', 'email']
|
||||
if (scope) console.info(`[context.session] scope: ${scope}`)
|
||||
const url = params?.redirect_uri || params?.redirectURL || window.location.href
|
||||
const url = params.redirect_uri || params.redirectURL || window.location.href
|
||||
setConfig((c: ConfigType) => ({ ...c, redirectURL: url.split('?')[0] }))
|
||||
changeSearchParams({ mode: 'confirm-email', m: 'auth' }, { replace: true })
|
||||
}
|
||||
})
|
||||
|
||||
// handle token confirm
|
||||
// Handle token confirmation
|
||||
createEffect(() => {
|
||||
const token = searchParams?.token
|
||||
const access_token = searchParams?.access_token
|
||||
if (access_token)
|
||||
changeSearchParams({
|
||||
if (access_token) {
|
||||
changeSearchParams(
|
||||
{
|
||||
mode: 'confirm-email',
|
||||
m: 'auth',
|
||||
access_token
|
||||
})
|
||||
else if (token) {
|
||||
changeSearchParams({
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
} else if (token) {
|
||||
changeSearchParams(
|
||||
{
|
||||
mode: 'change-password',
|
||||
m: 'auth',
|
||||
token
|
||||
})
|
||||
},
|
||||
{ replace: true }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Function to load session data
|
||||
/**
|
||||
* Function to load session data by fetching the current session from the authorizer.
|
||||
* It handles session expiration and sets up a timer to refresh the session as needed.
|
||||
* @returns A Promise resolving to the AuthToken containing session information.
|
||||
*/
|
||||
const sessionData = async () => {
|
||||
try {
|
||||
const s: ApiResponse<AuthToken> = await authorizer().getSession()
|
||||
|
@ -191,6 +214,10 @@ export const SessionProvider = (props: {
|
|||
initialValue: {} as AuthToken
|
||||
})
|
||||
|
||||
/**
|
||||
* Checks if the current session has expired and refreshes the session if necessary.
|
||||
* Sets up a timer to check the session expiration every minute.
|
||||
*/
|
||||
const checkSessionIsExpired = () => {
|
||||
const expires_at_data = localStorage?.getItem('expires_at')
|
||||
|
||||
|
@ -209,9 +236,11 @@ export const SessionProvider = (props: {
|
|||
}
|
||||
}
|
||||
|
||||
onCleanup(() => clearTimeout(minuteLater as NodeJS.Timeout))
|
||||
onCleanup(() => {
|
||||
if (minuteLater) clearTimeout(minuteLater)
|
||||
})
|
||||
|
||||
// initial effect
|
||||
// Initial effect
|
||||
onMount(() => {
|
||||
setConfig({
|
||||
...defaultConfig,
|
||||
|
@ -221,16 +250,23 @@ export const SessionProvider = (props: {
|
|||
loadSession()
|
||||
})
|
||||
|
||||
// callback state updater
|
||||
// Callback state updater
|
||||
createEffect(
|
||||
on([() => props.onStateChangeCallback, session], ([_, ses]) => {
|
||||
ses?.user?.id && props.onStateChangeCallback(ses)
|
||||
if (ses?.user?.id) props.onStateChangeCallback(ses)
|
||||
})
|
||||
)
|
||||
|
||||
const [authCallback, setAuthCallback] = createSignal<() => void>(noop)
|
||||
|
||||
/**
|
||||
* Requires the user to be authenticated before executing a callback function.
|
||||
* If the user is not authenticated, it shows the authentication modal.
|
||||
* @param callback - The function to execute after authentication.
|
||||
* @param modalSource - The source of the authentication modal.
|
||||
*/
|
||||
const requireAuthentication = (callback: () => void, modalSource: AuthModalSource) => {
|
||||
setAuthCallback((_cb) => callback)
|
||||
setAuthCallback(() => callback)
|
||||
if (!session()) {
|
||||
loadSession()
|
||||
if (!session()) {
|
||||
|
@ -243,23 +279,36 @@ export const SessionProvider = (props: {
|
|||
const handler = authCallback()
|
||||
if (handler !== noop) {
|
||||
handler()
|
||||
setAuthCallback((_cb) => noop)
|
||||
setAuthCallback(() => noop)
|
||||
}
|
||||
})
|
||||
|
||||
// authorizer api proxy methods
|
||||
/**
|
||||
* General function to authenticate a user using a specified authentication function.
|
||||
* @param authFunction - The authentication function to use (e.g., signup, login).
|
||||
* @param params - The parameters to pass to the authentication function.
|
||||
* @returns An object containing data and errors from the authentication attempt.
|
||||
*/
|
||||
type AuthFunctionType = (
|
||||
data: SignupInput | LoginInput | UpdateProfileInput
|
||||
) => Promise<ApiResponse<AuthToken | GenericResponse>>
|
||||
const authenticate = async (
|
||||
authFunction: (data: SignupInput) => Promise<ApiResponse<AuthToken | GenericResponse>>,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: authorizer
|
||||
params: any
|
||||
authFunction: AuthFunctionType,
|
||||
params: SignupInput | LoginInput | UpdateProfileInput
|
||||
) => {
|
||||
const resp = await authFunction(params)
|
||||
console.debug('[context.session] authenticate:', resp)
|
||||
if (resp?.data && resp?.errors.length === 0) setSession(resp.data as AuthToken)
|
||||
return { data: resp?.data, errors: resp?.errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs up a new user using the provided parameters.
|
||||
* @param params - The signup input parameters.
|
||||
* @returns A Promise resolving to `true` if signup was successful, otherwise `false`.
|
||||
*/
|
||||
const signUp = async (params: SignupInput): Promise<boolean> => {
|
||||
const resp = await authenticate(authorizer().signup, params as SignupInput)
|
||||
const resp = await authenticate(authorizer().signup as AuthFunctionType, params as SignupInput)
|
||||
console.debug('[context.session] signUp:', resp)
|
||||
if (resp?.data) {
|
||||
setSession(resp.data as AuthToken)
|
||||
|
@ -268,8 +317,13 @@ export const SessionProvider = (props: {
|
|||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in a user using the provided credentials.
|
||||
* @param params - The login input parameters.
|
||||
* @returns A Promise resolving to `true` if sign-in was successful, otherwise `false`.
|
||||
*/
|
||||
const signIn = async (params: LoginInput): Promise<boolean> => {
|
||||
const resp = await authenticate(authorizer().login, params as LoginInput)
|
||||
const resp = await authenticate(authorizer().login as AuthFunctionType, params)
|
||||
console.debug('[context.session] signIn:', resp)
|
||||
if (resp?.data) {
|
||||
setSession(resp.data as AuthToken)
|
||||
|
@ -280,61 +334,97 @@ export const SessionProvider = (props: {
|
|||
return false
|
||||
}
|
||||
|
||||
const updateProfile = async (params: UpdateProfileInput) => {
|
||||
const resp = await authenticate(authorizer().updateProfile, params as UpdateProfileInput)
|
||||
/**
|
||||
* Updates the user's profile with the provided parameters.
|
||||
* @param params - The update profile input parameters.
|
||||
* @returns A Promise resolving to `true` if the update was successful, otherwise `false`.
|
||||
*/
|
||||
const updateProfile = async (params: UpdateProfileInput): Promise<boolean> => {
|
||||
const resp = await authenticate(authorizer().updateProfile, params)
|
||||
console.debug('[context.session] updateProfile response:', resp)
|
||||
if (resp?.data) {
|
||||
// console.debug('[context.session] response data ', resp.data)
|
||||
// FIXME: renew updated profile
|
||||
// Optionally refresh session or user data here
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const signOut = async () => {
|
||||
/**
|
||||
* Signs out the current user and clears the session.
|
||||
* @returns A Promise resolving to `true` if sign-out was successful.
|
||||
*/
|
||||
const signOut = async (): Promise<boolean> => {
|
||||
const authResult: ApiResponse<GenericResponse> = await authorizer().logout()
|
||||
// console.debug('[context.session] sign out', authResult)
|
||||
if (authResult) {
|
||||
setSession({} as AuthToken)
|
||||
setIsSessionLoaded(true)
|
||||
showSnackbar({ body: t("You've successfully logged out") })
|
||||
// console.debug(session())
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const changePassword = async (password: string, token: string) => {
|
||||
/**
|
||||
* Changes the user's password using a token from a password reset email.
|
||||
* @param password - The new password.
|
||||
* @param token - The token from the password reset email.
|
||||
* @returns A Promise resolving to `true` if the password was changed successfully.
|
||||
*/
|
||||
const changePassword = async (password: string, token: string): Promise<boolean> => {
|
||||
const resp = await authorizer().resetPassword({
|
||||
password,
|
||||
token,
|
||||
confirm_password: password
|
||||
})
|
||||
console.debug('[context.session] change password response:', resp)
|
||||
if (resp.data) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const forgotPassword = async (params: ForgotPasswordInput) => {
|
||||
/**
|
||||
* Initiates the forgot password process for the given email.
|
||||
* @param params - The forgot password input parameters.
|
||||
* @returns A Promise resolving to an error message if any, otherwise an empty string.
|
||||
*/
|
||||
const forgotPassword = async (params: ForgotPasswordInput): Promise<string> => {
|
||||
const resp = await authorizer().forgotPassword(params)
|
||||
console.debug('[context.session] change password response:', resp)
|
||||
return resp?.errors?.pop()?.message || ''
|
||||
console.debug('[context.session] forgot password response:', resp)
|
||||
if (resp.errors.length > 0) {
|
||||
return resp.errors.pop()?.message || ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Resends the verification email to the user.
|
||||
* @param params - The resend verify email input parameters.
|
||||
* @returns A Promise resolving to `true` if the email was sent successfully.
|
||||
*/
|
||||
const resendVerifyEmail = async (params: ResendVerifyEmailInput): Promise<boolean> => {
|
||||
const resp = await authorizer().resendVerifyEmail(params as ResendVerifyEmailInput)
|
||||
const resp = await authorizer().resendVerifyEmail(params)
|
||||
console.debug('[context.session] resend verify email response:', resp)
|
||||
if (resp.errors) {
|
||||
if (resp.errors.length > 0) {
|
||||
resp.errors.forEach((error) => {
|
||||
showSnackbar({ type: 'error', body: error.message })
|
||||
})
|
||||
return false
|
||||
}
|
||||
return resp ? resp.data?.message === 'Verification email has been sent. Please check your inbox' : false
|
||||
return resp.data?.message === 'Verification email has been sent. Please check your inbox'
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an email is already registered.
|
||||
* @param email - The email to check.
|
||||
* @returns A Promise resolving to the message from the server indicating the registration status.
|
||||
*/
|
||||
const isRegistered = async (email: string): Promise<string> => {
|
||||
console.debug('[context.session] calling is_registered for ', email)
|
||||
try {
|
||||
const response = await authorizer().graphqlQuery({
|
||||
query: `query { is_registered(email: "${email}") { message }}`
|
||||
query: 'query IsRegistered($email: String!) { is_registered(email: $email) { message }}',
|
||||
variables: { email }
|
||||
})
|
||||
return response?.data?.is_registered?.message
|
||||
} catch (error) {
|
||||
|
@ -361,6 +451,16 @@ export const SessionProvider = (props: {
|
|||
console.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
// authorized graphql client
|
||||
const [client, setClient] = createSignal<Client>()
|
||||
createEffect(
|
||||
on(session, (s?: AuthToken) => {
|
||||
const tkn = s?.access_token
|
||||
setClient((_c?: Client) => graphqlClientCreate(coreApiUrl, tkn))
|
||||
})
|
||||
)
|
||||
|
||||
const actions = {
|
||||
loadSession,
|
||||
requireAuthentication,
|
||||
|
@ -378,6 +478,7 @@ export const SessionProvider = (props: {
|
|||
isRegistered
|
||||
}
|
||||
const value: SessionContextType = {
|
||||
client,
|
||||
authError,
|
||||
config,
|
||||
session,
|
||||
|
|
|
@ -9,3 +9,5 @@ mount(() => <StartClient />, document.getElementById('app') || document.body)
|
|||
// navigator.serviceWorker.register(`/sw.js`);
|
||||
// });
|
||||
// }
|
||||
|
||||
export default {}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export const notChar = /[^\dA-Za-zА-Яа-я]/
|
||||
export const notChar = /[^\dA-Za-zА-Яа-я]/g
|
||||
export const allChar = /[\dA-Za-zА-Яа-я]/
|
||||
export const notLatin = /[^A-Za-z]/
|
||||
export const notRus = /[^ËА-Яа-яё]/
|
||||
|
|
|
@ -295,6 +295,7 @@
|
|||
"New stories and more are waiting for you every day!": "Каждый день вас ждут новые истории и ещё много всего интересного!",
|
||||
"Newsletter": "Рассылка",
|
||||
"Night mode": "Ночная тема",
|
||||
"No drafts": "Нет черновиков",
|
||||
"No notifications yet": "Уведомлений пока нет",
|
||||
"No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться",
|
||||
"not verified": "ещё не подтверждён",
|
||||
|
|
|
@ -3,10 +3,22 @@ import { capitalize } from '~/utils/capitalize'
|
|||
import { cyrillicRegex, findFirstReadableCharIndex, notChar, notLatin, notRus } from './chars'
|
||||
import { translit } from './translit'
|
||||
|
||||
/**
|
||||
* Checks if a string contains Cyrillic characters.
|
||||
* @param s - The string to check.
|
||||
* @returns `true` if the string contains Cyrillic characters, otherwise `false`.
|
||||
*/
|
||||
export const isCyrillic = (s: string): boolean => {
|
||||
return cyrillicRegex.test(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the author's name based on the provided language. For English (`lng === 'en'`), it transliterates
|
||||
* and capitalizes Cyrillic names, handling special cases for characters like 'ё' and 'ь'.
|
||||
* @param author - The author object containing the name to translate.
|
||||
* @param lng - The target language for translation ('en' or 'ru').
|
||||
* @returns The translated author name, or the original if no translation is needed.
|
||||
*/
|
||||
export const translateAuthor = (author: Author, lng: string) =>
|
||||
lng === 'en' && isCyrillic(author?.name || '')
|
||||
? capitalize(
|
||||
|
@ -15,6 +27,15 @@ export const translateAuthor = (author: Author, lng: string) =>
|
|||
)
|
||||
: author.name
|
||||
|
||||
/**
|
||||
* Reduces a list of authors into groups based on the first readable letter of their last name.
|
||||
* The grouping depends on the language ('ru' for Russian and 'en' for English).
|
||||
* Non-Cyrillic or non-Latin characters are grouped under `@`.
|
||||
* @param acc - The accumulator object for grouping authors by the first readable letter.
|
||||
* @param author - The author object containing the name.
|
||||
* @param lng - The language code ('en' or 'ru') used for transliteration and sorting.
|
||||
* @returns The accumulator object with authors grouped by the first readable letter of their last name.
|
||||
*/
|
||||
export const authorLetterReduce = (acc: { [x: string]: Author[] }, author: Author, lng: string) => {
|
||||
let letter = ''
|
||||
|
||||
|
|
|
@ -1,9 +1,20 @@
|
|||
import { EditorOptions } from '@tiptap/core'
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import { Document as DocExt } from '@tiptap/extension-document'
|
||||
import Dropcursor from '@tiptap/extension-dropcursor'
|
||||
import Focus from '@tiptap/extension-focus'
|
||||
import Gapcursor from '@tiptap/extension-gapcursor'
|
||||
import HardBreak from '@tiptap/extension-hard-break'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import HorizontalRule from '@tiptap/extension-horizontal-rule'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Italic from '@tiptap/extension-italic'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import { Text } from '@tiptap/extension-text'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import ArticleNode from '~/components/Editor/extensions/Article'
|
||||
import { CustomBlockquote } from '~/components/Editor/extensions/CustomBlockquote'
|
||||
import { Figcaption } from '~/components/Editor/extensions/Figcaption'
|
||||
import { Figure } from '~/components/Editor/extensions/Figure'
|
||||
|
@ -36,6 +47,15 @@ export const base: EditorOptions['extensions'] = [
|
|||
})
|
||||
]
|
||||
|
||||
export const minimal: EditorOptions['extensions'] = [
|
||||
DocExt,
|
||||
Text,
|
||||
Paragraph,
|
||||
Bold,
|
||||
Italic,
|
||||
Link.configure({ autolink: true, openOnClick: false })
|
||||
]
|
||||
|
||||
// Extend the Figure extension to include Figcaption
|
||||
export const ImageFigure = Figure.extend({
|
||||
name: 'capturedImage',
|
||||
|
@ -53,50 +73,15 @@ export const custom: EditorOptions['extensions'] = [
|
|||
]
|
||||
|
||||
export const extended: EditorOptions['extensions'] = [
|
||||
HorizontalRule.configure({ HTMLAttributes: { class: 'horizontalRule' } }),
|
||||
Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }),
|
||||
Dropcursor,
|
||||
CustomBlockquote,
|
||||
Span,
|
||||
ToggleTextWrap,
|
||||
Footnote,
|
||||
CustomBlockquote
|
||||
// TODO: Добавьте другие кастомные расширения здесь
|
||||
Focus,
|
||||
Gapcursor,
|
||||
HardBreak,
|
||||
ArticleNode
|
||||
]
|
||||
|
||||
/*
|
||||
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.')
|
||||
}
|
||||
}
|
||||
*/
|
|
@ -1,13 +1,10 @@
|
|||
import { createAsync } from '@solidjs/router'
|
||||
import { Client } from '@urql/core'
|
||||
import { createMemo } from 'solid-js'
|
||||
import { AuthGuard } from '~/components/AuthGuard'
|
||||
import { DraftsView } from '~/components/Views/DraftsView'
|
||||
import { PageLayout } from '~/components/_shared/PageLayout'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useSession } from '~/context/session'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import getDraftsQuery from '~/graphql/query/core/articles-load-drafts'
|
||||
import { Shout } from '~/graphql/schema/core.gen'
|
||||
|
||||
|
@ -19,9 +16,8 @@ const fetchDrafts = async (client: Client) => {
|
|||
|
||||
export default () => {
|
||||
const { t } = useLocalize()
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const drafts = createAsync(async () => await fetchDrafts(client()))
|
||||
const { client } = useSession()
|
||||
const drafts = createAsync(async () => client() && (await fetchDrafts(client() as Client)))
|
||||
|
||||
return (
|
||||
<PageLayout title={`${t('Discours')} :: ${t('Drafts')}`}>
|
||||
|
|
|
@ -2,11 +2,9 @@ import { RouteSectionProps, redirect } from '@solidjs/router'
|
|||
import { createEffect, createMemo, createSignal, lazy, on } from 'solid-js'
|
||||
import { AuthGuard } from '~/components/AuthGuard'
|
||||
import { PageLayout } from '~/components/_shared/PageLayout'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useSession } from '~/context/session'
|
||||
import { useSnackbar } from '~/context/ui'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import getShoutDraft from '~/graphql/query/core/article-my'
|
||||
import { Shout } from '~/graphql/schema/core.gen'
|
||||
import { LayoutType } from '~/types/common'
|
||||
|
@ -15,31 +13,32 @@ const EditView = lazy(() => import('~/components/Views/EditView/EditView'))
|
|||
|
||||
export default (props: RouteSectionProps) => {
|
||||
const { t } = useLocalize()
|
||||
const { session } = useSession()
|
||||
const { session, client } = useSession()
|
||||
const snackbar = useSnackbar()
|
||||
const fail = async (error: string) => {
|
||||
console.error(error)
|
||||
const errorMessage = error === 'forbidden' ? "You can't edit this post" : error
|
||||
await snackbar?.showSnackbar({ type: 'error', body: t(errorMessage) })
|
||||
redirect('/edit') // all drafts page
|
||||
}
|
||||
const [shout, setShout] = createSignal<Shout>()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
|
||||
createEffect(on(session, (s) => s?.access_token && loadDraft(), { defer: true }))
|
||||
|
||||
const loadDraft = async () => {
|
||||
createEffect(
|
||||
on(
|
||||
session,
|
||||
async (s) => {
|
||||
if (!s?.access_token) return
|
||||
const shout_id = Number.parseInt(props.params.id)
|
||||
const result = await client()?.query(getShoutDraft, { shout_id }).toPromise()
|
||||
if (result) {
|
||||
const { shout: loadedShout, error } = result.data.get_my_shout
|
||||
if (error) {
|
||||
fail(error)
|
||||
console.error(error)
|
||||
const errorMessage = error === 'forbidden' ? "You can't edit this post" : error
|
||||
await snackbar?.showSnackbar({ type: 'error', body: t(errorMessage) })
|
||||
redirect('/edit') // all drafts page
|
||||
} else {
|
||||
setShout(loadedShout)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{}
|
||||
)
|
||||
)
|
||||
|
||||
const title = createMemo(() => {
|
||||
const layout = (shout()?.layout as LayoutType) || 'article'
|
||||
|
|
|
@ -1,22 +1,24 @@
|
|||
import { AuthToken } from '@authorizerdev/authorizer-js'
|
||||
import { RouteSectionProps } from '@solidjs/router'
|
||||
import { createEffect, createMemo, createSignal, on } from 'solid-js'
|
||||
import { createEffect, createSignal, on } from 'solid-js'
|
||||
import { AuthGuard } from '~/components/AuthGuard'
|
||||
import EditSettingsView from '~/components/Views/EditView/EditSettingsView'
|
||||
import { PageLayout } from '~/components/_shared/PageLayout'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useSession } from '~/context/session'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import getShoutDraft from '~/graphql/query/core/article-my'
|
||||
import { Shout } from '~/graphql/schema/core.gen'
|
||||
|
||||
export default (props: RouteSectionProps) => {
|
||||
const { t } = useLocalize()
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
createEffect(on(session, (s) => s?.access_token && loadDraft(), { defer: true }))
|
||||
const { session, client } = useSession()
|
||||
const [shout, setShout] = createSignal<Shout>()
|
||||
const loadDraft = async () => {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
session,
|
||||
async (s?: AuthToken) => {
|
||||
if (!s?.access_token) return
|
||||
const shout_id = Number.parseInt(props.params.id)
|
||||
const result = await client()?.query(getShoutDraft, { shout_id }).toPromise()
|
||||
if (result) {
|
||||
|
@ -24,7 +26,11 @@ export default (props: RouteSectionProps) => {
|
|||
if (error) throw new Error(error)
|
||||
setShout(loadedShout)
|
||||
}
|
||||
}
|
||||
},
|
||||
{}
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<PageLayout title={`${t('Discours')} :: ${t('Publication settings')}`}>
|
||||
<AuthGuard>
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
import { useNavigate } from '@solidjs/router'
|
||||
import { clsx } from 'clsx'
|
||||
import { For, createMemo } from 'solid-js'
|
||||
import { For } from 'solid-js'
|
||||
import { AuthGuard } from '~/components/AuthGuard'
|
||||
import { Button } from '~/components/_shared/Button'
|
||||
import { Icon } from '~/components/_shared/Icon'
|
||||
import { PageLayout } from '~/components/_shared/PageLayout'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { useEditorContext } from '~/context/editor'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useSession } from '~/context/session'
|
||||
import { useSnackbar } from '~/context/ui'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import createShoutMutation from '~/graphql/mutation/core/article-create'
|
||||
import styles from '~/styles/Create.module.scss'
|
||||
import { LayoutType } from '~/types/common'
|
||||
|
||||
import styles from '~/styles/Create.module.scss'
|
||||
|
||||
export default () => {
|
||||
const { t } = useLocalize()
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const { client } = useSession()
|
||||
const { saveDraftToLocalStorage } = useEditorContext()
|
||||
|
||||
const { showSnackbar } = useSnackbar()
|
||||
const navigate = useNavigate()
|
||||
|
@ -29,19 +29,30 @@ export default () => {
|
|||
if (result) {
|
||||
console.debug(result)
|
||||
const { shout, error } = result.data.create_shout
|
||||
if (error)
|
||||
if (error) {
|
||||
showSnackbar({
|
||||
body: `${t('Error')}: ${t(error)}`,
|
||||
type: 'error'
|
||||
})
|
||||
if (shout?.id) navigate(`/edit/${shout.id}`)
|
||||
return
|
||||
}
|
||||
if (shout?.id) {
|
||||
saveDraftToLocalStorage({
|
||||
shoutId: shout.id,
|
||||
selectedTopics: shout.topics,
|
||||
slug: shout.slug,
|
||||
title: '',
|
||||
body: ''
|
||||
})
|
||||
navigate(`/edit/${shout.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<PageLayout
|
||||
title={`${t('Discours')} :: ${t('Choose a post type')}`}
|
||||
key="home"
|
||||
desc="Participate in the Discours: share information, join the editorial team"
|
||||
desc={t('Participate in the Discours: share information, join the editorial team')}
|
||||
>
|
||||
<AuthGuard>
|
||||
<article class={clsx('wide-container', 'container--static-page', styles.Create)}>
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import { Params, RouteSectionProps, createAsync } from '@solidjs/router'
|
||||
import { Show, createEffect, createMemo, on } from 'solid-js'
|
||||
import { Show, createEffect, createSignal, on } from 'solid-js'
|
||||
import { TopicsNav } from '~/components/TopicsNav'
|
||||
import { Expo } from '~/components/Views/Expo'
|
||||
import ExpoNav from '~/components/Views/Expo/ExpoNav'
|
||||
import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper'
|
||||
import { PageLayout } from '~/components/_shared/PageLayout'
|
||||
import { EXPO_LAYOUTS, SHOUTS_PER_PAGE } from '~/context/feed'
|
||||
import { EXPO_LAYOUTS, EXPO_TITLES, SHOUTS_PER_PAGE, useFeed } from '~/context/feed'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { loadShouts } from '~/graphql/api/public'
|
||||
import { LoadShoutsOptions, Shout } from '~/graphql/schema/core.gen'
|
||||
import { LayoutType } from '~/types/common'
|
||||
import { LoadShoutsFilters, LoadShoutsOptions, Shout } from '~/graphql/schema/core.gen'
|
||||
import { ExpoLayoutType } from '~/types/common'
|
||||
import { restoreScrollPosition, saveScrollPosition } from '~/utils/scroll'
|
||||
import { byCreated } from '~/utils/sort'
|
||||
|
||||
const fetchExpoShouts = async (layouts: string[]) => {
|
||||
const result = await loadShouts({
|
||||
|
@ -28,39 +32,63 @@ export const route = {
|
|||
|
||||
export default (props: RouteSectionProps<Shout[]>) => {
|
||||
const { t } = useLocalize()
|
||||
const { expoFeed, setExpoFeed, feedByLayout } = useFeed()
|
||||
const [loadMoreVisible, setLoadMoreVisible] = createSignal(false)
|
||||
const getTitle = (l?: string) => EXPO_TITLES[(l as ExpoLayoutType) || '']
|
||||
|
||||
const shouts = createAsync(
|
||||
async () =>
|
||||
props.data || (await fetchExpoShouts(props.params.layout ? [props.params.layout] : EXPO_LAYOUTS))
|
||||
)
|
||||
const layout = createMemo(() => props.params.layout)
|
||||
const title = createMemo(() => {
|
||||
switch (layout()) {
|
||||
case 'audio': {
|
||||
return t('Audio')
|
||||
}
|
||||
case 'video': {
|
||||
return t('Video')
|
||||
}
|
||||
case 'image': {
|
||||
return t('Artworks')
|
||||
}
|
||||
case 'literature': {
|
||||
return t('Literature')
|
||||
}
|
||||
default: {
|
||||
return t('Art')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(on(title, (ttl) => (document.title = ttl), { defer: true }))
|
||||
|
||||
// Функция для загрузки дополнительных шотов
|
||||
const loadMore = async () => {
|
||||
saveScrollPosition()
|
||||
const limit = SHOUTS_PER_PAGE
|
||||
const layouts = props.params.layout ? [props.params.layout] : EXPO_LAYOUTS
|
||||
const offset = expoFeed()?.length || 0
|
||||
const filters: LoadShoutsFilters = { layouts, featured: true }
|
||||
const options: LoadShoutsOptions = { filters, limit, offset }
|
||||
const shoutsFetcher = loadShouts(options)
|
||||
const result = await shoutsFetcher()
|
||||
setLoadMoreVisible(Boolean(result?.length))
|
||||
if (result) {
|
||||
setExpoFeed((prev) => Array.from(new Set([...(prev || []), ...result])).sort(byCreated))
|
||||
}
|
||||
restoreScrollPosition()
|
||||
return result as LoadMoreItems
|
||||
}
|
||||
// Эффект для загрузки данных при изменении layout
|
||||
createEffect(
|
||||
on(
|
||||
() => props.params.layout as ExpoLayoutType,
|
||||
async (layout?: ExpoLayoutType) => {
|
||||
const layouts = layout ? [layout] : EXPO_LAYOUTS
|
||||
const offset = (layout ? feedByLayout()[layout]?.length : expoFeed()?.length) || 0
|
||||
const options: LoadShoutsOptions = {
|
||||
filters: { layouts, featured: true },
|
||||
limit: SHOUTS_PER_PAGE,
|
||||
offset
|
||||
}
|
||||
const shoutsFetcher = loadShouts(options)
|
||||
const result = await shoutsFetcher()
|
||||
setExpoFeed(result || [])
|
||||
}
|
||||
)
|
||||
)
|
||||
return (
|
||||
<PageLayout withPadding={true} zeroBottomPadding={true} title={`${t('Discours')} :: ${title()}`}>
|
||||
<PageLayout
|
||||
withPadding={true}
|
||||
zeroBottomPadding={true}
|
||||
title={`${t('Discours')} :: ${getTitle(props.params.layout || '')}`}
|
||||
>
|
||||
<TopicsNav />
|
||||
<ExpoNav layout={(props.params.layout || '') as ExpoLayoutType | ''} />
|
||||
<LoadMoreWrapper loadFunction={loadMore} pageSize={SHOUTS_PER_PAGE} hidden={!loadMoreVisible()}>
|
||||
<Show when={shouts()} keyed>
|
||||
{(sss) => <Expo shouts={sss} layout={layout() as LayoutType} />}
|
||||
{(sss: Shout[]) => <Expo shouts={sss} layout={props.params.layout as ExpoLayoutType} />}
|
||||
</Show>
|
||||
</LoadMoreWrapper>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { RouteSectionProps, useSearchParams } from '@solidjs/router'
|
||||
import { createEffect, createMemo } from 'solid-js'
|
||||
|
||||
import { AUTHORS_PER_PAGE } from '~/components/Views/AllAuthors/AllAuthors'
|
||||
import { Feed } from '~/components/Views/Feed'
|
||||
import { FeedProps } from '~/components/Views/Feed/Feed'
|
||||
import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper'
|
||||
import { PageLayout } from '~/components/_shared/PageLayout'
|
||||
import { coreApiUrl } from '~/config'
|
||||
import { useFeed } from '~/context/feed'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { ReactionsProvider } from '~/context/reactions'
|
||||
|
@ -17,7 +17,6 @@ import {
|
|||
loadFollowedShouts,
|
||||
loadUnratedShouts
|
||||
} from '~/graphql/api/private'
|
||||
import { graphqlClientCreate } from '~/graphql/client'
|
||||
import { LoadShoutsOptions, Shout, Topic } from '~/graphql/schema/core.gen'
|
||||
import { FromPeriod, getFromDate } from '~/lib/fromPeriod'
|
||||
|
||||
|
@ -38,8 +37,7 @@ export default (props: RouteSectionProps<{ shouts: Shout[]; topics: Topic[] }>)
|
|||
const [searchParams] = useSearchParams<FeedSearchParams>() // ?period=month
|
||||
const { t } = useLocalize()
|
||||
const { setFeed, feed } = useFeed()
|
||||
const { session } = useSession()
|
||||
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
|
||||
const { client } = useSession()
|
||||
|
||||
// preload all topics
|
||||
const { addTopics, sortedTopics } = useTopics()
|
||||
|
|
3
src/types/common.d.ts
vendored
3
src/types/common.d.ts
vendored
|
@ -4,7 +4,8 @@ export type RootSearchParams = {
|
|||
token: string;
|
||||
};
|
||||
|
||||
export type LayoutType = 'article' | 'audio' | 'video' | 'image' | 'literature';
|
||||
export type ExpoLayoutType = 'audio' | 'video' | 'image' | 'literature';
|
||||
export type LayoutType = 'article' | ExpoLayoutType;
|
||||
export type FollowsFilter = 'all' | 'authors' | 'topics' | 'communities';
|
||||
export type SortFunction<T> = (a: T, b: T) => number
|
||||
export type FilterFunction<T> = (a: T) => boolean
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
// biome-ignore lint/correctness/noNodejsModules: used during build
|
||||
import path from 'node:path'
|
||||
// import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import dotenv from 'dotenv'
|
||||
import { CSSOptions } from 'vite'
|
||||
import mkcert from 'vite-plugin-mkcert'
|
||||
import { PolyfillOptions, nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
import sassDts from 'vite-plugin-sass-dts'
|
||||
// import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
console.log(`[vite.config] development mode: ${isDev}`)
|
||||
// Load environment variables from .env file
|
||||
dotenv.config()
|
||||
|
||||
export const isDev = process.env.NODE_ENV !== 'production'
|
||||
console.log(`[vite.config] ${process.env.NODE_ENV} mode`)
|
||||
|
||||
const polyfillOptions = {
|
||||
include: ['path', 'stream', 'util'],
|
||||
exclude: ['http'],
|
||||
globals: {
|
||||
Buffer: true
|
||||
},
|
||||
overrides: {
|
||||
fs: 'memfs'
|
||||
},
|
||||
globals: { Buffer: true },
|
||||
overrides: { fs: 'memfs' },
|
||||
protocolImports: true
|
||||
} as PolyfillOptions
|
||||
|
||||
|
@ -45,12 +45,19 @@ export default {
|
|||
build: {
|
||||
target: 'esnext',
|
||||
sourcemap: true,
|
||||
minify: 'terser', // explicit terser usage
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true // removes console logs in production
|
||||
}
|
||||
},
|
||||
rollupOptions: {
|
||||
// plugins: [visualizer()]
|
||||
output: {
|
||||
manualChunks: {
|
||||
icons: ['./src/components/_shared/Icon/Icon.tsx'],
|
||||
session: ['./src/context/session.tsx'],
|
||||
localize: ['./src/context/localize.tsx'],
|
||||
editor: ['./src/context/editor.tsx'],
|
||||
connect: ['./src/context/connect.tsx']
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user