Merge branch 'dev' into hotfix/sv-author-empty

This commit is contained in:
Stepan Vladovskiy 2024-09-30 14:30:21 +00:00
commit b91a1be989
86 changed files with 1096 additions and 1508 deletions

2
.gitignore vendored
View File

@ -25,9 +25,9 @@ bun.lockb
/plawright-report/ /plawright-report/
target target
.github/dependabot.yml .github/dependabot.yml
.output .output
.vinxi .vinxi
*.pem *.pem
edge.* edge.*
.vscode/settings.json .vscode/settings.json
storybook-static

View File

@ -1,3 +1,4 @@
import type { Page } from '@playwright/test'
import type { TestRunnerConfig } from '@storybook/test-runner' import type { TestRunnerConfig } from '@storybook/test-runner'
import { checkA11y, injectAxe } from 'axe-playwright' 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 * See https://storybook.js.org/docs/react/writing-tests/test-runner#test-hook-api-experimental
* to learn more about the test-runner hooks API. * to learn more about the test-runner hooks API.
*/ */
const a11yConfig: TestRunnerConfig = { const a11yConfig = {
async preRender(page) { async preRender(page: Page) {
await injectAxe(page) await injectAxe(page)
}, },
async postRender(page) { async postRender(page: Page) {
await checkA11y(page, '#storybook-root', { await checkA11y(page, '#storybook-root', {
detailedReport: true, detailedReport: true,
detailedReportOptions: { detailedReportOptions: {
@ -17,6 +18,6 @@ const a11yConfig: TestRunnerConfig = {
} }
}) })
} }
} } as TestRunnerConfig
module.exports = a11yConfig module.exports = a11yConfig

View File

@ -1,2 +1,6 @@
.vercel/ node_modules
dist/ dist/
storybook-static
.output
.vinxi
.vercel

View File

@ -1,12 +1,12 @@
import { SolidStartInlineConfig, defineConfig } from '@solidjs/start/config' 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 isVercel = Boolean(process.env.VERCEL)
const isNetlify = Boolean(process?.env.NETLIFY) const isNetlify = Boolean(process.env.NETLIFY)
const isBun = Boolean(process.env.BUN) const isBun = Boolean(process.env.BUN)
export const runtime = isNetlify ? 'netlify' : isVercel ? 'vercel_edge' : isBun ? 'bun' : 'node' const preset = isNetlify ? 'netlify' : isVercel ? 'vercel_edge' : isBun ? 'bun' : 'node'
console.info(`[app.config] solid-start build for ${runtime}!`) console.info(`[app.config] solid-start preset {> ${preset} <}`)
export default defineConfig({ export default defineConfig({
nitro: { nitro: {
@ -14,10 +14,10 @@ export default defineConfig({
}, },
ssr: true, ssr: true,
server: { server: {
preset: runtime, preset,
port: 3000, port: 3000,
https: true https: true
}, },
devOverlay: true, devOverlay: isDev,
vite: viteConfig vite: viteConfig
} as SolidStartInlineConfig) } as SolidStartInlineConfig)

42
package-lock.json generated
View File

@ -77,7 +77,7 @@
"@tiptap/starter-kit": "^2.7.2", "@tiptap/starter-kit": "^2.7.2",
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
"@types/cookie-signature": "^1.1.2", "@types/cookie-signature": "^1.1.2",
"@types/node": "^22.5.5", "@types/node": "^22.6.0",
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@urql/core": "^5.0.6", "@urql/core": "^5.0.6",
"axe-playwright": "^2.0.2", "axe-playwright": "^2.0.2",
@ -100,7 +100,7 @@
"prosemirror-view": "^1.34.3", "prosemirror-view": "^1.34.3",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"sass": "1.77.6", "sass": "1.77.6",
"solid-js": "^1.8.22", "solid-js": "^1.8.23",
"solid-popper": "^0.3.0", "solid-popper": "^0.3.0",
"solid-tiptap": "0.7.0", "solid-tiptap": "0.7.0",
"solid-transition-group": "^0.2.3", "solid-transition-group": "^0.2.3",
@ -124,7 +124,7 @@
"vite-plugin-node-polyfills": "^0.22.0", "vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-sass-dts": "^1.3.29", "vite-plugin-sass-dts": "^1.3.29",
"y-prosemirror": "1.2.12", "y-prosemirror": "1.2.12",
"yjs": "13.6.18" "yjs": "13.6.19"
}, },
"engines": { "engines": {
"node": ">= 20" "node": ">= 20"
@ -5254,9 +5254,9 @@
} }
}, },
"node_modules/@rollup/plugin-node-resolve": { "node_modules/@rollup/plugin-node-resolve": {
"version": "15.2.4", "version": "15.3.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz",
"integrity": "sha512-wnKAGisav1m2vgVK2/2mNowK5DCqff7kpz76cY1pECVE0qRQTCAIcWP5xmdGDi8X8K9SYeeC98i6cD3fk6qkDg==", "integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5324,9 +5324,9 @@
} }
}, },
"node_modules/@rollup/pluginutils": { "node_modules/@rollup/pluginutils": {
"version": "5.1.1", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.2.tgz",
"integrity": "sha512-bVRmQqBIyGD+VMihdEV2IBurfIrdW9tD9yzJUL3CBRDbyPBVzQnBSMSgyUZHl1E335rpMRj7r4o683fXLYw8iw==", "integrity": "sha512-/FIdS3PyZ39bjZlwqFnWqCOVnW7o963LtKMwQOD0NhQqw22gSr2YY1afu3FxRip4ZCZNsD5jq6Aaz6QV3D/Njw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -7740,9 +7740,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/lodash": { "node_modules/@types/lodash": {
"version": "4.17.7", "version": "4.17.9",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.9.tgz",
"integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", "integrity": "sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -7771,9 +7771,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.5.5", "version": "22.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.6.1.tgz",
"integrity": "sha512-Xjs4y5UPO/CLdzpgR6GirZJx36yScjh73+2NlLlkFRSoQN8B0DpfXPdZGnvVmLRLOsqDpOfTNv7D9trgGhmOIA==", "integrity": "sha512-V48tCfcKb/e6cVUigLAaJDAILdMP0fUW6BidkPK4GpGjXcfbnoHasCZDwz3N3yVt5we2RHm4XTQCpv0KJz9zqw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -22932,9 +22932,9 @@
} }
}, },
"node_modules/solid-js": { "node_modules/solid-js": {
"version": "1.8.22", "version": "1.8.23",
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.8.22.tgz", "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.8.23.tgz",
"integrity": "sha512-VBzN5j+9Y4rqIKEnK301aBk+S7fvFSTs9ljg+YEdFxjNjH0hkjXPiQRcws9tE5fUzMznSS6KToL5hwMfHDgpLA==", "integrity": "sha512-0jKzMgxmU/b3k4iJmIZJW2BIArrHN+Mug0n7m7MeHvGHWiS57ZdyTmnqNMSbGRvE73QBnTiGFJc90cPPieawaA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -26804,9 +26804,9 @@
} }
}, },
"node_modules/yjs": { "node_modules/yjs": {
"version": "13.6.18", "version": "13.6.19",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.18.tgz", "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.19.tgz",
"integrity": "sha512-GBTjO4QCmv2HFKFkYIJl7U77hIB1o22vSCSQD1Ge8ZxWbIbn8AltI4gyXbtL+g5/GJep67HCMq3Y5AmNwDSyEg==", "integrity": "sha512-GNKw4mEUn5yWU2QPHRx8jppxmCm9KzbBhB4qJLUJFiiYD0g/tDVgXQ7aPkyh01YO28kbs2J/BEbWBagjuWyejw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -84,7 +84,7 @@
"@tiptap/starter-kit": "^2.7.2", "@tiptap/starter-kit": "^2.7.2",
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
"@types/cookie-signature": "^1.1.2", "@types/cookie-signature": "^1.1.2",
"@types/node": "^22.5.5", "@types/node": "^22.6.1",
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@urql/core": "^5.0.6", "@urql/core": "^5.0.6",
"axe-playwright": "^2.0.2", "axe-playwright": "^2.0.2",
@ -107,7 +107,7 @@
"prosemirror-view": "^1.34.3", "prosemirror-view": "^1.34.3",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"sass": "1.77.6", "sass": "1.77.6",
"solid-js": "^1.8.22", "solid-js": "^1.8.23",
"solid-popper": "^0.3.0", "solid-popper": "^0.3.0",
"solid-tiptap": "0.7.0", "solid-tiptap": "0.7.0",
"solid-transition-group": "^0.2.3", "solid-transition-group": "^0.2.3",
@ -131,12 +131,12 @@
"vite-plugin-node-polyfills": "^0.22.0", "vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-sass-dts": "^1.3.29", "vite-plugin-sass-dts": "^1.3.29",
"y-prosemirror": "1.2.12", "y-prosemirror": "1.2.12",
"yjs": "13.6.18" "yjs": "13.6.19"
}, },
"overrides": { "overrides": {
"sass": "1.77.6", "sass": "1.77.6",
"vite": "5.3.5", "vite": "5.3.5",
"yjs": "13.6.18", "yjs": "13.6.19",
"y-prosemirror": "1.2.12" "y-prosemirror": "1.2.12"
}, },
"engines": { "engines": {

View File

@ -9,7 +9,7 @@ import { SharePopup, getShareUrl } from '../SharePopup'
import styles from './AudioPlayer.module.scss' 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')) const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
type Props = { type Props = {
@ -171,10 +171,9 @@ export const PlayerPlaylist = (props: Props) => {
} }
> >
<div class={styles.descriptionBlock}> <div class={styles.descriptionBlock}>
<SimplifiedEditor <MicroEditor
initialContent={mi.body} content={mi.body}
placeholder={`${t('Description')}...`} placeholder={`${t('Description')}...`}
smallHeight={true}
onChange={(value) => handleMediaItemFieldChange('body', value)} onChange={(value) => handleMediaItemFieldChange('body', value)}
/> />
<GrowingTextarea <GrowingTextarea

View File

@ -3,12 +3,10 @@ import { clsx } from 'clsx'
import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js' import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js'
import { Icon } from '~/components/_shared/Icon' import { Icon } from '~/components/_shared/Icon'
import { ShowIfAuthenticated } from '~/components/_shared/ShowIfAuthenticated' import { ShowIfAuthenticated } from '~/components/_shared/ShowIfAuthenticated'
import { coreApiUrl } from '~/config'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useReactions } from '~/context/reactions' import { useReactions } from '~/context/reactions'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
import { useSnackbar, useUI } from '~/context/ui' import { useSnackbar, useUI } from '~/context/ui'
import { graphqlClientCreate } from '~/graphql/client'
import deleteReactionMutation from '~/graphql/mutation/core/reaction-destroy' import deleteReactionMutation from '~/graphql/mutation/core/reaction-destroy'
import { import {
Author, Author,
@ -23,7 +21,7 @@ import { CommentDate } from '../CommentDate'
import { CommentRatingControl } from '../CommentRatingControl' import { CommentRatingControl } from '../CommentRatingControl'
import styles from './Comment.module.scss' import styles from './Comment.module.scss'
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor')) const MiniEditor = lazy(() => import('../../Editor/MiniEditor/MiniEditor'))
type Props = { type Props = {
comment: Reaction comment: Reaction
@ -43,14 +41,12 @@ export const Comment = (props: Props) => {
const [isReplyVisible, setIsReplyVisible] = createSignal(false) const [isReplyVisible, setIsReplyVisible] = createSignal(false)
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [editMode, setEditMode] = createSignal(false) const [editMode, setEditMode] = createSignal(false)
const [clearEditor, setClearEditor] = createSignal(false)
const [editedBody, setEditedBody] = createSignal<string>() const [editedBody, setEditedBody] = createSignal<string>()
const { session } = useSession() const { session, client } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author) const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const { createShoutReaction, updateShoutReaction } = useReactions() const { createShoutReaction, updateShoutReaction } = useReactions()
const { showConfirm } = useUI() const { showConfirm } = useUI()
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const canEdit = createMemo( const canEdit = createMemo(
() => () =>
Boolean(author()?.id) && Boolean(author()?.id) &&
@ -107,13 +103,11 @@ export const Comment = (props: Props) => {
shout: props.comment.shout.id shout: props.comment.shout.id
} }
} as MutationCreate_ReactionArgs) } as MutationCreate_ReactionArgs)
setClearEditor(true)
setIsReplyVisible(false) setIsReplyVisible(false)
setLoading(false) setLoading(false)
} catch (error) { } catch (error) {
console.error('[handleCreate reaction]:', error) console.error('[handleCreate reaction]:', error)
} }
setClearEditor(false)
} }
const toggleEditMode = () => { const toggleEditMode = () => {
@ -192,16 +186,11 @@ export const Comment = (props: Props) => {
<div class={styles.commentBody}> <div class={styles.commentBody}>
<Show when={editMode()} fallback={<div innerHTML={body()} />}> <Show when={editMode()} fallback={<div innerHTML={body()} />}>
<Suspense fallback={<p>{t('Loading')}</p>}> <Suspense fallback={<p>{t('Loading')}</p>}>
<SimplifiedEditor <MiniEditor
initialContent={editedBody() || props.comment.body || ''} content={editedBody() || props.comment.body || ''}
submitButtonText={t('Save')}
quoteEnabled={true}
imageEnabled={true}
placeholder={t('Write a comment...')} placeholder={t('Write a comment...')}
onSubmit={(value) => handleUpdate(value)} onSubmit={(value) => handleUpdate(value)}
submitByCtrlEnter={true}
onCancel={() => setEditMode(false)} onCancel={() => setEditMode(false)}
setClear={clearEditor()}
/> />
</Suspense> </Suspense>
</Show> </Show>
@ -261,12 +250,9 @@ export const Comment = (props: Props) => {
<Show when={isReplyVisible() && props.clickedReplyId === props.comment.id}> <Show when={isReplyVisible() && props.clickedReplyId === props.comment.id}>
<Suspense fallback={<p>{t('Loading')}</p>}> <Suspense fallback={<p>{t('Loading')}</p>}>
<SimplifiedEditor <MiniEditor
quoteEnabled={true}
imageEnabled={true}
placeholder={t('Write a comment...')} placeholder={t('Write a comment...')}
onSubmit={(value) => handleCreate(value)} onSubmit={(value) => handleCreate(value)}
submitByCtrlEnter={true}
/> />
</Suspense> </Suspense>
</Show> </Show>

View File

@ -9,11 +9,12 @@ import { Author, Reaction, ReactionKind, ReactionSort } from '~/graphql/schema/c
import { SortFunction } from '~/types/common' import { SortFunction } from '~/types/common'
import { byCreated, byStat } from '~/utils/sort' import { byCreated, byStat } from '~/utils/sort'
import { Button } from '../_shared/Button' import { Button } from '../_shared/Button'
import { Loading } from '../_shared/Loading'
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated' import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
import styles from './Article.module.scss' import styles from './Article.module.scss'
import { Comment } from './Comment' import { Comment } from './Comment'
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor')) const MiniEditor = lazy(() => import('../Editor/MiniEditor/MiniEditor'))
type Props = { type Props = {
articleAuthors: Author[] articleAuthors: Author[]
@ -27,7 +28,6 @@ export const CommentsTree = (props: Props) => {
const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest) const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest)
const [onlyNew, setOnlyNew] = createSignal(false) const [onlyNew, setOnlyNew] = createSignal(false)
const [newReactions, setNewReactions] = createSignal<Reaction[]>([]) const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
const [clearEditor, setClearEditor] = createSignal(false)
const [clickedReplyId, setClickedReplyId] = createSignal<number>() const [clickedReplyId, setClickedReplyId] = createSignal<number>()
const { reactionEntities, createShoutReaction, loadReactionsBy } = useReactions() const { reactionEntities, createShoutReaction, loadReactionsBy } = useReactions()
@ -70,6 +70,7 @@ export const CommentsTree = (props: Props) => {
setCookie() setCookie()
} }
}) })
const [posting, setPosting] = createSignal(false) const [posting, setPosting] = createSignal(false)
const handleSubmitComment = async (value: string) => { const handleSubmitComment = async (value: string) => {
setPosting(true) setPosting(true)
@ -81,12 +82,10 @@ export const CommentsTree = (props: Props) => {
shout: props.shoutId shout: props.shoutId
} }
}) })
setClearEditor(true)
await loadReactionsBy({ by: { shout: props.shoutSlug } }) await loadReactionsBy({ by: { shout: props.shoutSlug } })
} catch (error) { } catch (error) {
console.error('[handleCreate reaction]:', error) console.error('[handleCreate reaction]:', error)
} }
setClearEditor(false)
setPosting(false) setPosting(false)
} }
@ -155,16 +154,10 @@ export const CommentsTree = (props: Props) => {
</div> </div>
} }
> >
<SimplifiedEditor <MiniEditor placeholder={t('Write a comment...')} onSubmit={handleSubmitComment} />
quoteEnabled={true} <Show when={posting()}>
imageEnabled={true} <Loading />
autoFocus={false} </Show>
submitByCtrlEnter={true}
placeholder={t('Write a comment...')}
onSubmit={(value) => handleSubmitComment(value)}
setClear={clearEditor()}
isPosting={posting()}
/>
</ShowIfAuthenticated> </ShowIfAuthenticated>
</> </>
) )

View File

@ -1,3 +1,4 @@
import { AuthToken } from '@authorizerdev/authorizer-js'
import { createPopper } from '@popperjs/core' import { createPopper } from '@popperjs/core'
import { Link } from '@solidjs/meta' import { Link } from '@solidjs/meta'
import { A, useSearchParams } from '@solidjs/router' import { A, useSearchParams } from '@solidjs/router'
@ -73,7 +74,6 @@ export const FullArticle = (props: Props) => {
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false) const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const { t, formatDate, lang } = useLocalize() const { t, formatDate, lang } = useLocalize()
const { session, requireAuthentication } = useSession() const { session, requireAuthentication } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const { addSeen } = useFeed() const { addSeen } = useFeed()
const formattedDate = createMemo(() => formatDate(new Date((props.article.published_at || 0) * 1000))) const formattedDate = createMemo(() => formatDate(new Date((props.article.published_at || 0) * 1000)))
@ -100,12 +100,19 @@ export const FullArticle = (props: Props) => {
) )
) )
const canEdit = createMemo( const [canEdit, setCanEdit] = createSignal<boolean>(false)
() => createEffect(
Boolean(author()?.id) && on(
(props.article.authors?.some((a) => Boolean(a) && a?.id === author().id) || () => session(),
props.article.created_by?.id === author().id || (s?: AuthToken) => {
session()?.user?.roles?.includes('editor')) 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(() => { const mainTopic = createMemo(() => {
@ -534,7 +541,7 @@ export const FullArticle = (props: Props) => {
/> />
</div> </div>
<Show when={author()?.id && !canEdit()}> <Show when={session()?.access_token && !canEdit()}>
<div class={styles.help}> <div class={styles.help}>
<button class="button">{t('Cooperate')}</button> <button class="button">{t('Cooperate')}</button>
</div> </div>

View File

@ -1,10 +1,8 @@
import type { Author } from '~/graphql/schema/core.gen' import type { Author } from '~/graphql/schema/core.gen'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createMemo, createSignal } from 'solid-js' import { Show, createSignal } from 'solid-js'
import { coreApiUrl } from '~/config'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
import { graphqlClientCreate } from '~/graphql/client'
import rateAuthorMutation from '~/graphql/mutation/core/author-rate' import rateAuthorMutation from '~/graphql/mutation/core/author-rate'
import styles from './AuthorRatingControl.module.scss' import styles from './AuthorRatingControl.module.scss'
@ -17,8 +15,7 @@ export const AuthorRatingControl = (props: AuthorRatingControlProps) => {
const isUpvoted = false const isUpvoted = false
const isDownvoted = false const isDownvoted = false
const { session } = useSession() const { client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
// eslint-disable-next-line unicorn/consistent-function-scoping // eslint-disable-next-line unicorn/consistent-function-scoping
const handleRatingChange = async (isUpvote: boolean) => { const handleRatingChange = async (isUpvote: boolean) => {

View File

@ -1,14 +1,13 @@
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { renderUploadedImage } from '~/components/Upload/renderUploadedImage'
import { renderUploadedImage } from '~/components/Editor/renderUploadedImage'
import { Icon } from '~/components/_shared/Icon' import { Icon } from '~/components/_shared/Icon'
import { Popover } from '~/components/_shared/Popover' import { Popover } from '~/components/_shared/Popover'
import { useLocalize } from '~/context/localize' 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 { 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' import styles from './BubbleMenu.module.scss'
type Props = { type Props = {
@ -20,8 +19,8 @@ export const FigureBubbleMenu = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { hideModal } = useUI() const { hideModal } = useUI()
const handleUpload = (image: UploadedFile) => { const handleUpload = (image?: UploadedFile) => {
renderUploadedImage(props.editor, image) image && renderUploadedImage(props.editor, image)
hideModal() hideModal()
} }
@ -81,11 +80,7 @@ export const FigureBubbleMenu = (props: Props) => {
</Popover> </Popover>
<Modal variant="narrow" name="uploadImage"> <Modal variant="narrow" name="uploadImage">
<UploadModalContent <UploadModalContent onClose={handleUpload} />
onClose={(value) => {
handleUpload(value as UploadedFile)
}}
/>
</Modal> </Modal>
</div> </div>
) )

View File

@ -1,17 +1,15 @@
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Match, Show, Switch, createEffect, createSignal, lazy, onCleanup, onMount } from 'solid-js' import { Match, Show, Switch, createEffect, createSignal, lazy, onCleanup, onMount } from 'solid-js'
import { createEditorTransaction } from 'solid-tiptap' import { createEditorTransaction } from 'solid-tiptap'
import { Icon } from '~/components/_shared/Icon' import { Icon } from '~/components/_shared/Icon'
import { Popover } from '~/components/_shared/Popover' import { Popover } from '~/components/_shared/Popover'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { InsertLinkForm } from '../InsertLinkForm' import { InsertLinkForm } from '../EditorToolbar/InsertLinkForm'
import styles from './TextBubbleMenu.module.scss' import styles from './TextBubbleMenu.module.scss'
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor')) const MiniEditor = lazy(() => import('../MiniEditor/MiniEditor'))
type BubbleMenuProps = { type BubbleMenuProps = {
editor: Editor editor: Editor
@ -146,18 +144,13 @@ export const TextBubbleMenu = (props: BubbleMenuProps) => {
<InsertLinkForm editor={props.editor} onClose={handleCloseLinkForm} /> <InsertLinkForm editor={props.editor} onClose={handleCloseLinkForm} />
</Match> </Match>
<Match when={footnoteEditorOpen()}> <Match when={footnoteEditorOpen()}>
<SimplifiedEditor <MiniEditor
maxHeight={180}
controlsAlwaysVisible={true}
imageEnabled={true}
placeholder={t('Enter footnote text')} placeholder={t('Enter footnote text')}
onSubmit={(value) => handleAddFootnote(value)} onSubmit={(value: string) => handleAddFootnote(value)}
variant={'bordered'} content={footNote()}
initialContent={footNote()}
onCancel={() => { onCancel={() => {
setFootnoteEditorOpen(false) setFootnoteEditorOpen(false)
}} }}
submitButtonText={t('Send')}
/> />
</Match> </Match>
<Match when={!(linkEditorOpen() && footnoteEditorOpen())}> <Match when={!(linkEditorOpen() && footnoteEditorOpen())}>

View File

@ -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 { Meta, StoryObj } from 'storybook-solidjs'
import { EditorContext, EditorContextType, ShoutForm } from '~/context/editor' import { EditorComponent } from './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
}
const meta: Meta<typeof EditorComponent> = { const meta: Meta<typeof EditorComponent> = {
title: 'Components/Editor', title: 'Components/Editor',
component: EditorComponent, component: EditorComponent,
argTypes: { argTypes: {
shoutId: { content: {
control: 'number',
description: 'Unique identifier for the shout (document)',
defaultValue: 1
},
initialContent: {
control: 'text', control: 'text',
description: 'Initial content for the editor', description: 'Initial content for the editor',
defaultValue: '' defaultValue: ''
}, },
onChange: { limit: {
action: 'contentChanged', control: 'number',
description: 'Callback when the content changes' description: 'Character limit for the editor',
defaultValue: 500
}, },
disableCollaboration: { placeholder: {
control: 'boolean', control: 'text',
description: 'Disable collaboration features for Storybook', description: 'Placeholder text when the editor is empty',
defaultValue: true 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> type Story = StoryObj<typeof EditorComponent>
export const Default: Story = { 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: { args: {
shoutId: 1, content: '',
initialContent: '', limit: 500,
disableCollaboration: true placeholder: 'Start typing here...'
} }
} }
export const WithInitialContent: Story = { export const WithInitialContent: Story = {
...Default,
args: { args: {
...Default.args, content: 'This is some initial content',
initialContent: '<p>This is some initial content in the editor.</p>' limit: 500,
placeholder: 'Start typing here...'
}
}
export const WithCharacterLimit: Story = {
args: {
content: '',
limit: 50,
placeholder: 'You have a 50 character limit...'
}
}
export const WithCustomPlaceholder: Story = {
args: {
content: '',
limit: 500,
placeholder: 'Custom placeholder here...'
} }
} }

View File

@ -4,14 +4,7 @@ import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { CharacterCount } from '@tiptap/extension-character-count' import { CharacterCount } from '@tiptap/extension-character-count'
import { Collaboration } from '@tiptap/extension-collaboration' import { Collaboration } from '@tiptap/extension-collaboration'
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor' import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
import { Dropcursor } from '@tiptap/extension-dropcursor'
import { FloatingMenu } from '@tiptap/extension-floating-menu' 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 { Placeholder } from '@tiptap/extension-placeholder'
import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js' import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js'
import uniqolor from 'uniqolor' import uniqolor from 'uniqolor'
@ -21,23 +14,14 @@ import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
import { useSnackbar } from '~/context/ui' import { useSnackbar } from '~/context/ui'
import { Author } from '~/graphql/schema/core.gen' import { Author } from '~/graphql/schema/core.gen'
import { base, custom, extended } from '~/lib/editorExtensions'
import { handleImageUpload } from '~/lib/handleImageUpload' import { handleImageUpload } from '~/lib/handleImageUpload'
import { renderUploadedImage } from '../Upload/renderUploadedImage'
import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu' import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
import { TextBubbleMenu } from './BubbleMenu/TextBubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu' 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 './Editor.module.scss'
import { base } from '~/lib/editorOptions'
export type EditorComponentProps = { export type EditorComponentProps = {
shoutId: number shoutId: number
@ -118,26 +102,11 @@ export const EditorComponent = (props: EditorComponentProps) => {
}, },
extensions: [ extensions: [
...base, ...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') }), 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 CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
TrailingNode,
ArticleNode,
// menus // menus

View File

@ -1,15 +1,14 @@
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
import { Show, createEffect, createSignal } from 'solid-js' import { Show, createEffect, createSignal } from 'solid-js'
import { renderUploadedImage } from '~/components/Upload/renderUploadedImage'
import { renderUploadedImage } from '~/components/Editor/renderUploadedImage'
import { Icon } from '~/components/_shared/Icon' import { Icon } from '~/components/_shared/Icon'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useUI } from '~/context/ui' import { useUI } from '~/context/ui'
import { useOutsideClickHandler } from '~/lib/useOutsideClickHandler' import { useOutsideClickHandler } from '~/lib/useOutsideClickHandler'
import { UploadedFile } from '~/types/upload' import { UploadedFile } from '~/types/upload'
import { UploadModalContent } from '../../Upload/UploadModalContent'
import { InlineForm } from '../../_shared/InlineForm'
import { Modal } from '../../_shared/Modal' import { Modal } from '../../_shared/Modal'
import { InlineForm } from '../InlineForm'
import { UploadModalContent } from '../UploadModalContent'
import { Menu } from './Menu' import { Menu } from './Menu'
import type { MenuItem } from './Menu/Menu' import type { MenuItem } from './Menu/Menu'

View File

@ -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>
)
}

View 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>
)
}

View File

@ -1,9 +1,8 @@
import { Editor } from '@tiptap/core' import { Editor } from '@tiptap/core'
import { createEditorTransaction } from 'solid-tiptap' import { createEffect, createSignal, onCleanup } from 'solid-js'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { validateUrl } from '~/utils/validate' import { validateUrl } from '~/utils/validate'
import { InlineForm } from '../InlineForm' import { InlineForm } from '../../_shared/InlineForm'
type Props = { type Props = {
editor: Editor editor: Editor
@ -21,12 +20,22 @@ export const checkUrl = (url: string) => {
export const InsertLinkForm = (props: Props) => { export const InsertLinkForm = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const currentUrl = createEditorTransaction( const [currentUrl, setCurrentUrl] = createSignal('')
() => props.editor,
(ed) => { createEffect(() => {
return ed?.getAttributes('link').href || '' 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 = () => { const handleClearLinkForm = () => {
if (currentUrl()) { if (currentUrl()) {
props.editor?.chain().focus().unsetLink().run() props.editor?.chain().focus().unsetLink().run()
@ -39,7 +48,9 @@ export const InsertLinkForm = (props: Props) => {
.focus() .focus()
.setLink({ href: checkUrl(value) }) .setLink({ href: checkUrl(value) })
.run() .run()
props.onClose()
} }
return ( return (
<div> <div>
<InlineForm <InlineForm

View 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

View File

@ -1 +0,0 @@
export { InsertLinkForm } from './InsertLinkForm'

View File

@ -1,4 +0,0 @@
.LinkBubbleMenu {
background: var(--editor-bubble-menu-background);
box-shadow: 0 4px 10px rgba(#000, 0.25);
}

View File

@ -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>
)
}

View File

@ -1 +0,0 @@
export { LinkBubbleMenuModule } from './LinkBubbleMenu.module'

View 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)
}
}

View 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

View File

@ -1,4 +1,4 @@
.SimplifiedEditor { .MiniEditor {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,71 +1,28 @@
import type { Editor } from '@tiptap/core'
import CharacterCount from '@tiptap/extension-character-count' import CharacterCount from '@tiptap/extension-character-count'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import clsx from 'clsx' import clsx from 'clsx'
import { type JSX, Show, createEffect, createSignal, onCleanup } from 'solid-js' import { type JSX, Show, createEffect, createSignal, on } from 'solid-js'
import { import { createEditorTransaction, createTiptapEditor, useEditorHTML } from 'solid-tiptap'
createEditorTransaction, import { Button } from '~/components/_shared/Button'
createTiptapEditor,
useEditorHTML,
useEditorIsEmpty,
useEditorIsFocused
} from 'solid-tiptap'
import { Toolbar } from 'terracotta'
import { Icon } from '~/components/_shared/Icon/Icon'
import { Popover } from '~/components/_shared/Popover/Popover'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useUI } from '~/context/ui' import { base } from '~/lib/editorExtensions'
import { base } from '~/lib/editorOptions' import { EditorToolbar } from '../EditorToolbar/EditorToolbar'
import { InsertLinkForm } from '../InsertLinkForm/InsertLinkForm'
import styles from '../SimplifiedEditor.module.scss' import styles from './MiniEditor.module.scss'
interface ControlProps {
editor: Editor
title: string
key: string
onChange: () => void
isActive?: (editor: Editor) => boolean
children: JSX.Element
}
function Control(props: ControlProps): JSX.Element {
const handleClick = (ev?: MouseEvent) => {
ev?.preventDefault()
ev?.stopPropagation()
props.onChange?.()
}
return (
<Popover content={props.title}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: props.editor.isActive(props.key) })}
onClick={handleClick}
>
{props.children}
</button>
)}
</Popover>
)
}
interface MiniEditorProps { interface MiniEditorProps {
content?: string content?: string
onChange?: (content: string) => void onChange?: (content: string) => void
onSubmit?: (content: string) => void
onCancel?: () => void
limit?: number limit?: number
placeholder?: string placeholder?: string
} }
export default function MiniEditor(props: MiniEditorProps): JSX.Element { export default function MiniEditor(props: MiniEditorProps): JSX.Element {
const { t } = useLocalize()
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>() const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
const [counter, setCounter] = createSignal(0) const [counter, setCounter] = createSignal(0)
const [showLinkInput, setShowLinkInput] = createSignal(false)
const [showSimpleMenu, setShowSimpleMenu] = createSignal(false)
const { t } = useLocalize()
const { showModal } = useUI()
const editor = createTiptapEditor(() => ({ const editor = createTiptapEditor(() => ({
element: editorElement()!, element: editorElement()!,
@ -82,12 +39,11 @@ export default function MiniEditor(props: MiniEditorProps): JSX.Element {
content: props.content || '' content: props.content || ''
})) }))
const isEmpty = useEditorIsEmpty(editor) const isFocused = createEditorTransaction(editor, (instance) => instance?.isFocused)
const isFocused = useEditorIsFocused(editor) const isEmpty = createEditorTransaction(editor, (instance) => instance?.isEmpty)
const isTextSelection = createEditorTransaction(editor, (instance) => !instance?.state.selection.empty)
const html = useEditorHTML(editor) const html = useEditorHTML(editor)
createEffect(() => setShowSimpleMenu(isTextSelection())) createEffect(on(html, (c?: string) => c && props.onChange?.(c)))
createEffect(() => { createEffect(() => {
const textLength = editor()?.getText().length || 0 const textLength = editor()?.getText().length || 0
@ -96,88 +52,28 @@ export default function MiniEditor(props: MiniEditorProps): JSX.Element {
content && props.onChange?.(content) content && props.onChange?.(content)
}) })
const handleLinkClick = () => { const handleSubmit = () => {
setShowLinkInput(!showLinkInput()) html() && props.onSubmit?.(html() || '')
editor()?.chain().focus().run() 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 ( return (
<div <div class={clsx(styles.MiniEditor, styles.bordered, { [styles.isFocused]: isFocused() })}>
class={clsx(styles.SimplifiedEditor, styles.bordered, {
[styles.isFocused]: isEmpty() || isFocused()
})}
>
<div> <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} /> <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}> <Show when={counter() > 0}>
<small class={styles.limit}> <small class={styles.limit}>
{counter()} / {props.limit || '∞'} {counter()} / {props.limit || '∞'}

View File

@ -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'
}
}

View File

@ -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

View File

@ -1 +0,0 @@
export { TextBubbleMenu } from './TextBubbleMenu'

View File

@ -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;
}

View File

@ -1,4 +0,0 @@
export { EditorComponent as Editor } from './Editor'
export { Panel } from './Panel'
export { TopicSelect } from './TopicSelect'
export { UploadModalContent } from './UploadModalContent'

View 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;
}

View File

@ -10,7 +10,7 @@ import { useSession } from '~/context/session'
import { useUI } from '~/context/ui' import { useUI } from '~/context/ui'
import { handleImageUpload } from '~/lib/handleImageUpload' import { handleImageUpload } from '~/lib/handleImageUpload'
import { UploadedFile } from '~/types/upload' import { UploadedFile } from '~/types/upload'
import { InlineForm } from '../InlineForm' import { InlineForm } from '../../_shared/InlineForm'
import styles from './UploadModalContent.module.scss' import styles from './UploadModalContent.module.scss'

View File

@ -3,7 +3,6 @@ import { clsx } from 'clsx'
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js' import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js'
import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper' import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper'
import { Loading } from '~/components/_shared/Loading' import { Loading } from '~/components/_shared/Loading'
import { coreApiUrl } from '~/config'
import { useAuthors } from '~/context/authors' import { useAuthors } from '~/context/authors'
import { SHOUTS_PER_PAGE, useFeed } from '~/context/feed' import { SHOUTS_PER_PAGE, useFeed } from '~/context/feed'
import { useFollowing } from '~/context/following' import { useFollowing } from '~/context/following'
@ -11,7 +10,6 @@ import { useLocalize } from '~/context/localize'
import { useReactions } from '~/context/reactions' import { useReactions } from '~/context/reactions'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
import { loadReactions, loadShouts } from '~/graphql/api/public' import { loadReactions, loadShouts } from '~/graphql/api/public'
import { graphqlClientCreate } from '~/graphql/client'
import getAuthorFollowersQuery from '~/graphql/query/core/author-followers' import getAuthorFollowersQuery from '~/graphql/query/core/author-followers'
import getAuthorFollowsQuery from '~/graphql/query/core/author-follows' import getAuthorFollowsQuery from '~/graphql/query/core/author-follows'
import type { Author, Reaction, Shout, Topic } from '~/graphql/schema/core.gen' import type { Author, Reaction, Shout, Topic } from '~/graphql/schema/core.gen'
@ -45,8 +43,7 @@ export const AuthorView = (props: AuthorViewProps) => {
const params = useParams() const params = useParams()
const [currentTab, setCurrentTab] = createSignal<string>(params.tab) const [currentTab, setCurrentTab] = createSignal<string>(params.tab)
const { session } = useSession() const { session, client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const { loadAuthor, authorsEntities } = useAuthors() const { loadAuthor, authorsEntities } = useAuthors()
const { followers: myFollowers, follows: myFollows } = useFollowing() const { followers: myFollowers, follows: myFollows } = useFollowing()

View File

@ -1,17 +1,14 @@
import { useNavigate } from '@solidjs/router' import { useNavigate } from '@solidjs/router'
import { clsx } from 'clsx' 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 { Draft } from '~/components/Draft'
import { Loading } from '~/components/_shared/Loading'
import { useEditorContext } from '~/context/editor' import { useEditorContext } from '~/context/editor'
import { useSession } from '~/context/session' import { useLocalize } from '~/context/localize'
import { Shout } from '~/graphql/schema/core.gen' import { Shout } from '~/graphql/schema/core.gen'
import styles from './DraftsView.module.scss' import styles from './DraftsView.module.scss'
export const DraftsView = (props: { drafts: Shout[] }) => { export const DraftsView = (props: { drafts: Shout[] }) => {
const [drafts, setDrafts] = createSignal<Shout[]>(props.drafts || []) const [drafts, setDrafts] = createSignal<Shout[]>(props.drafts || [])
const { session } = useSession()
const authorized = createMemo<boolean>(() => Boolean(session()?.access_token))
const navigate = useNavigate() const navigate = useNavigate()
const { publishShoutById, deleteShout } = useEditorContext() const { publishShoutById, deleteShout } = useEditorContext()
const handleDraftDelete = async (shout: Shout) => { const handleDraftDelete = async (shout: Shout) => {
@ -26,26 +23,33 @@ export const DraftsView = (props: { drafts: Shout[] }) => {
setTimeout(() => navigate('/feed'), 2000) setTimeout(() => navigate('/feed'), 2000)
} }
const { t } = useLocalize()
return ( return (
<div class={clsx(styles.DraftsView)}> <div class={clsx(styles.DraftsView)}>
<Show when={authorized()} fallback={<Loading />}> <div class="wide-container">
<div class="wide-container"> <div class="row offset-md-5">
<div class="row"> <h2>{t('Drafts')}</h2>
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
<For each={drafts()}>
{(draft) => (
<Draft
class={styles.draft}
shout={draft}
onDelete={handleDraftDelete}
onPublish={handleDraftPublish}
/>
)}
</For>
</div>
</div>
</div> </div>
</Show> <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={ddd()}>
{(draft) => (
<Draft
class={styles.draft}
shout={draft}
onDelete={handleDraftDelete}
onPublish={handleDraftPublish}
/>
)}
</For>
</div>
</div>
)}
</Show>
</div>
</div> </div>
) )
} }

View File

@ -1,20 +1,18 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import deepEqual from 'fast-deep-equal' 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 { createStore } from 'solid-js/store'
import { debounce } from 'throttle-debounce' import { debounce } from 'throttle-debounce'
import { Panel } from '~/components/Editor/Panel/Panel'
import { Icon } from '~/components/_shared/Icon' import { Icon } from '~/components/_shared/Icon'
import { InviteMembers } from '~/components/_shared/InviteMembers' import { InviteMembers } from '~/components/_shared/InviteMembers'
import { coreApiUrl } from '~/config'
import { ShoutForm, useEditorContext } from '~/context/editor' import { ShoutForm, useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
import { graphqlClientCreate } from '~/graphql/client'
import getMyShoutQuery from '~/graphql/query/core/article-my' import getMyShoutQuery from '~/graphql/query/core/article-my'
import type { Shout, Topic } from '~/graphql/schema/core.gen' import type { Shout, Topic } from '~/graphql/schema/core.gen'
import { isDesktop } from '~/lib/mediaQuery' import { isDesktop } from '~/lib/mediaQuery'
import { clone } from '~/utils/clone' import { clone } from '~/utils/clone'
import { Panel } from '../../Editor'
import { AutoSaveNotice } from '../../Editor/AutoSaveNotice' import { AutoSaveNotice } from '../../Editor/AutoSaveNotice'
import { Modal } from '../../_shared/Modal' import { Modal } from '../../_shared/Modal'
import { TableOfContents } from '../../_shared/TableOfContents' import { TableOfContents } from '../../_shared/TableOfContents'
@ -44,8 +42,7 @@ const handleScrollTopButtonClick = (ev: MouseEvent | TouchEvent) => {
export const EditSettingsView = (props: Props) => { export const EditSettingsView = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const [isScrolled, setIsScrolled] = createSignal(false) const [isScrolled, setIsScrolled] = createSignal(false)
const { session } = useSession() const { client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const { form, setForm, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage } = useEditorContext() const { form, setForm, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage } = useEditorContext()
const [shoutTopics, setShoutTopics] = createSignal<Topic[]>([]) const [shoutTopics, setShoutTopics] = createSignal<Topic[]>([])
const [draft, setDraft] = createSignal() const [draft, setDraft] = createSignal()

View File

@ -1,29 +1,19 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import deepEqual from 'fast-deep-equal' import deepEqual from 'fast-deep-equal'
import { import { Show, createEffect, createSignal, lazy, on, onCleanup, onMount } from 'solid-js'
Accessor,
Show,
createEffect,
createMemo,
createSignal,
lazy,
on,
onCleanup,
onMount
} from 'solid-js'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { debounce } from 'throttle-debounce' 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 { DropArea } from '~/components/_shared/DropArea'
import { Icon } from '~/components/_shared/Icon' import { Icon } from '~/components/_shared/Icon'
import { InviteMembers } from '~/components/_shared/InviteMembers' import { InviteMembers } from '~/components/_shared/InviteMembers'
import { Loading } from '~/components/_shared/Loading' import { Loading } from '~/components/_shared/Loading'
import { Popover } from '~/components/_shared/Popover' import { Popover } from '~/components/_shared/Popover'
import { EditorSwiper } from '~/components/_shared/SolidSwiper' import { EditorSwiper } from '~/components/_shared/SolidSwiper'
import { coreApiUrl } from '~/config'
import { ShoutForm, useEditorContext } from '~/context/editor' import { ShoutForm, useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
import { graphqlClientCreate } from '~/graphql/client'
import getMyShoutQuery from '~/graphql/query/core/article-my' import getMyShoutQuery from '~/graphql/query/core/article-my'
import type { Shout, Topic } from '~/graphql/schema/core.gen' import type { Shout, Topic } from '~/graphql/schema/core.gen'
import { slugify } from '~/intl/translit' import { slugify } from '~/intl/translit'
@ -32,15 +22,14 @@ import { isDesktop } from '~/lib/mediaQuery'
import { LayoutType } from '~/types/common' import { LayoutType } from '~/types/common'
import { MediaItem } from '~/types/mediaitem' import { MediaItem } from '~/types/mediaitem'
import { clone } from '~/utils/clone' import { clone } from '~/utils/clone'
import { Editor as EditorComponent, Panel } from '../../Editor'
import { AudioUploader } from '../../Editor/AudioUploader'
import { AutoSaveNotice } from '../../Editor/AutoSaveNotice' 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 { Modal } from '../../_shared/Modal'
import { TableOfContents } from '../../_shared/TableOfContents' import { TableOfContents } from '../../_shared/TableOfContents'
import styles from './EditView.module.scss' 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')) const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
type Props = { type Props = {
@ -65,10 +54,7 @@ const handleScrollTopButtonClick = (ev: MouseEvent | TouchEvent) => {
export const EditView = (props: Props) => { export const EditView = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const [isScrolled, setIsScrolled] = createSignal(false) const { client } = useSession()
const { session } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const { const {
form, form,
formErrors, formErrors,
@ -78,14 +64,18 @@ export const EditView = (props: Props) => {
saveDraftToLocalStorage, saveDraftToLocalStorage,
getDraftFromLocalStorage getDraftFromLocalStorage
} = useEditorContext() } = useEditorContext()
const [shoutTopics, setShoutTopics] = createSignal<Topic[]>([])
const [draft, setDraft] = createSignal() const [subtitleInput, setSubtitleInput] = createSignal<HTMLTextAreaElement | undefined>()
let subtitleInput: HTMLTextAreaElement | null
const [prevForm, setPrevForm] = createStore<ShoutForm>(clone(form)) const [prevForm, setPrevForm] = createStore<ShoutForm>(clone(form))
const [saving, setSaving] = createSignal(false) const [saving, setSaving] = createSignal(false)
const [isSubtitleVisible, setIsSubtitleVisible] = createSignal(Boolean(form.subtitle)) const [isSubtitleVisible, setIsSubtitleVisible] = createSignal(Boolean(form.subtitle))
const [isLeadVisible, setIsLeadVisible] = createSignal(Boolean(form.lead)) 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( createEffect(
on( on(
@ -97,7 +87,7 @@ export const EditView = (props: Props) => {
const stored = getDraftFromLocalStorage(shout.id) const stored = getDraftFromLocalStorage(shout.id)
if (stored) { if (stored) {
// console.info(`[EditView] got stored shout: ${stored}`) // console.info(`[EditView] got stored shout: ${stored}`)
setDraft(stored) setDraft((old) => ({ ...old, ...stored }) as Shout)
} else { } else {
if (!shout.slug) { if (!shout.slug) {
console.warn(`[EditView] shout has no slug! ${shout}`) console.warn(`[EditView] shout has no slug! ${shout}`)
@ -131,7 +121,7 @@ export const EditView = (props: Props) => {
(d) => { (d) => {
if (d) { if (d) {
const draftForm = Object.keys(d) ? d : { shoutId: props.shout.id } const draftForm = Object.keys(d) ? d : { shoutId: props.shout.id }
setForm(draftForm) setForm(draftForm as ShoutForm)
console.debug('draft from localstorage: ', draftForm) console.debug('draft from localstorage: ', draftForm)
} }
}, },
@ -267,7 +257,7 @@ export const EditView = (props: Props) => {
const showSubtitleInput = () => { const showSubtitleInput = () => {
setIsSubtitleVisible(true) setIsSubtitleVisible(true)
subtitleInput?.focus() subtitleInput()?.focus()
} }
const showLeadInput = () => { const showLeadInput = () => {
@ -359,7 +349,7 @@ export const EditView = (props: Props) => {
<Show when={props.shout.layout !== 'audio'}> <Show when={props.shout.layout !== 'audio'}>
<Show when={isSubtitleVisible()}> <Show when={isSubtitleVisible()}>
<GrowingTextarea <GrowingTextarea
textAreaRef={(el) => (subtitleInput = el)} textAreaRef={setSubtitleInput}
allowEnterKey={false} allowEnterKey={false}
value={(value) => handleInputChange('subtitle', value || '')} value={(value) => handleInputChange('subtitle', value || '')}
class={styles.subtitleInput} class={styles.subtitleInput}
@ -369,13 +359,10 @@ export const EditView = (props: Props) => {
/> />
</Show> </Show>
<Show when={isLeadVisible()}> <Show when={isLeadVisible()}>
<SimplifiedEditor <MicroEditor
variant="minimal"
hideToolbar={true}
smallHeight={true}
placeholder={t('A short introduction to keep the reader interested')} placeholder={t('A short introduction to keep the reader interested')}
initialContent={form.lead} content={form.lead}
onChange={(value) => handleInputChange('lead', value)} onChange={(value: string) => handleInputChange('lead', value)}
/> />
</Show> </Show>
</Show> </Show>
@ -455,7 +442,7 @@ export const EditView = (props: Props) => {
</Show> </Show>
</div> </div>
</div> </div>
<Show when={form?.shoutId} fallback={<Loading />}> <Show when={draft()?.id} fallback={<Loading />}>
<EditorComponent <EditorComponent
shoutId={form.shoutId} shoutId={form.shoutId}
initialContent={form.body} initialContent={form.body}

View File

@ -3,12 +3,4 @@
background: #fef2f2; background: #fef2f2;
padding: 0 0 4rem; padding: 0 0 4rem;
min-height: 100vh; min-height: 100vh;
.showMore {
display: flex;
width: 100%;
padding: 4rem 0 2rem;
align-items: center;
justify-content: center;
}
} }

View File

@ -1,19 +1,13 @@
import { A } from '@solidjs/router' import { For, Show, createEffect, createSignal, on } from 'solid-js'
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 { Loading } from '~/components/_shared/Loading' import { Loading } from '~/components/_shared/Loading'
import { ArticleCardSwiper } from '~/components/_shared/SolidSwiper/ArticleCardSwiper' import { ArticleCardSwiper } from '~/components/_shared/SolidSwiper/ArticleCardSwiper'
import { coreApiUrl } from '~/config' import { EXPO_LAYOUTS, SHOUTS_PER_PAGE } from '~/context/feed'
import { EXPO_LAYOUTS, SHOUTS_PER_PAGE, useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session' 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 getRandomTopShoutsQuery from '~/graphql/query/core/articles-load-random-top'
import { LoadShoutsFilters, LoadShoutsOptions, Shout } from '~/graphql/schema/core.gen' import { LoadShoutsOptions, Shout } from '~/graphql/schema/core.gen'
import { LayoutType } from '~/types/common' import { ExpoLayoutType } from '~/types/common'
import { getUnixtime } from '~/utils/date' import { getUnixtime } from '~/utils/date'
import { ArticleCard } from '../../Feed/ArticleCard' import { ArticleCard } from '../../Feed/ArticleCard'
@ -21,39 +15,20 @@ import styles from './Expo.module.scss'
type Props = { type Props = {
shouts: Shout[] shouts: Shout[]
topMonthShouts?: Shout[] layout: ExpoLayoutType
topRatedShouts?: Shout[]
layout?: LayoutType
} }
export const PRERENDERED_ARTICLES_COUNT = 36
const LOAD_MORE_PAGE_SIZE = 12
export const Expo = (props: Props) => { export const Expo = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { session } = useSession() const { client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const [favoriteTopArticles, setFavoriteTopArticles] = createSignal<Shout[]>([]) const [favoriteTopArticles, setFavoriteTopArticles] = createSignal<Shout[]>([])
const [reactedTopMonthArticles, setReactedTopMonthArticles] = 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 loadRandomTopArticles = async () => {
const layouts = props.layout ? [props.layout] : EXPO_LAYOUTS
const options: LoadShoutsOptions = { const options: LoadShoutsOptions = {
filters: { layouts: layouts(), featured: true }, filters: { layouts, featured: true },
limit: 10, limit: 10,
random_limit: 100 random_limit: 100
} }
@ -61,11 +36,13 @@ export const Expo = (props: Props) => {
setFavoriteTopArticles(resp?.data?.load_shouts_random_top || []) setFavoriteTopArticles(resp?.data?.load_shouts_random_top || [])
} }
// Функция загрузки популярных статей за последний месяц
const loadRandomTopMonthArticles = async () => { const loadRandomTopMonthArticles = async () => {
const layouts = props.layout ? [props.layout] : EXPO_LAYOUTS
const now = new Date() const now = new Date()
const after = getUnixtime(new Date(now.setMonth(now.getMonth() - 1))) const after = getUnixtime(new Date(now.setMonth(now.getMonth() - 1)))
const options: LoadShoutsOptions = { const options: LoadShoutsOptions = {
filters: { layouts: layouts(), after, reacted: true }, filters: { layouts, after, reacted: true },
limit: 10, limit: 10,
random_limit: 10 random_limit: 10
} }
@ -73,127 +50,46 @@ export const Expo = (props: Props) => {
setReactedTopMonthArticles(resp?.data?.load_shouts_random_top || []) setReactedTopMonthArticles(resp?.data?.load_shouts_random_top || [])
} }
onMount(() => { // Эффект для загрузки random top при изменении layout
loadRandomTopArticles()
loadRandomTopMonthArticles()
})
createEffect( createEffect(
on( on(
() => props.layout, () => props.layout,
() => { async (_layout?: ExpoLayoutType) => {
setExpoShouts([]) await loadRandomTopArticles()
setFavoriteTopArticles([]) await loadRandomTopMonthArticles()
setReactedTopMonthArticles([])
loadRandomTopArticles()
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 ( return (
<div class={styles.Expo}> <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 />}> <Show when={reactedTopMonthArticles()?.length > 0}>
<LoadMoreWrapper loadFunction={loadMoreFiltered} pageSize={LOAD_MORE_PAGE_SIZE}> <ArticleCardSwiper title={t('Top month')} slides={reactedTopMonthArticles()} />
<ExpoGrid /> </Show>
</LoadMoreWrapper>
<Show when={favoriteTopArticles()?.length > 0}>
<ArticleCardSwiper title={t('Favorite')} slides={favoriteTopArticles()} />
</Show>
</div>
)}
</Show> </Show>
</div> </div>
) )

View 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

View File

@ -7,7 +7,6 @@ import { Icon } from '~/components/_shared/Icon'
import { InviteMembers } from '~/components/_shared/InviteMembers' import { InviteMembers } from '~/components/_shared/InviteMembers'
import { Loading } from '~/components/_shared/Loading' import { Loading } from '~/components/_shared/Loading'
import { ShareModal } from '~/components/_shared/ShareModal' import { ShareModal } from '~/components/_shared/ShareModal'
import { coreApiUrl } from '~/config'
import { useAuthors } from '~/context/authors' import { useAuthors } from '~/context/authors'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useReactions } from '~/context/reactions' import { useReactions } from '~/context/reactions'
@ -15,7 +14,6 @@ import { useSession } from '~/context/session'
import { useTopics } from '~/context/topics' import { useTopics } from '~/context/topics'
import { useUI } from '~/context/ui' import { useUI } from '~/context/ui'
import { loadUnratedShouts } from '~/graphql/api/private' import { loadUnratedShouts } from '~/graphql/api/private'
import { graphqlClientCreate } from '~/graphql/client'
import type { Author, Reaction, Shout } from '~/graphql/schema/core.gen' import type { Author, Reaction, Shout } from '~/graphql/schema/core.gen'
import { FeedSearchParams } from '~/routes/feed/[...order]' import { FeedSearchParams } from '~/routes/feed/[...order]'
import { byCreated } from '~/utils/sort' import { byCreated } from '~/utils/sort'
@ -49,11 +47,10 @@ const PERIODS = {
export const FeedView = (props: FeedProps) => { export const FeedView = (props: FeedProps) => {
const { t } = useLocalize() const { t } = useLocalize()
const loc = useLocation() const loc = useLocation()
const { session } = useSession() const { client, session } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const unrated = createAsync(async () => { const unrated = createAsync(async () => {
if (client) { if (client()) {
const shoutsLoader = loadUnratedShouts(client(), { limit: 5 }) const shoutsLoader = loadUnratedShouts(client(), { limit: 5 })
return await shoutsLoader() return await shoutsLoader()
} }
@ -218,7 +215,7 @@ export const FeedView = (props: FeedProps) => {
<div class={styles.comment}> <div class={styles.comment}>
<div class={clsx('text-truncate', styles.commentBody)}> <div class={clsx('text-truncate', styles.commentBody)}>
<A <A
href={`article/${comment.shout.slug}?commentId=${comment.id}`} href={`/${comment.shout.slug}?commentId=${comment.id}`}
innerHTML={comment.body || ''} innerHTML={comment.body || ''}
/> />
</div> </div>

View File

@ -1,6 +1,6 @@
import { useNavigate } from '@solidjs/router' import { useNavigate } from '@solidjs/router'
import { clsx } from 'clsx' 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 QuotedMessage from '~/components/Inbox/QuotedMessage'
import { Icon } from '~/components/_shared/Icon' import { Icon } from '~/components/_shared/Icon'
import { InviteMembers } from '~/components/_shared/InviteMembers' import { InviteMembers } from '~/components/_shared/InviteMembers'
@ -17,7 +17,6 @@ import type {
} from '~/graphql/schema/chat.gen' } from '~/graphql/schema/chat.gen'
import type { Author } from '~/graphql/schema/core.gen' import type { Author } from '~/graphql/schema/core.gen'
import { getShortDate } from '~/utils/date' import { getShortDate } from '~/utils/date'
import SimplifiedEditor from '../../Editor/SimplifiedEditor'
import DialogCard from '../../Inbox/DialogCard' import DialogCard from '../../Inbox/DialogCard'
import DialogHeader from '../../Inbox/DialogHeader' import DialogHeader from '../../Inbox/DialogHeader'
import { Message } from '../../Inbox/Message' import { Message } from '../../Inbox/Message'
@ -26,6 +25,8 @@ import Search from '../../Inbox/Search'
import { Modal } from '../../_shared/Modal' import { Modal } from '../../_shared/Modal'
import styles from './Inbox.module.scss' import styles from './Inbox.module.scss'
const MiniEditor = lazy(() => import('../../Editor/MiniEditor/MiniEditor'))
const userSearch = (array: Author[], keyword: string) => { const userSearch = (array: Author[], keyword: string) => {
return array.filter((value) => new RegExp(keyword.trim(), 'gi').test(value.name || '')) 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 [sortByPerToPer, setSortByPerToPer] = createSignal(false)
const [currentDialog, setCurrentDialog] = createSignal<Chat>() const [currentDialog, setCurrentDialog] = createSignal<Chat>()
const [messageToReply, setMessageToReply] = createSignal<MessageType | null>(null) const [messageToReply, setMessageToReply] = createSignal<MessageType | null>(null)
const [isClear, setClear] = createSignal(false)
const [isScrollToNewVisible, setIsScrollToNewVisible] = createSignal(false) const [isScrollToNewVisible, setIsScrollToNewVisible] = createSignal(false)
const { session } = useSession() const { session } = useSession()
const authorId = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0) 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, reply_to: messageToReply()?.id,
chat_id: currentDialog()?.id || '' chat_id: currentDialog()?.id || ''
} as MutationCreate_MessageArgs) } as MutationCreate_MessageArgs)
setClear(true)
setMessageToReply(null) setMessageToReply(null)
if (messagesContainerRef) if (messagesContainerRef)
(messagesContainerRef as HTMLDivElement).scrollTop = messagesContainerRef?.scrollHeight || 0 (messagesContainerRef as HTMLDivElement).scrollTop = messagesContainerRef?.scrollHeight || 0
setClear(false)
} }
createEffect( createEffect(
@ -291,15 +289,7 @@ export const InboxView = (props: { authors: Author[]; chat?: Chat }) => {
/> />
</Show> </Show>
<div class={styles.wrapper}> <div class={styles.wrapper}>
<SimplifiedEditor <MiniEditor placeholder={t('New message')} onSubmit={handleSubmit} />
smallHeight={true}
imageEnabled={true}
isCancelButtonVisible={false}
placeholder={t('New message')}
setClear={isClear()}
onSubmit={(message) => handleSubmit(message)}
submitByCtrlEnter={true}
/>
</div> </div>
</div> </div>
</Show> </Show>

View File

@ -14,7 +14,6 @@ import {
onMount onMount
} from 'solid-js' } from 'solid-js'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import SimplifiedEditor from '~/components/Editor/SimplifiedEditor'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useProfile } from '~/context/profile' import { useProfile } from '~/context/profile'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
@ -35,7 +34,7 @@ import { SocialNetworkInput } from '../../_shared/SocialNetworkInput'
import styles from './Settings.module.scss' import styles from './Settings.module.scss'
import { profileSocialLinks } from './profileSocialLinks' import { profileSocialLinks } from './profileSocialLinks'
// const SimplifiedEditor = lazy(() => import('~/components/Editor/SimplifiedEditor')) const MicroEditor = lazy(() => import('../../Editor/MicroEditor/MicroEditor'))
const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea')) const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
function filterNulls(arr: InputMaybe<string>[]): string[] { function filterNulls(arr: InputMaybe<string>[]): string[] {
@ -340,18 +339,7 @@ export const ProfileSettings = () => {
/> />
<h4>{t('About')}</h4> <h4>{t('About')}</h4>
<SimplifiedEditor <MicroEditor content={about() || ''} onChange={setAbout} placeholder={t('About')} />
resetToInitial={true}
noLimits={true}
variant="bordered"
hideToolbar={true}
smallHeight={true}
label={t('About')}
initialContent={about() || ''}
autoFocus={false}
onChange={setAbout}
placeholder={t('About')}
/>
<div class={clsx(styles.multipleControls, 'pretty-form__item')}> <div class={clsx(styles.multipleControls, 'pretty-form__item')}>
<div class={styles.multipleControlsHeader}> <div class={styles.multipleControlsHeader}>
<h4>{t('Social networks')}</h4> <h4>{t('Social networks')}</h4>

View File

@ -12,13 +12,14 @@ import { useTopics } from '~/context/topics'
import { useSnackbar, useUI } from '~/context/ui' import { useSnackbar, useUI } from '~/context/ui'
import { Topic } from '~/graphql/schema/core.gen' import { Topic } from '~/graphql/schema/core.gen'
import { UploadedFile } from '~/types/upload' import { UploadedFile } from '~/types/upload'
import { TopicSelect, UploadModalContent } from '../../Editor'
import { Modal } from '../../_shared/Modal' 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 stylesBeside from '../../Feed/Beside.module.scss'
import styles from './PublishSettings.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 GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
const DESCRIPTION_MAX_LENGTH = 400 const DESCRIPTION_MAX_LENGTH = 400
@ -224,16 +225,10 @@ export const PublishSettings = (props: Props) => {
allowEnterKey={false} allowEnterKey={false}
maxLength={100} maxLength={100}
/> />
<SimplifiedEditor <MicroEditor
variant="bordered"
hideToolbar={true}
smallHeight={true}
placeholder={t('Write a short introduction')} placeholder={t('Write a short introduction')}
label={t('Description')} content={composeDescription()}
initialContent={composeDescription()} onChange={(value?: string) => value && setForm('description', value)}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
onChange={(value: any) => setForm('description', value)}
maxLength={DESCRIPTION_MAX_LENGTH}
/> />
</div> </div>

View File

@ -1,5 +1,5 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createSignal, onMount } from 'solid-js' import { createEffect, createSignal, onMount } from 'solid-js'
import { Icon } from '~/components/_shared/Icon' import { Icon } from '~/components/_shared/Icon'
import { Popover } from '~/components/_shared/Popover' import { Popover } from '~/components/_shared/Popover'
@ -15,20 +15,24 @@ type Props = {
initialValue?: string initialValue?: string
showInput?: boolean showInput?: boolean
placeholder: string placeholder: string
onFocus?: (event: FocusEvent) => void
} }
export const InlineForm = (props: Props) => { export const InlineForm = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const [formValue, setFormValue] = createSignal(props.initialValue || '') const [formValue, setFormValue] = createSignal(props.initialValue || '')
const [formValueError, setFormValueError] = createSignal<string | undefined>() const [formValueError, setFormValueError] = createSignal<string | undefined>()
const [inputRef, setInputRef] = createSignal<HTMLInputElement | undefined>()
let inputRef: HTMLInputElement | undefined
const handleFormInput = (e: { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { const handleFormInput = (e: { currentTarget: HTMLInputElement; target: HTMLInputElement }) => {
const value = (e.currentTarget || e.target).value const value = (e.currentTarget || e.target).value
setFormValueError() setFormValueError()
setFormValue(value) setFormValue(value)
} }
createEffect(() => {
setFormValue(props.initialValue || '')
})
const handleSaveButtonClick = async () => { const handleSaveButtonClick = async () => {
if (props.validate) { if (props.validate) {
const errorMessage = await props.validate(formValue()) const errorMessage = await props.validate(formValue())
@ -56,23 +60,23 @@ export const InlineForm = (props: Props) => {
} }
const handleClear = () => { const handleClear = () => {
props.initialValue ? props.onClear?.() : props.onClose() props.initialValue && props.onClear?.()
props.onClose()
} }
onMount(() => { onMount(() => inputRef()?.focus())
inputRef?.focus()
})
return ( return (
<div class={styles.InlineForm}> <div class={styles.InlineForm}>
<div class={styles.form}> <div class={styles.form}>
<input <input
ref={(el) => (inputRef = el)} ref={setInputRef}
type="text" type="text"
value={props.initialValue ?? ''} value={formValue()}
placeholder={props.placeholder} placeholder={props.placeholder}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onInput={handleFormInput} onInput={handleFormInput}
onFocus={props.onFocus}
/> />
<Popover content={t('Add link')}> <Popover content={t('Add link')}>
{(triggerRef: (el: HTMLElement) => void) => ( {(triggerRef: (el: HTMLElement) => void) => (

View File

@ -33,6 +33,7 @@ export const LoadMoreWrapper = (props: LoadMoreProps) => {
) )
const loadItems = async () => { const loadItems = async () => {
// console.debug('LoadMoreWrapper.loadItems offset:', offset())
setIsLoading(true) setIsLoading(true)
saveScrollPosition() saveScrollPosition()
const newItems = await props.loadFunction(offset()) const newItems = await props.loadFunction(offset())
@ -47,6 +48,7 @@ export const LoadMoreWrapper = (props: LoadMoreProps) => {
) )
setIsLoading(false) setIsLoading(false)
restoreScrollPosition() restoreScrollPosition()
// console.debug('LoadMoreWrapper.loadItems loaded:', newItems.length)
} }
onMount(loadItems) onMount(loadItems)
@ -54,16 +56,18 @@ export const LoadMoreWrapper = (props: LoadMoreProps) => {
return ( return (
<> <>
{props.children} {props.children}
<Show when={isLoadMoreButtonVisible() && !props.hidden}> <div>
<div class="load-more-container"> <Show when={isLoadMoreButtonVisible() && !props.hidden}>
<Button <div class="load-more-container">
onClick={loadItems} <Button
disabled={isLoading()} onClick={loadItems}
value={t('Load more')} disabled={isLoading()}
title={`${items().length} ${t('loaded')}`} value={t('Load more')}
/> title={`${items().length} ${t('loaded')}`}
</div> />
</Show> </div>
</Show>
</div>
</> </>
) )
} }

View File

@ -3,7 +3,6 @@ import { clsx } from 'clsx'
import { For, Show, createEffect, createSignal, lazy, on, onMount } from 'solid-js' import { For, Show, createEffect, createSignal, lazy, on, onMount } from 'solid-js'
import SwiperCore from 'swiper' import SwiperCore from 'swiper'
import { Manipulation, Navigation, Pagination } from 'swiper/modules' import { Manipulation, Navigation, Pagination } from 'swiper/modules'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useSnackbar } from '~/context/ui' import { useSnackbar } from '~/context/ui'
import { composeMediaItems } from '~/lib/composeMediaItems' import { composeMediaItems } from '~/lib/composeMediaItems'
@ -23,7 +22,7 @@ import { MediaItem } from '~/types/mediaitem'
import { UploadedFile } from '~/types/upload' import { UploadedFile } from '~/types/upload'
import styles from './Swiper.module.scss' import styles from './Swiper.module.scss'
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor')) const MicroEditor = lazy(() => import('../../Editor/MicroEditor/MicroEditor'))
type Props = { type Props = {
images: MediaItem[] images: MediaItem[]
@ -316,9 +315,8 @@ export const EditorSwiper = (props: Props) => {
value={props.images[slideIndex()]?.source} value={props.images[slideIndex()]?.source}
onChange={(event) => handleSlideDescriptionChange(slideIndex(), 'source', event.target.value)} onChange={(event) => handleSlideDescriptionChange(slideIndex(), 'source', event.target.value)}
/> />
<SimplifiedEditor <MicroEditor
initialContent={props.images[slideIndex()]?.body} content={props.images[slideIndex()]?.body}
smallHeight={true}
placeholder={t('Enter image description')} placeholder={t('Enter image description')}
onChange={(value) => setSlideBody(value)} onChange={(value) => setSlideBody(value)}
/> />

View File

@ -1,9 +1,14 @@
export const isDev = import.meta.env.MODE === 'development'
export const cdnUrl = 'https://cdn.discours.io' export const cdnUrl = 'https://cdn.discours.io'
export const thumborUrl = import.meta.env.PUBLIC_THUMBOR_URL || 'https://images.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 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 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 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 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

View File

@ -1,17 +1,15 @@
import { useMatch, useNavigate } from '@solidjs/router' import { useMatch, useNavigate } from '@solidjs/router'
import { Editor, EditorOptions } from '@tiptap/core' import { Editor, EditorOptions } from '@tiptap/core'
import type { JSX } from 'solid-js' import type { JSX } from 'solid-js'
import { Accessor, createContext, createMemo, createSignal, useContext } from 'solid-js' import { Accessor, createContext, createSignal, useContext } from 'solid-js'
import { SetStoreFunction, createStore } from 'solid-js/store' import { SetStoreFunction, createStore } from 'solid-js/store'
import { createTiptapEditor } from 'solid-tiptap' import { createTiptapEditor } from 'solid-tiptap'
import { coreApiUrl } from '~/config'
import { useSnackbar } from '~/context/ui' import { useSnackbar } from '~/context/ui'
import deleteShoutQuery from '~/graphql/mutation/core/article-delete' import deleteShoutQuery from '~/graphql/mutation/core/article-delete'
import updateShoutQuery from '~/graphql/mutation/core/article-update' import updateShoutQuery from '~/graphql/mutation/core/article-update'
import { Topic, TopicInput } from '~/graphql/schema/core.gen' import { Topic, TopicInput } from '~/graphql/schema/core.gen'
import { slugify } from '~/intl/translit' import { slugify } from '~/intl/translit'
import { useFeed } from '../context/feed' import { useFeed } from '../context/feed'
import { graphqlClientCreate } from '../graphql/client'
import { useLocalize } from './localize' import { useLocalize } from './localize'
import { useSession } from './session' import { useSession } from './session'
@ -85,8 +83,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
const navigate = useNavigate() const navigate = useNavigate()
const matchEdit = useMatch(() => '/edit') const matchEdit = useMatch(() => '/edit')
const matchEditSettings = useMatch(() => '/editSettings') const matchEditSettings = useMatch(() => '/editSettings')
const { session } = useSession() const { client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const [editor, setEditor] = createSignal<Editor | undefined>() const [editor, setEditor] = createSignal<Editor | undefined>()
const { addFeed } = useFeed() const { addFeed } = useFeed()
const snackbar = useSnackbar() const snackbar = useSnackbar()

View File

@ -1,7 +1,6 @@
import { createLazyMemo } from '@solid-primitives/memo' import { createLazyMemo } from '@solid-primitives/memo'
import { makePersisted } from '@solid-primitives/storage' import { makePersisted } from '@solid-primitives/storage'
import { Accessor, JSX, Setter, createContext, createMemo, createSignal, useContext } from 'solid-js' import { Accessor, JSX, Setter, createContext, createSignal, useContext } from 'solid-js'
import { coreApiUrl } from '~/config'
import { loadFollowedShouts } from '~/graphql/api/private' import { loadFollowedShouts } from '~/graphql/api/private'
import { loadShoutsSearch as fetchShoutsSearch, getShout, loadShouts } from '~/graphql/api/public' import { loadShoutsSearch as fetchShoutsSearch, getShout, loadShouts } from '~/graphql/api/public'
import { import {
@ -11,14 +10,20 @@ import {
Shout, Shout,
Topic Topic
} from '~/graphql/schema/core.gen' } from '~/graphql/schema/core.gen'
import { LayoutType } from '~/types/common' import { ExpoLayoutType } from '~/types/common'
import { graphqlClientCreate } from '../graphql/client'
import { byStat } from '../utils/sort' import { byStat } from '../utils/sort'
import { useSession } from './session' import { useSession } from './session'
export const PRERENDERED_ARTICLES_COUNT = 5 export const PRERENDERED_ARTICLES_COUNT = 5
export const SHOUTS_PER_PAGE = 20 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 = { type FeedContextType = {
sortedFeed: Accessor<Shout[]> sortedFeed: Accessor<Shout[]>
@ -176,8 +181,7 @@ export const FeedProvider = (props: { children: JSX.Element }) => {
addFeed(result) addFeed(result)
return { hasMore, newShouts: result } return { hasMore, newShouts: result }
} }
const { session } = useSession() const { client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
// Load the user's feed based on the provided options and update the articleEntities and sortedFeed state // Load the user's feed based on the provided options and update the articleEntities and sortedFeed state
const loadMyFeed = async ( const loadMyFeed = async (

View File

@ -1,21 +1,10 @@
import { import { Accessor, JSX, createContext, createEffect, createSignal, on, useContext } from 'solid-js'
Accessor,
JSX,
createContext,
createEffect,
createMemo,
createSignal,
on,
useContext
} from 'solid-js'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { coreApiUrl } from '~/config'
import followMutation from '~/graphql/mutation/core/follow' import followMutation from '~/graphql/mutation/core/follow'
import unfollowMutation from '~/graphql/mutation/core/unfollow' import unfollowMutation from '~/graphql/mutation/core/unfollow'
import loadAuthorFollowers from '~/graphql/query/core/author-followers' import loadAuthorFollowers from '~/graphql/query/core/author-followers'
import { Author, Community, FollowingEntity, Topic } from '~/graphql/schema/core.gen' import { Author, Community, FollowingEntity, Topic } from '~/graphql/schema/core.gen'
import { graphqlClientCreate } from '../graphql/client'
import { useSession } from './session' import { useSession } from './session'
export type FollowsFilter = 'all' | 'authors' | 'topics' | 'communities' 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 [loading, setLoading] = createSignal<boolean>(false)
const [followers, setFollowers] = createSignal<Author[]>([] as Author[]) const [followers, setFollowers] = createSignal<Author[]>([] as Author[])
const [follows, setFollows] = createStore<AuthorFollowsResult>(EMPTY_SUBSCRIPTIONS) const [follows, setFollows] = createStore<AuthorFollowsResult>(EMPTY_SUBSCRIPTIONS)
const { session } = useSession() const { session, client } = useSession()
const authorized = createMemo<boolean>(() => Boolean(session()?.access_token))
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const fetchData = async () => { const fetchData = async () => {
setLoading(true) setLoading(true)
@ -96,7 +83,7 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
const [following, setFollowing] = createSignal<FollowingData>(defaultFollowing) const [following, setFollowing] = createSignal<FollowingData>(defaultFollowing)
const follow = async (what: FollowingEntity, slug: string) => { const follow = async (what: FollowingEntity, slug: string) => {
if (!authorized()) return if (!session()?.access_token) return
setFollowing({ slug, type: 'follow' }) setFollowing({ slug, type: 'follow' })
try { try {
const resp = await client()?.mutation(followMutation, { what, slug }).toPromise() 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) => { const unfollow = async (what: FollowingEntity, slug: string) => {
if (!authorized()) return if (!session()?.access_token) return
setFollowing({ slug: slug, type: 'unfollow' }) setFollowing({ slug: slug, type: 'unfollow' })
try { try {
const resp = await client()?.mutation(unfollowMutation, { what, slug }).toPromise() const resp = await client()?.mutation(unfollowMutation, { what, slug }).toPromise()

View File

@ -1,7 +1,5 @@
import type { Accessor, JSX } from 'solid-js' import type { Accessor, JSX } from 'solid-js'
import { createContext, createMemo, createSignal, useContext } from 'solid-js' import { createContext, createSignal, useContext } from 'solid-js'
import { chatApiUrl } from '~/config'
import { graphqlClientCreate } from '~/graphql/client'
import createChatMutation from '~/graphql/mutation/chat/chat-create' import createChatMutation from '~/graphql/mutation/chat/chat-create'
import createMessageMutation from '~/graphql/mutation/chat/chat-message-create' import createMessageMutation from '~/graphql/mutation/chat/chat-message-create'
import loadChatMessagesQuery from '~/graphql/query/chat/chat-messages-load-by' 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 [chats, setChats] = createSignal<Chat[]>([])
const [messages, setMessages] = createSignal<Message[]>([]) const [messages, setMessages] = createSignal<Message[]>([])
const { authorsSorted } = useAuthors() const { authorsSorted } = useAuthors()
const { session } = useSession() const { client } = useSession()
const client = createMemo(() => graphqlClientCreate(chatApiUrl, session()?.access_token))
const handleMessage = (sseMessage: SSEMessage) => { const handleMessage = (sseMessage: SSEMessage) => {
// handling all action types: create update delete join left seen // handling all action types: create update delete join left seen

View File

@ -1,12 +1,9 @@
import { makePersisted } from '@solid-primitives/storage' import { makePersisted } from '@solid-primitives/storage'
import type { Accessor, JSX } from 'solid-js' import type { Accessor, JSX } from 'solid-js'
import { createContext, createMemo, createSignal, onMount, useContext } from 'solid-js' import { createContext, createMemo, createSignal, onMount, useContext } from 'solid-js'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { Portal } from 'solid-js/web' import { Portal } from 'solid-js/web'
import { coreApiUrl } from '~/config'
import { graphqlClientCreate } from '~/graphql/client'
import markSeenMutation from '~/graphql/mutation/notifier/mark-seen' import markSeenMutation from '~/graphql/mutation/notifier/mark-seen'
import markSeenAfterMutation from '~/graphql/mutation/notifier/mark-seen-after' import markSeenAfterMutation from '~/graphql/mutation/notifier/mark-seen-after'
import markSeenThreadMutation from '~/graphql/mutation/notifier/mark-seen-thread' 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 [unreadNotificationsCount, setUnreadNotificationsCount] = createSignal(0)
const [totalNotificationsCount, setTotalNotificationsCount] = createSignal(0) const [totalNotificationsCount, setTotalNotificationsCount] = createSignal(0)
const [notificationEntities, setNotificationEntities] = createStore<Record<string, NotificationGroup>>({}) const [notificationEntities, setNotificationEntities] = createStore<Record<string, NotificationGroup>>({})
const { session } = useSession() const { session, client } = useSession()
const authorized = createMemo<boolean>(() => Boolean(session()?.access_token))
const { addHandler } = useConnect() const { addHandler } = useConnect()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const loadNotificationsGrouped = async (options: QueryLoad_NotificationsArgs) => { const loadNotificationsGrouped = async (options: QueryLoad_NotificationsArgs) => {
if (authorized()) { if (session()?.access_token) {
const resp = await client()?.query(getNotifications, options).toPromise() const resp = await client()?.query(getNotifications, options).toPromise()
const result = resp?.data?.get_notifications const result = resp?.data?.get_notifications
const groups = result?.notifications || [] const groups = result?.notifications || []
@ -87,7 +82,7 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
onMount(() => { onMount(() => {
addHandler((data: SSEMessage) => { addHandler((data: SSEMessage) => {
if (data.entity === 'reaction' && authorized()) { if (data.entity === 'reaction' && session()?.access_token) {
console.info('[context.notifications] event', data) console.info('[context.notifications] event', data)
loadNotificationsGrouped({ loadNotificationsGrouped({
after: after() || now, after: after() || now,
@ -107,14 +102,14 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
} }
const markSeenAll = async () => { const markSeenAll = async () => {
if (authorized()) { if (session()?.access_token) {
const _resp = await client()?.mutation(markSeenAfterMutation, { after: after() }).toPromise() const _resp = await client()?.mutation(markSeenAfterMutation, { after: after() }).toPromise()
await loadNotificationsGrouped({ after: after() || now, limit: loadedNotificationsCount() }) await loadNotificationsGrouped({ after: after() || now, limit: loadedNotificationsCount() })
} }
} }
const markSeen = async (notification_id: number) => { const markSeen = async (notification_id: number) => {
if (authorized()) { if (session()?.access_token) {
await client()?.mutation(markSeenMutation, { notification_id }).toPromise() await client()?.mutation(markSeenMutation, { notification_id }).toPromise()
await loadNotificationsGrouped({ after: after() || now, limit: loadedNotificationsCount() }) await loadNotificationsGrouped({ after: after() || now, limit: loadedNotificationsCount() })
} }

View File

@ -1,20 +1,9 @@
import type { Author, ProfileInput } from '~/graphql/schema/core.gen' import type { Author, ProfileInput } from '~/graphql/schema/core.gen'
import { AuthToken } from '@authorizerdev/authorizer-js' import { AuthToken } from '@authorizerdev/authorizer-js'
import { import { Accessor, JSX, createContext, createEffect, createSignal, on, useContext } from 'solid-js'
Accessor,
JSX,
createContext,
createEffect,
createMemo,
createSignal,
on,
useContext
} from 'solid-js'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { coreApiUrl } from '~/config'
import updateAuthorMuatation from '~/graphql/mutation/core/author-update' import updateAuthorMuatation from '~/graphql/mutation/core/author-update'
import { graphqlClientCreate } from '../graphql/client'
import { useAuthors } from './authors' import { useAuthors } from './authors'
import { useSession } from './session' import { useSession } from './session'
@ -41,8 +30,7 @@ const userpicUrl = (userpic: string) => {
} }
export const ProfileProvider = (props: { children: JSX.Element }) => { export const ProfileProvider = (props: { children: JSX.Element }) => {
const { session } = useSession() const { session, client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const { addAuthor } = useAuthors() const { addAuthor } = useAuthors()
const [form, setForm] = createStore<ProfileInput>({} as ProfileInput) const [form, setForm] = createStore<ProfileInput>({} as ProfileInput)
const [author, setAuthor] = createSignal<Author>({} as Author) const [author, setAuthor] = createSignal<Author>({} as Author)
@ -66,7 +54,7 @@ export const ProfileProvider = (props: { children: JSX.Element }) => {
const submit = async (profile: ProfileInput) => { const submit = async (profile: ProfileInput) => {
const response = await client()?.mutation(updateAuthorMuatation, profile).toPromise() const response = await client()?.mutation(updateAuthorMuatation, profile).toPromise()
if (response.error) { if (response?.error) {
console.error(response.error) console.error(response.error)
throw response.error throw response.error
} }

View File

@ -1,6 +1,5 @@
import type { Accessor, JSX } from 'solid-js' import type { Accessor, JSX } from 'solid-js'
import { createContext, createMemo, createSignal, onCleanup, useContext } from 'solid-js' import { createContext, createSignal, onCleanup, useContext } from 'solid-js'
import { coreApiUrl } from '~/config'
import { loadReactions } from '~/graphql/api/public' import { loadReactions } from '~/graphql/api/public'
import createReactionMutation from '~/graphql/mutation/core/reaction-create' import createReactionMutation from '~/graphql/mutation/core/reaction-create'
import destroyReactionMutation from '~/graphql/mutation/core/reaction-destroy' import destroyReactionMutation from '~/graphql/mutation/core/reaction-destroy'
@ -12,7 +11,6 @@ import {
Reaction, Reaction,
ReactionKind ReactionKind
} from '~/graphql/schema/core.gen' } from '~/graphql/schema/core.gen'
import { graphqlClientCreate } from '../graphql/client'
import { useLocalize } from './localize' import { useLocalize } from './localize'
import { useSession } from './session' import { useSession } from './session'
import { useSnackbar } from './ui' import { useSnackbar } from './ui'
@ -41,8 +39,7 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
const [commentsByAuthor, setCommentsByAuthor] = createSignal<Record<number, Reaction[]>>({}) const [commentsByAuthor, setCommentsByAuthor] = createSignal<Record<number, Reaction[]>>({})
const { t } = useLocalize() const { t } = useLocalize()
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const { session } = useSession() const { client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
const addShoutReactions = (rrr: Reaction[]) => { const addShoutReactions = (rrr: Reaction[]) => {
const newReactionEntities = { ...reactionEntities() } const newReactionEntities = { ...reactionEntities() }

View File

@ -12,6 +12,7 @@ import {
VerifyEmailInput VerifyEmailInput
} from '@authorizerdev/authorizer-js' } from '@authorizerdev/authorizer-js'
import { useSearchParams } from '@solidjs/router' import { useSearchParams } from '@solidjs/router'
import { Client } from '@urql/core'
import type { Accessor, JSX, Resource } from 'solid-js' import type { Accessor, JSX, Resource } from 'solid-js'
import { import {
createContext, createContext,
@ -25,13 +26,14 @@ import {
useContext useContext
} from 'solid-js' } from 'solid-js'
import { type AuthModalSource, useSnackbar, useUI } from '~/context/ui' 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' import { useLocalize } from './localize'
const defaultConfig: ConfigType = { const defaultConfig: ConfigType = {
authorizerURL: authApiUrl.replace('/graphql', ''), authorizerURL: authApiUrl.replace('/graphql', ''),
redirectURL: 'https://testing.discours.io', redirectURL: authorizerRedirectUrl,
clientID: 'b9038a34-ca59-41ae-a105-c7fbea603e24' clientID: authorizerClientId
} }
export type SessionContextType = { export type SessionContextType = {
@ -51,20 +53,22 @@ export type SessionContextType = {
signOut: () => Promise<boolean> signOut: () => Promise<boolean>
oauth: (provider: string) => Promise<void> oauth: (provider: string) => Promise<void>
forgotPassword: (params: ForgotPasswordInput) => Promise<string> forgotPassword: (params: ForgotPasswordInput) => Promise<string>
changePassword: (password: string, token: string) => void changePassword: (password: string, token: string) => Promise<boolean>
confirmEmail: (input: VerifyEmailInput) => Promise<void> confirmEmail: (input: VerifyEmailInput) => Promise<void>
setIsSessionLoaded: (loaded: boolean) => void setIsSessionLoaded: (loaded: boolean) => void
authorizer: () => Authorizer authorizer: () => Authorizer
isRegistered: (email: string) => Promise<string> isRegistered: (email: string) => Promise<string>
resendVerifyEmail: (params: ResendVerifyEmailInput) => Promise<boolean> resendVerifyEmail: (params: ResendVerifyEmailInput) => Promise<boolean>
client: Accessor<Client | undefined>
} }
const noop = () => null const noop = () => null
const metaRes = { const metaRes = {
data: { data: {
meta: { meta: {
version: 'latest', version: 'latest',
client_id: 'b9038a34-ca59-41ae-a105-c7fbea603e24', client_id: authorizerClientId,
is_google_login_enabled: true, is_google_login_enabled: true,
is_facebook_login_enabled: true, is_facebook_login_enabled: true,
is_github_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 const SessionContext = createContext<SessionContextType>({} as SessionContextType)
export function useSession() { export function useSession() {
return useContext(SessionContext) 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: { export const SessionProvider = (props: {
onStateChangeCallback(state: AuthToken): unknown onStateChangeCallback(state: AuthToken): unknown
children: JSX.Element children: JSX.Element
@ -113,45 +126,55 @@ export const SessionProvider = (props: {
const authorizer = createMemo(() => new Authorizer(config())) const authorizer = createMemo(() => new Authorizer(config()))
const [oauthState, setOauthState] = createSignal<string>() const [oauthState, setOauthState] = createSignal<string>()
// load // Session expiration timer
let minuteLater: NodeJS.Timeout | null let minuteLater: ReturnType<typeof setTimeout> | null = null
const [isSessionLoaded, setIsSessionLoaded] = createSignal(false) const [isSessionLoaded, setIsSessionLoaded] = createSignal(false)
const [authError, setAuthError] = createSignal<string>('') const [authError, setAuthError] = createSignal<string>('')
const { showModal } = useUI() const { showModal } = useUI()
// handle auth state callback from outside // Handle auth state callback from outside
onMount(() => { onMount(() => {
const params = searchParams const params = searchParams
if (params?.state) { if (params?.state) {
setOauthState((_s) => params?.state) setOauthState(params.state)
const scope = params?.scope ? params?.scope?.toString().split(' ') : ['openid', 'profile', 'email'] const scope = params.scope ? params.scope.toString().split(' ') : ['openid', 'profile', 'email']
if (scope) console.info(`[context.session] scope: ${scope}`) 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] })) setConfig((c: ConfigType) => ({ ...c, redirectURL: url.split('?')[0] }))
changeSearchParams({ mode: 'confirm-email', m: 'auth' }, { replace: true }) changeSearchParams({ mode: 'confirm-email', m: 'auth' }, { replace: true })
} }
}) })
// handle token confirm // Handle token confirmation
createEffect(() => { createEffect(() => {
const token = searchParams?.token const token = searchParams?.token
const access_token = searchParams?.access_token const access_token = searchParams?.access_token
if (access_token) if (access_token) {
changeSearchParams({ changeSearchParams(
mode: 'confirm-email', {
m: 'auth', mode: 'confirm-email',
access_token m: 'auth',
}) access_token
else if (token) { },
changeSearchParams({ { replace: true }
mode: 'change-password', )
m: 'auth', } else if (token) {
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 () => { const sessionData = async () => {
try { try {
const s: ApiResponse<AuthToken> = await authorizer().getSession() const s: ApiResponse<AuthToken> = await authorizer().getSession()
@ -191,6 +214,10 @@ export const SessionProvider = (props: {
initialValue: {} as AuthToken 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 checkSessionIsExpired = () => {
const expires_at_data = localStorage?.getItem('expires_at') 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(() => { onMount(() => {
setConfig({ setConfig({
...defaultConfig, ...defaultConfig,
@ -221,16 +250,23 @@ export const SessionProvider = (props: {
loadSession() loadSession()
}) })
// callback state updater // Callback state updater
createEffect( createEffect(
on([() => props.onStateChangeCallback, session], ([_, ses]) => { on([() => props.onStateChangeCallback, session], ([_, ses]) => {
ses?.user?.id && props.onStateChangeCallback(ses) if (ses?.user?.id) props.onStateChangeCallback(ses)
}) })
) )
const [authCallback, setAuthCallback] = createSignal<() => void>(noop) 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) => { const requireAuthentication = (callback: () => void, modalSource: AuthModalSource) => {
setAuthCallback((_cb) => callback) setAuthCallback(() => callback)
if (!session()) { if (!session()) {
loadSession() loadSession()
if (!session()) { if (!session()) {
@ -243,23 +279,36 @@ export const SessionProvider = (props: {
const handler = authCallback() const handler = authCallback()
if (handler !== noop) { if (handler !== noop) {
handler() 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 ( const authenticate = async (
authFunction: (data: SignupInput) => Promise<ApiResponse<AuthToken | GenericResponse>>, authFunction: AuthFunctionType,
// biome-ignore lint/suspicious/noExplicitAny: authorizer params: SignupInput | LoginInput | UpdateProfileInput
params: any
) => { ) => {
const resp = await authFunction(params) const resp = await authFunction(params)
console.debug('[context.session] authenticate:', resp) console.debug('[context.session] authenticate:', resp)
if (resp?.data && resp?.errors.length === 0) setSession(resp.data as AuthToken) if (resp?.data && resp?.errors.length === 0) setSession(resp.data as AuthToken)
return { data: resp?.data, errors: resp?.errors } 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 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) console.debug('[context.session] signUp:', resp)
if (resp?.data) { if (resp?.data) {
setSession(resp.data as AuthToken) setSession(resp.data as AuthToken)
@ -268,8 +317,13 @@ export const SessionProvider = (props: {
return false 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 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) console.debug('[context.session] signIn:', resp)
if (resp?.data) { if (resp?.data) {
setSession(resp.data as AuthToken) setSession(resp.data as AuthToken)
@ -280,61 +334,97 @@ export const SessionProvider = (props: {
return false 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) console.debug('[context.session] updateProfile response:', resp)
if (resp?.data) { if (resp?.data) {
// console.debug('[context.session] response data ', resp.data) // Optionally refresh session or user data here
// FIXME: renew updated profile
return true return true
} }
return false 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() const authResult: ApiResponse<GenericResponse> = await authorizer().logout()
// console.debug('[context.session] sign out', authResult)
if (authResult) { if (authResult) {
setSession({} as AuthToken) setSession({} as AuthToken)
setIsSessionLoaded(true) setIsSessionLoaded(true)
showSnackbar({ body: t("You've successfully logged out") }) showSnackbar({ body: t("You've successfully logged out") })
// console.debug(session())
return true return true
} }
return false 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({ const resp = await authorizer().resetPassword({
password, password,
token, token,
confirm_password: password confirm_password: password
}) })
console.debug('[context.session] change password response:', resp) 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) const resp = await authorizer().forgotPassword(params)
console.debug('[context.session] change password response:', resp) console.debug('[context.session] forgot password response:', resp)
return resp?.errors?.pop()?.message || '' 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 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) console.debug('[context.session] resend verify email response:', resp)
if (resp.errors) { if (resp.errors.length > 0) {
resp.errors.forEach((error) => { resp.errors.forEach((error) => {
showSnackbar({ type: 'error', body: error.message }) 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> => { const isRegistered = async (email: string): Promise<string> => {
console.debug('[context.session] calling is_registered for ', email) console.debug('[context.session] calling is_registered for ', email)
try { try {
const response = await authorizer().graphqlQuery({ 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 return response?.data?.is_registered?.message
} catch (error) { } catch (error) {
@ -361,6 +451,16 @@ export const SessionProvider = (props: {
console.warn(error) 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 = { const actions = {
loadSession, loadSession,
requireAuthentication, requireAuthentication,
@ -378,6 +478,7 @@ export const SessionProvider = (props: {
isRegistered isRegistered
} }
const value: SessionContextType = { const value: SessionContextType = {
client,
authError, authError,
config, config,
session, session,

View File

@ -9,3 +9,5 @@ mount(() => <StartClient />, document.getElementById('app') || document.body)
// navigator.serviceWorker.register(`/sw.js`); // navigator.serviceWorker.register(`/sw.js`);
// }); // });
// } // }
export default {}

View File

@ -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 allChar = /[\dA-Za-zА-Яа-я]/
export const notLatin = /[^A-Za-z]/ export const notLatin = /[^A-Za-z]/
export const notRus = /[^ËА-Яа-яё]/ export const notRus = /[^ËА-Яа-яё]/

View File

@ -295,6 +295,7 @@
"New stories and more are waiting for you every day!": "Каждый день вас ждут новые истории и ещё много всего интересного!", "New stories and more are waiting for you every day!": "Каждый день вас ждут новые истории и ещё много всего интересного!",
"Newsletter": "Рассылка", "Newsletter": "Рассылка",
"Night mode": "Ночная тема", "Night mode": "Ночная тема",
"No drafts": "Нет черновиков",
"No notifications yet": "Уведомлений пока нет", "No notifications yet": "Уведомлений пока нет",
"No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться", "No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться",
"not verified": "ещё не подтверждён", "not verified": "ещё не подтверждён",

View File

@ -3,10 +3,22 @@ import { capitalize } from '~/utils/capitalize'
import { cyrillicRegex, findFirstReadableCharIndex, notChar, notLatin, notRus } from './chars' import { cyrillicRegex, findFirstReadableCharIndex, notChar, notLatin, notRus } from './chars'
import { translit } from './translit' 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 => { export const isCyrillic = (s: string): boolean => {
return cyrillicRegex.test(s) 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) => export const translateAuthor = (author: Author, lng: string) =>
lng === 'en' && isCyrillic(author?.name || '') lng === 'en' && isCyrillic(author?.name || '')
? capitalize( ? capitalize(
@ -15,6 +27,15 @@ export const translateAuthor = (author: Author, lng: string) =>
) )
: author.name : 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) => { export const authorLetterReduce = (acc: { [x: string]: Author[] }, author: Author, lng: string) => {
let letter = '' let letter = ''

View File

@ -1,9 +1,20 @@
import { EditorOptions } from '@tiptap/core' 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 Highlight from '@tiptap/extension-highlight'
import HorizontalRule from '@tiptap/extension-horizontal-rule'
import Image from '@tiptap/extension-image' import Image from '@tiptap/extension-image'
import Italic from '@tiptap/extension-italic'
import Link from '@tiptap/extension-link' 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 Underline from '@tiptap/extension-underline'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import ArticleNode from '~/components/Editor/extensions/Article'
import { CustomBlockquote } from '~/components/Editor/extensions/CustomBlockquote' import { CustomBlockquote } from '~/components/Editor/extensions/CustomBlockquote'
import { Figcaption } from '~/components/Editor/extensions/Figcaption' import { Figcaption } from '~/components/Editor/extensions/Figcaption'
import { Figure } from '~/components/Editor/extensions/Figure' 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 // Extend the Figure extension to include Figcaption
export const ImageFigure = Figure.extend({ export const ImageFigure = Figure.extend({
name: 'capturedImage', name: 'capturedImage',
@ -53,50 +73,15 @@ export const custom: EditorOptions['extensions'] = [
] ]
export const extended: EditorOptions['extensions'] = [ export const extended: EditorOptions['extensions'] = [
HorizontalRule.configure({ HTMLAttributes: { class: 'horizontalRule' } }),
Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }),
Dropcursor,
CustomBlockquote,
Span,
ToggleTextWrap,
Footnote, Footnote,
CustomBlockquote Focus,
// TODO: Добавьте другие кастомные расширения здесь 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.')
}
}
*/

View File

@ -1,13 +1,10 @@
import { createAsync } from '@solidjs/router' import { createAsync } from '@solidjs/router'
import { Client } from '@urql/core' import { Client } from '@urql/core'
import { createMemo } from 'solid-js'
import { AuthGuard } from '~/components/AuthGuard' import { AuthGuard } from '~/components/AuthGuard'
import { DraftsView } from '~/components/Views/DraftsView' import { DraftsView } from '~/components/Views/DraftsView'
import { PageLayout } from '~/components/_shared/PageLayout' import { PageLayout } from '~/components/_shared/PageLayout'
import { coreApiUrl } from '~/config'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
import { graphqlClientCreate } from '~/graphql/client'
import getDraftsQuery from '~/graphql/query/core/articles-load-drafts' import getDraftsQuery from '~/graphql/query/core/articles-load-drafts'
import { Shout } from '~/graphql/schema/core.gen' import { Shout } from '~/graphql/schema/core.gen'
@ -19,9 +16,8 @@ const fetchDrafts = async (client: Client) => {
export default () => { export default () => {
const { t } = useLocalize() const { t } = useLocalize()
const { session } = useSession() const { client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token)) const drafts = createAsync(async () => client() && (await fetchDrafts(client() as Client)))
const drafts = createAsync(async () => await fetchDrafts(client()))
return ( return (
<PageLayout title={`${t('Discours')} :: ${t('Drafts')}`}> <PageLayout title={`${t('Discours')} :: ${t('Drafts')}`}>

View File

@ -2,11 +2,9 @@ import { RouteSectionProps, redirect } from '@solidjs/router'
import { createEffect, createMemo, createSignal, lazy, on } from 'solid-js' import { createEffect, createMemo, createSignal, lazy, on } from 'solid-js'
import { AuthGuard } from '~/components/AuthGuard' import { AuthGuard } from '~/components/AuthGuard'
import { PageLayout } from '~/components/_shared/PageLayout' import { PageLayout } from '~/components/_shared/PageLayout'
import { coreApiUrl } from '~/config'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
import { useSnackbar } from '~/context/ui' import { useSnackbar } from '~/context/ui'
import { graphqlClientCreate } from '~/graphql/client'
import getShoutDraft from '~/graphql/query/core/article-my' import getShoutDraft from '~/graphql/query/core/article-my'
import { Shout } from '~/graphql/schema/core.gen' import { Shout } from '~/graphql/schema/core.gen'
import { LayoutType } from '~/types/common' import { LayoutType } from '~/types/common'
@ -15,31 +13,32 @@ const EditView = lazy(() => import('~/components/Views/EditView/EditView'))
export default (props: RouteSectionProps) => { export default (props: RouteSectionProps) => {
const { t } = useLocalize() const { t } = useLocalize()
const { session } = useSession() const { session, client } = useSession()
const snackbar = useSnackbar() 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 [shout, setShout] = createSignal<Shout>()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
createEffect(on(session, (s) => s?.access_token && loadDraft(), { defer: true })) createEffect(
on(
const loadDraft = async () => { session,
const shout_id = Number.parseInt(props.params.id) async (s) => {
const result = await client()?.query(getShoutDraft, { shout_id }).toPromise() if (!s?.access_token) return
if (result) { const shout_id = Number.parseInt(props.params.id)
const { shout: loadedShout, error } = result.data.get_my_shout const result = await client()?.query(getShoutDraft, { shout_id }).toPromise()
if (error) { if (result) {
fail(error) const { shout: loadedShout, error } = result.data.get_my_shout
} else { if (error) {
setShout(loadedShout) 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 title = createMemo(() => {
const layout = (shout()?.layout as LayoutType) || 'article' const layout = (shout()?.layout as LayoutType) || 'article'

View File

@ -1,30 +1,36 @@
import { AuthToken } from '@authorizerdev/authorizer-js'
import { RouteSectionProps } from '@solidjs/router' 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 { AuthGuard } from '~/components/AuthGuard'
import EditSettingsView from '~/components/Views/EditView/EditSettingsView' import EditSettingsView from '~/components/Views/EditView/EditSettingsView'
import { PageLayout } from '~/components/_shared/PageLayout' import { PageLayout } from '~/components/_shared/PageLayout'
import { coreApiUrl } from '~/config'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
import { graphqlClientCreate } from '~/graphql/client'
import getShoutDraft from '~/graphql/query/core/article-my' import getShoutDraft from '~/graphql/query/core/article-my'
import { Shout } from '~/graphql/schema/core.gen' import { Shout } from '~/graphql/schema/core.gen'
export default (props: RouteSectionProps) => { export default (props: RouteSectionProps) => {
const { t } = useLocalize() const { t } = useLocalize()
const { session } = useSession() const { session, client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
createEffect(on(session, (s) => s?.access_token && loadDraft(), { defer: true }))
const [shout, setShout] = createSignal<Shout>() const [shout, setShout] = createSignal<Shout>()
const loadDraft = async () => {
const shout_id = Number.parseInt(props.params.id) createEffect(
const result = await client()?.query(getShoutDraft, { shout_id }).toPromise() on(
if (result) { session,
const { shout: loadedShout, error } = result.data.get_my_shout async (s?: AuthToken) => {
if (error) throw new Error(error) if (!s?.access_token) return
setShout(loadedShout) 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) throw new Error(error)
setShout(loadedShout)
}
},
{}
)
)
return ( return (
<PageLayout title={`${t('Discours')} :: ${t('Publication settings')}`}> <PageLayout title={`${t('Discours')} :: ${t('Publication settings')}`}>
<AuthGuard> <AuthGuard>

View File

@ -1,23 +1,23 @@
import { useNavigate } from '@solidjs/router' import { useNavigate } from '@solidjs/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, createMemo } from 'solid-js' import { For } from 'solid-js'
import { AuthGuard } from '~/components/AuthGuard' import { AuthGuard } from '~/components/AuthGuard'
import { Button } from '~/components/_shared/Button' import { Button } from '~/components/_shared/Button'
import { Icon } from '~/components/_shared/Icon' import { Icon } from '~/components/_shared/Icon'
import { PageLayout } from '~/components/_shared/PageLayout' import { PageLayout } from '~/components/_shared/PageLayout'
import { coreApiUrl } from '~/config' import { useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
import { useSnackbar } from '~/context/ui' import { useSnackbar } from '~/context/ui'
import { graphqlClientCreate } from '~/graphql/client'
import createShoutMutation from '~/graphql/mutation/core/article-create' import createShoutMutation from '~/graphql/mutation/core/article-create'
import styles from '~/styles/Create.module.scss'
import { LayoutType } from '~/types/common' import { LayoutType } from '~/types/common'
import styles from '~/styles/Create.module.scss'
export default () => { export default () => {
const { t } = useLocalize() const { t } = useLocalize()
const { session } = useSession() const { client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token)) const { saveDraftToLocalStorage } = useEditorContext()
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const navigate = useNavigate() const navigate = useNavigate()
@ -29,19 +29,30 @@ export default () => {
if (result) { if (result) {
console.debug(result) console.debug(result)
const { shout, error } = result.data.create_shout const { shout, error } = result.data.create_shout
if (error) if (error) {
showSnackbar({ showSnackbar({
body: `${t('Error')}: ${t(error)}`, body: `${t('Error')}: ${t(error)}`,
type: '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 ( return (
<PageLayout <PageLayout
title={`${t('Discours')} :: ${t('Choose a post type')}`} title={`${t('Discours')} :: ${t('Choose a post type')}`}
key="home" 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> <AuthGuard>
<article class={clsx('wide-container', 'container--static-page', styles.Create)}> <article class={clsx('wide-container', 'container--static-page', styles.Create)}>

View File

@ -1,13 +1,17 @@
import { Params, RouteSectionProps, createAsync } from '@solidjs/router' 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 { TopicsNav } from '~/components/TopicsNav'
import { Expo } from '~/components/Views/Expo' 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 { 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 { useLocalize } from '~/context/localize'
import { loadShouts } from '~/graphql/api/public' import { loadShouts } from '~/graphql/api/public'
import { LoadShoutsOptions, Shout } from '~/graphql/schema/core.gen' import { LoadShoutsFilters, LoadShoutsOptions, Shout } from '~/graphql/schema/core.gen'
import { LayoutType } from '~/types/common' import { ExpoLayoutType } from '~/types/common'
import { restoreScrollPosition, saveScrollPosition } from '~/utils/scroll'
import { byCreated } from '~/utils/sort'
const fetchExpoShouts = async (layouts: string[]) => { const fetchExpoShouts = async (layouts: string[]) => {
const result = await loadShouts({ const result = await loadShouts({
@ -28,39 +32,63 @@ export const route = {
export default (props: RouteSectionProps<Shout[]>) => { export default (props: RouteSectionProps<Shout[]>) => {
const { t } = useLocalize() 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( const shouts = createAsync(
async () => async () =>
props.data || (await fetchExpoShouts(props.params.layout ? [props.params.layout] : EXPO_LAYOUTS)) props.data || (await fetchExpoShouts(props.params.layout ? [props.params.layout] : EXPO_LAYOUTS))
) )
const layout = createMemo(() => props.params.layout)
const title = createMemo(() => { // Функция для загрузки дополнительных шотов
switch (layout()) { const loadMore = async () => {
case 'audio': { saveScrollPosition()
return t('Audio') const limit = SHOUTS_PER_PAGE
} const layouts = props.params.layout ? [props.params.layout] : EXPO_LAYOUTS
case 'video': { const offset = expoFeed()?.length || 0
return t('Video') const filters: LoadShoutsFilters = { layouts, featured: true }
} const options: LoadShoutsOptions = { filters, limit, offset }
case 'image': { const shoutsFetcher = loadShouts(options)
return t('Artworks') const result = await shoutsFetcher()
} setLoadMoreVisible(Boolean(result?.length))
case 'literature': { if (result) {
return t('Literature') setExpoFeed((prev) => Array.from(new Set([...(prev || []), ...result])).sort(byCreated))
}
default: {
return t('Art')
}
} }
}) restoreScrollPosition()
return result as LoadMoreItems
createEffect(on(title, (ttl) => (document.title = ttl), { defer: true })) }
// Эффект для загрузки данных при изменении 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 ( return (
<PageLayout withPadding={true} zeroBottomPadding={true} title={`${t('Discours')} :: ${title()}`}> <PageLayout
withPadding={true}
zeroBottomPadding={true}
title={`${t('Discours')} :: ${getTitle(props.params.layout || '')}`}
>
<TopicsNav /> <TopicsNav />
<Show when={shouts()} keyed> <ExpoNav layout={(props.params.layout || '') as ExpoLayoutType | ''} />
{(sss) => <Expo shouts={sss} layout={layout() as LayoutType} />} <LoadMoreWrapper loadFunction={loadMore} pageSize={SHOUTS_PER_PAGE} hidden={!loadMoreVisible()}>
</Show> <Show when={shouts()} keyed>
{(sss: Shout[]) => <Expo shouts={sss} layout={props.params.layout as ExpoLayoutType} />}
</Show>
</LoadMoreWrapper>
</PageLayout> </PageLayout>
) )
} }

View File

@ -1,11 +1,11 @@
import { RouteSectionProps, useSearchParams } from '@solidjs/router' import { RouteSectionProps, useSearchParams } from '@solidjs/router'
import { createEffect, createMemo } from 'solid-js' import { createEffect, createMemo } from 'solid-js'
import { AUTHORS_PER_PAGE } from '~/components/Views/AllAuthors/AllAuthors' import { AUTHORS_PER_PAGE } from '~/components/Views/AllAuthors/AllAuthors'
import { Feed } from '~/components/Views/Feed' import { Feed } from '~/components/Views/Feed'
import { FeedProps } from '~/components/Views/Feed/Feed' import { FeedProps } from '~/components/Views/Feed/Feed'
import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper' import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper'
import { PageLayout } from '~/components/_shared/PageLayout' import { PageLayout } from '~/components/_shared/PageLayout'
import { coreApiUrl } from '~/config'
import { useFeed } from '~/context/feed' import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { ReactionsProvider } from '~/context/reactions' import { ReactionsProvider } from '~/context/reactions'
@ -17,7 +17,6 @@ import {
loadFollowedShouts, loadFollowedShouts,
loadUnratedShouts loadUnratedShouts
} from '~/graphql/api/private' } from '~/graphql/api/private'
import { graphqlClientCreate } from '~/graphql/client'
import { LoadShoutsOptions, Shout, Topic } from '~/graphql/schema/core.gen' import { LoadShoutsOptions, Shout, Topic } from '~/graphql/schema/core.gen'
import { FromPeriod, getFromDate } from '~/lib/fromPeriod' 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 [searchParams] = useSearchParams<FeedSearchParams>() // ?period=month
const { t } = useLocalize() const { t } = useLocalize()
const { setFeed, feed } = useFeed() const { setFeed, feed } = useFeed()
const { session } = useSession() const { client } = useSession()
const client = createMemo(() => graphqlClientCreate(coreApiUrl, session()?.access_token))
// preload all topics // preload all topics
const { addTopics, sortedTopics } = useTopics() const { addTopics, sortedTopics } = useTopics()

View File

@ -4,7 +4,8 @@ export type RootSearchParams = {
token: string; 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 FollowsFilter = 'all' | 'authors' | 'topics' | 'communities';
export type SortFunction<T> = (a: T, b: T) => number export type SortFunction<T> = (a: T, b: T) => number
export type FilterFunction<T> = (a: T) => boolean export type FilterFunction<T> = (a: T) => boolean

View File

@ -1,23 +1,23 @@
// biome-ignore lint/correctness/noNodejsModules: used during build // biome-ignore lint/correctness/noNodejsModules: used during build
import path from 'node:path' import path from 'node:path'
// import { visualizer } from 'rollup-plugin-visualizer'
import dotenv from 'dotenv'
import { CSSOptions } from 'vite' import { CSSOptions } from 'vite'
import mkcert from 'vite-plugin-mkcert' import mkcert from 'vite-plugin-mkcert'
import { PolyfillOptions, nodePolyfills } from 'vite-plugin-node-polyfills' import { PolyfillOptions, nodePolyfills } from 'vite-plugin-node-polyfills'
import sassDts from 'vite-plugin-sass-dts' import sassDts from 'vite-plugin-sass-dts'
// import { visualizer } from 'rollup-plugin-visualizer'
const isDev = process.env.NODE_ENV !== 'production' // Load environment variables from .env file
console.log(`[vite.config] development mode: ${isDev}`) dotenv.config()
export const isDev = process.env.NODE_ENV !== 'production'
console.log(`[vite.config] ${process.env.NODE_ENV} mode`)
const polyfillOptions = { const polyfillOptions = {
include: ['path', 'stream', 'util'], include: ['path', 'stream', 'util'],
exclude: ['http'], exclude: ['http'],
globals: { globals: { Buffer: true },
Buffer: true overrides: { fs: 'memfs' },
},
overrides: {
fs: 'memfs'
},
protocolImports: true protocolImports: true
} as PolyfillOptions } as PolyfillOptions
@ -45,12 +45,19 @@ export default {
build: { build: {
target: 'esnext', target: 'esnext',
sourcemap: true, sourcemap: true,
minify: 'terser', // explicit terser usage
terserOptions: {
compress: {
drop_console: true // removes console logs in production
}
},
rollupOptions: { rollupOptions: {
// plugins: [visualizer()] // plugins: [visualizer()]
output: { output: {
manualChunks: { manualChunks: {
icons: ['./src/components/_shared/Icon/Icon.tsx'], icons: ['./src/components/_shared/Icon/Icon.tsx'],
session: ['./src/context/session.tsx'], session: ['./src/context/session.tsx'],
localize: ['./src/context/localize.tsx'],
editor: ['./src/context/editor.tsx'], editor: ['./src/context/editor.tsx'],
connect: ['./src/context/connect.tsx'] connect: ['./src/context/connect.tsx']
} }