Merge pull request #466 from Discours/hotfix/following

hotfix following status update
This commit is contained in:
Tony 2024-06-06 17:50:01 +03:00 committed by GitHub
commit ab61c1e35a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
96 changed files with 2382 additions and 3214 deletions

View File

@ -29,6 +29,14 @@ jobs:
- name: Test production build - name: Test production build
run: npm run build run: npm run build
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run e2e
env:
BASE_URL: ${{ github.event.deployment_status.target_url }}
email-templates: email-templates:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Update templates on Mailgun name: Update templates on Mailgun

View File

@ -10,6 +10,9 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
- name: Install dependencies - name: Install dependencies
run: npm i
- name: Install CI checks
run: npm ci run: npm ci
- name: Check types - name: Check types
@ -23,20 +26,3 @@ jobs:
- name: Test production build - name: Test production build
run: npm run build run: npm run build
e2e:
timeout-minutes: 60
runs-on: ubuntu-latest
if: github.event.deployment_status.state == 'success'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
env:
BASE_URL: ${{ github.event.deployment_status.target_url }}

View File

@ -16,15 +16,5 @@ npm run typecheck:watch
fix styles, imports, formatting and autofixable linting errors: fix styles, imports, formatting and autofixable linting errors:
``` ```
npm run fix npm run fix
``` npm run format
## Code generation
generate new SolidJS component:
```
npm run hygen component new NewComponentName
```
generate new SolidJS context:
```
npm run hygen context new NewContextName
``` ```

View File

@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", "$schema": "https://biomejs.dev/schemas/1.7.2/schema.json",
"files": { "files": {
"include": ["*.tsx", "*.ts", "*.js", "*.json"], "include": ["*.tsx", "*.ts", "*.js", "*.json"],
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"] "ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"]

View File

@ -1,18 +0,0 @@
---
to: src/components/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.tsx
---
import { clsx } from 'clsx'
import styles from './<%= h.changeCase.pascal(name) %>.module.scss'
type Props = {
class?: string
}
export const <%= h.changeCase.pascal(name) %> = (props: Props) => {
return (
<div class={clsx(styles.<%= h.changeCase.pascal(name) %>, props.class)}>
<%= h.changeCase.pascal(name) %>
</div>
)
}

View File

@ -1,4 +0,0 @@
---
to: src/components/<%= h.changeCase.pascal(name) %>/index.ts
---
export { <%= h.changeCase.pascal(name) %> } from './<%= h.changeCase.pascal(name) %>'

View File

@ -1,7 +0,0 @@
---
to: src/components/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.module.scss
---
.<%= h.changeCase.pascal(name) %> {
display: block;
}

View File

@ -1,24 +0,0 @@
---
to: src/context/<%= h.changeCase.camel(name) %>.tsx
---
import type { Accessor, JSX } from 'solid-js'
import { createContext, createSignal, useContext } from 'solid-js'
type <%= h.changeCase.pascal(name) %>ContextType = {
}
const <%= h.changeCase.pascal(name) %>Context = createContext<<%= h.changeCase.pascal(name) %>ContextType>()
export function use<%= h.changeCase.pascal(name) %>() {
return useContext(<%= h.changeCase.pascal(name) %>Context)
}
export const <%= h.changeCase.pascal(name) %>Provider = (props: { children: JSX.Element }) => {
const actions = {
}
const value: <%= h.changeCase.pascal(name) %>ContextType = { ...actions }
return <<%= h.changeCase.pascal(name) %>Context.Provider value={value}>{props.children}</<%= h.changeCase.pascal(name) %>Context.Provider>
}

View File

@ -1,5 +0,0 @@
---
message: |
hygen {bold generator new} --name [NAME] --action [ACTION]
hygen {bold generator with-prompt} --name [NAME] --action [ACTION]
---

View File

@ -1,16 +0,0 @@
---
to: gen/<%= name %>/<%= action || 'new' %>/hello.ejs.t
---
---
to: app/hello.js
---
const hello = ```
Hello!
This is your first hygen template.
Learn what it can do here:
https://github.com/jondot/hygen
```
console.log(hello)

View File

@ -1,16 +0,0 @@
---
to: gen/<%= name %>/<%= action || 'new' %>/hello.ejs.t
---
---
to: app/hello.js
---
const hello = ```
Hello!
This is your first prompt based hygen template.
Learn what it can do here:
https://github.com/jondot/hygen
```
console.log(hello)

View File

@ -1,14 +0,0 @@
---
to: gen/<%= name %>/<%= action || 'new' %>/prompt.js
---
// see types of prompts:
// https://github.com/enquirer/enquirer/tree/master/examples
//
module.exports = [
{
type: 'input',
name: 'message',
message: "What's your message?"
}
]

View File

@ -1,4 +0,0 @@
---
setup: <%= name %>
force: true # this is because mostly, people init into existing folders is safe
---

2762
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,10 +10,9 @@
"codegen": "graphql-codegen", "codegen": "graphql-codegen",
"deploy": "graphql-codegen && npm run typecheck && vite build && vercel", "deploy": "graphql-codegen && npm run typecheck && vite build && vercel",
"dev": "vite", "dev": "vite",
"e2e": "npx playwright test --project=chromium", "e2e": "npx playwright test --project=webkit",
"fix": "npm run check:code:fix && stylelint **/*.{scss,css} --fix", "fix": "npm run check:code:fix && stylelint **/*.{scss,css} --fix",
"format": "npx @biomejs/biome format src/. --write", "format": "npx @biomejs/biome format src/. --write",
"hygen": "HYGEN_TMPLS=gen hygen",
"postinstall": "npm run codegen && npx patch-package", "postinstall": "npm run codegen && npx patch-package",
"check:code": "npx @biomejs/biome check src --log-kind=compact --verbose", "check:code": "npx @biomejs/biome check src --log-kind=compact --verbose",
"check:code:fix": "npx @biomejs/biome check . --apply", "check:code:fix": "npx @biomejs/biome check . --apply",
@ -33,8 +32,8 @@
"mailgun.js": "10.1.0" "mailgun.js": "10.1.0"
}, },
"devDependencies": { "devDependencies": {
"@authorizerdev/authorizer-js": "2.0.0", "@authorizerdev/authorizer-js": "^2.0.0",
"@babel/core": "7.23.3", "@babel/core": "^7.24.5",
"@biomejs/biome": "^1.7.2", "@biomejs/biome": "^1.7.2",
"@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/typescript": "^4.0.1", "@graphql-codegen/typescript": "^4.0.1",
@ -45,7 +44,7 @@
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
"@nanostores/router": "0.13.0", "@nanostores/router": "0.13.0",
"@nanostores/solid": "0.4.2", "@nanostores/solid": "0.4.2",
"@playwright/test": "1.41.2", "@playwright/test": "^1.44.0",
"@popperjs/core": "2.11.8", "@popperjs/core": "2.11.8",
"@sentry/browser": "^7.113.0", "@sentry/browser": "^7.113.0",
"@solid-primitives/media": "2.2.3", "@solid-primitives/media": "2.2.3",
@ -55,90 +54,87 @@
"@solid-primitives/storage": "^3.5.0", "@solid-primitives/storage": "^3.5.0",
"@solid-primitives/upload": "0.0.115", "@solid-primitives/upload": "0.0.115",
"@thisbeyond/solid-select": "0.14.0", "@thisbeyond/solid-select": "0.14.0",
"@tiptap/core": "2.2.3", "@tiptap/core": "2.4.0",
"@tiptap/extension-blockquote": "2.2.3", "@tiptap/extension-blockquote": "2.4.0",
"@tiptap/extension-bold": "2.2.3", "@tiptap/extension-bold": "2.4.0",
"@tiptap/extension-bubble-menu": "2.2.3", "@tiptap/extension-bubble-menu": "2.4.0",
"@tiptap/extension-bullet-list": "2.2.3", "@tiptap/extension-bullet-list": "2.4.0",
"@tiptap/extension-character-count": "2.2.3", "@tiptap/extension-character-count": "2.4.0",
"@tiptap/extension-collaboration": "2.2.3", "@tiptap/extension-collaboration": "2.4.0",
"@tiptap/extension-collaboration-cursor": "2.2.3", "@tiptap/extension-collaboration-cursor": "2.4.0",
"@tiptap/extension-document": "2.2.3", "@tiptap/extension-document": "2.4.0",
"@tiptap/extension-dropcursor": "2.2.3", "@tiptap/extension-dropcursor": "2.4.0",
"@tiptap/extension-floating-menu": "2.2.3", "@tiptap/extension-floating-menu": "2.4.0",
"@tiptap/extension-focus": "2.2.3", "@tiptap/extension-focus": "2.4.0",
"@tiptap/extension-gapcursor": "2.2.3", "@tiptap/extension-gapcursor": "2.4.0",
"@tiptap/extension-hard-break": "2.2.3", "@tiptap/extension-hard-break": "2.4.0",
"@tiptap/extension-heading": "2.2.3", "@tiptap/extension-heading": "2.4.0",
"@tiptap/extension-highlight": "2.2.3", "@tiptap/extension-highlight": "2.4.0",
"@tiptap/extension-history": "2.2.3", "@tiptap/extension-history": "2.4.0",
"@tiptap/extension-horizontal-rule": "2.2.3", "@tiptap/extension-horizontal-rule": "2.4.0",
"@tiptap/extension-image": "2.2.3", "@tiptap/extension-image": "2.4.0",
"@tiptap/extension-italic": "2.2.3", "@tiptap/extension-italic": "2.4.0",
"@tiptap/extension-link": "2.2.3", "@tiptap/extension-link": "2.4.0",
"@tiptap/extension-list-item": "2.2.3", "@tiptap/extension-list-item": "2.4.0",
"@tiptap/extension-ordered-list": "2.2.3", "@tiptap/extension-ordered-list": "2.4.0",
"@tiptap/extension-paragraph": "2.2.3", "@tiptap/extension-paragraph": "2.4.0",
"@tiptap/extension-placeholder": "2.2.3", "@tiptap/extension-placeholder": "2.4.0",
"@tiptap/extension-strike": "2.2.3", "@tiptap/extension-strike": "2.4.0",
"@tiptap/extension-text": "2.2.3", "@tiptap/extension-text": "2.4.0",
"@tiptap/extension-underline": "2.2.3", "@tiptap/extension-underline": "2.4.0",
"@tiptap/extension-youtube": "2.2.3", "@tiptap/extension-youtube": "2.4.0",
"@types/js-cookie": "3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@urql/core": "4.2.3", "@urql/core": "4.2.3",
"@urql/devtools": "^2.0.3", "@urql/devtools": "^2.0.3",
"babel-preset-solid": "1.8.4", "babel-preset-solid": "1.8.17",
"bootstrap": "5.3.2", "bootstrap": "5.3.2",
"clsx": "2.0.0", "clsx": "2.0.0",
"cropperjs": "1.6.1", "cropperjs": "1.6.1",
"cross-env": "7.0.3",
"fast-deep-equal": "3.1.3", "fast-deep-equal": "3.1.3",
"ga-gtag": "1.2.0", "ga-gtag": "1.2.0",
"graphql": "16.8.1", "graphql": "16.8.1",
"graphql-tag": "2.12.6", "graphql-tag": "^2.12.6",
"hygen": "6.2.11",
"i18next": "22.4.15", "i18next": "22.4.15",
"i18next-http-backend": "2.2.0", "i18next-http-backend": "2.2.0",
"i18next-icu": "2.3.0", "i18next-icu": "2.3.0",
"intl-messageformat": "10.5.3", "intl-messageformat": "^10.5.14",
"javascript-time-ago": "2.5.9", "javascript-time-ago": "^2.5.10",
"js-cookie": "3.0.5", "js-cookie": "3.0.5",
"lint-staged": "15.1.0", "loglevel": "^1.9.1",
"loglevel": "1.8.1", "loglevel-plugin-prefix": "^0.8.4",
"loglevel-plugin-prefix": "0.8.4", "nanostores": "^0.9.0",
"nanostores": "0.9.5",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"prosemirror-history": "1.3.2", "prosemirror-history": "1.3.2",
"prosemirror-trailing-node": "2.0.7", "prosemirror-trailing-node": "2.0.7",
"prosemirror-view": "1.32.7", "prosemirror-view": "1.32.7",
"rollup": "4.17.2", "rollup": "4.17.2",
"sass": "1.69.5", "sass": "1.77.2",
"solid-js": "1.8.17", "solid-js": "1.8.17",
"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",
"stylelint": "^16.0.0", "stylelint": "^16.5.0",
"stylelint-config-standard-scss": "^13.0.0", "stylelint-config-standard-scss": "^13.1.0",
"stylelint-order": "^6.0.3", "stylelint-order": "^6.0.3",
"stylelint-scss": "^6.1.0", "stylelint-scss": "^6.1.0",
"swiper": "11.0.5", "swiper": "11.0.5",
"throttle-debounce": "5.0.0", "throttle-debounce": "5.0.0",
"typescript": "5.2.2", "typescript": "5.4.5",
"typograf": "7.3.0", "typograf": "7.3.0",
"uniqolor": "1.1.0", "uniqolor": "1.1.0",
"vike": "0.4.148", "vike": "0.4.148",
"vite": "5.2.11", "vite": "5.2.11",
"vite-plugin-mkcert": "^1.17.3", "vite-plugin-mkcert": "^1.17.5",
"vite-plugin-node-polyfills": "0.21.0", "vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-sass-dts": "^1.3.17", "vite-plugin-sass-dts": "^1.3.22",
"vite-plugin-solid": "2.10.1", "vite-plugin-solid": "^2.10.2",
"y-prosemirror": "1.2.2", "y-prosemirror": "1.2.5",
"yjs": "13.6.12" "yjs": "13.6.15"
}, },
"overrides": { "overrides": {
"y-prosemirror": "1.2.2", "y-prosemirror": "1.2.5",
"yjs": "13.6.12" "yjs": "13.6.15"
}, },
"trustedDependencies": ["@biomejs/biome"] "trustedDependencies": ["@biomejs/biome"]
} }

View File

@ -47,7 +47,7 @@ export default defineConfig({
use: { ...devices['Desktop Safari'] }, use: { ...devices['Desktop Safari'] },
}, },
/* Test against mobile viewports. */ /* Test against many viewports.
// { // {
// name: 'Mobile Chrome', // name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] }, // use: { ...devices['Pixel 5'] },
@ -68,10 +68,10 @@ export default defineConfig({
// }, // },
], ],
/* Run your local dev server before starting the tests */ /* Run local dev server before starting the tests */
//webServer: { //webServer: {
// command: 'npm run start', // command: 'npm run dev',
// url: 'http://127.0.0.1:3000', // url: 'https://localhost:3000',
// reuseExistingServer: !process.env.CI, // reuseExistingServer: !process.env.CI,
//}, //},
}) })

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.125 12.75H4.5C4.08854 12.75 3.75 12.4115 3.75 12C3.75 11.5885 4.08854 11.25 4.5 11.25H19.125C19.5365 11.25 19.875 11.5885 19.875 12C19.875 12.4115 19.5365 12.75 19.125 12.75Z" fill="currentColor"/>
<path
d="M14.0678 18.3593C13.8803 18.3593 13.6928 18.2916 13.547 18.151C13.2501 17.8593 13.2397 17.3853 13.5314 17.0885L18.4584 11.9999L13.5314 6.91137C13.2397 6.6145 13.2501 6.14054 13.547 5.84887C13.8439 5.56241 14.3178 5.57283 14.6043 5.8697L20.0366 11.4791C20.3178 11.7707 20.3178 12.2291 20.0366 12.5207L14.6043 18.1301C14.4584 18.2864 14.2657 18.3593 14.0678 18.3593Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 713 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View File

@ -31,16 +31,7 @@ export const AudioPlayer = (props: Props) => {
const [isPlaying, setIsPlaying] = createSignal(false) const [isPlaying, setIsPlaying] = createSignal(false)
const currentTack = createMemo(() => props.media[currentTrackIndex()]) const currentTack = createMemo(() => props.media[currentTrackIndex()])
createEffect(on(currentTrackIndex, () => setCurrentTrackDuration(0), { defer: true }))
createEffect(
on(
() => currentTrackIndex(),
() => {
setCurrentTrackDuration(0)
},
{ defer: true },
),
)
const handlePlayMedia = async (trackIndex: number) => { const handlePlayMedia = async (trackIndex: number) => {
setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex()) setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex())

View File

@ -48,7 +48,7 @@ export const Comment = (props: Props) => {
const canEdit = createMemo( const canEdit = createMemo(
() => () =>
Boolean(author()?.id) && Boolean(author()?.id) &&
(props.comment?.created_by?.slug === author().slug || session()?.user?.roles.includes('editor')), (props.comment?.created_by?.slug === author()?.slug || session()?.user?.roles.includes('editor')),
) )
const body = createMemo(() => (editedBody() ? editedBody().trim() : props.comment.body.trim() || '')) const body = createMemo(() => (editedBody() ? editedBody().trim() : props.comment.body.trim() || ''))

View File

@ -487,7 +487,7 @@ export const FullArticle = (props: Props) => {
<Show when={props.article.stat?.viewed}> <Show when={props.article.stat?.viewed}>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemViews)}> <div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemViews)}>
{t('viewsWithCount', { count: props.article.stat?.viewed })} {t('some views', { count: props.article.stat?.viewed })}
</div> </div>
</Show> </Show>

View File

@ -1,6 +1,6 @@
import { openPage } from '@nanostores/router' import { openPage } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Match, Show, Switch, createEffect, createMemo, createSignal } from 'solid-js' import { Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js'
import { useFollowing } from '../../../context/following' import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
@ -10,17 +10,17 @@ import { Author, FollowingEntity } from '../../../graphql/schema/core.gen'
import { router, useRouter } from '../../../stores/router' import { router, useRouter } from '../../../stores/router'
import { translit } from '../../../utils/ru2en' import { translit } from '../../../utils/ru2en'
import { isCyrillic } from '../../../utils/translate' import { isCyrillic } from '../../../utils/translate'
import { BadgeSubscribeButton } from '../../_shared/BadgeSubscribeButton'
import { Button } from '../../_shared/Button' import { Button } from '../../_shared/Button'
import { CheckButton } from '../../_shared/CheckButton' import { CheckButton } from '../../_shared/CheckButton'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper' import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { FollowingButton } from '../../_shared/FollowingButton'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { Userpic } from '../Userpic' import { Userpic } from '../Userpic'
import styles from './AuthorBadge.module.scss' import styles from './AuthorBadge.module.scss'
type Props = { type Props = {
author: Author author: Author
minimizeSubscribeButton?: boolean minimize?: boolean
showMessageButton?: boolean showMessageButton?: boolean
iconButtons?: boolean iconButtons?: boolean
nameOnly?: boolean nameOnly?: boolean
@ -32,19 +32,21 @@ type Props = {
export const AuthorBadge = (props: Props) => { export const AuthorBadge = (props: Props) => {
const { mediaMatches } = useMediaQuery() const { mediaMatches } = useMediaQuery()
const { author, requireAuthentication } = useSession() const { author, requireAuthentication } = useSession()
const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing() const { follow, unfollow, follows, following } = useFollowing()
const [isMobileView, setIsMobileView] = createSignal(false) const [isMobileView, setIsMobileView] = createSignal(false)
const [isSubscribed, setIsSubscribed] = createSignal<boolean>() const [isFollowed, setIsFollowed] = createSignal<boolean>(
follows?.authors?.some((authorEntity) => authorEntity.id === props.author?.id),
createEffect(() => { )
if (!(subscriptions && props.author)) return createEffect(() => setIsMobileView(!mediaMatches.sm))
const subscribed = subscriptions.authors?.some((authorEntity) => authorEntity.id === props.author?.id) createEffect(
setIsSubscribed(subscribed) on(
}) [() => follows?.authors, () => props.author, following],
([followingAuthors, currentAuthor, _]) => {
createEffect(() => { setIsFollowed(followingAuthors?.some((followedAuthor) => followedAuthor.id === currentAuthor?.id))
setIsMobileView(!mediaMatches.sm) },
}) { defer: true },
),
)
const { changeSearchParams } = useRouter() const { changeSearchParams } = useRouter()
const { t, formatDate, lang } = useLocalize() const { t, formatDate, lang } = useLocalize()
@ -72,11 +74,10 @@ export const AuthorBadge = (props: Props) => {
}) })
const handleFollowClick = () => { const handleFollowClick = () => {
requireAuthentication(() => { requireAuthentication(async () => {
isSubscribed() const handle = isFollowed() ? unfollow : follow
? unfollow(FollowingEntity.Author, props.author.slug) await handle(FollowingEntity.Author, props.author.slug)
: follow(FollowingEntity.Author, props.author.slug) }, 'follow')
}, 'subscribe')
} }
return ( return (
@ -117,13 +118,13 @@ export const AuthorBadge = (props: Props) => {
<Show when={props.author?.stat && !props.subscriptionsMode}> <Show when={props.author?.stat && !props.subscriptionsMode}>
<div class={styles.bio}> <div class={styles.bio}>
<Show when={props.author?.stat.shouts > 0}> <Show when={props.author?.stat.shouts > 0}>
<div>{t('PublicationsWithCount', { count: props.author.stat?.shouts ?? 0 })}</div> <div>{t('some posts', { count: props.author.stat?.shouts ?? 0 })}</div>
</Show> </Show>
<Show when={props.author?.stat.comments > 0}> <Show when={props.author?.stat.comments > 0}>
<div>{t('CommentsWithCount', { count: props.author.stat?.comments ?? 0 })}</div> <div>{t('some comments', { count: props.author.stat?.comments ?? 0 })}</div>
</Show> </Show>
<Show when={props.author?.stat.followers > 0}> <Show when={props.author?.stat.followers > 0}>
<div>{t('FollowersWithCount', { count: props.author.stat?.followers ?? 0 })}</div> <div>{t('some followers', { count: props.author.stat?.followers ?? 0 })}</div>
</Show> </Show>
</div> </div>
</Show> </Show>
@ -132,12 +133,10 @@ export const AuthorBadge = (props: Props) => {
</div> </div>
<Show when={props.author.slug !== author()?.slug && !props.nameOnly}> <Show when={props.author.slug !== author()?.slug && !props.nameOnly}>
<div class={styles.actions}> <div class={styles.actions}>
<BadgeSubscribeButton <FollowingButton
action={() => handleFollowClick()} action={handleFollowClick}
isSubscribed={isSubscribed()} isFollowed={isFollowed()}
actionMessageType={ actionMessageType={following()?.slug === props.author.slug ? following().type : undefined}
subscribeInAction()?.slug === props.author.slug ? subscribeInAction().type : undefined
}
/> />
<Show when={props.showMessageButton}> <Show when={props.showMessageButton}>
<Button <Button

View File

@ -20,7 +20,7 @@
@include font-size(4rem); @include font-size(4rem);
font-weight: 700; font-weight: 700;
margin-bottom: 0.2em; margin-bottom: 1.2rem;
} }
.authorAbout { .authorAbout {
@ -429,64 +429,19 @@
} }
} }
.listWrapper {
max-height: 70vh;
}
.subscribersContainer { .subscribersContainer {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
font-size: 1.4rem; font-size: 1.4rem;
margin-top: 1.5rem; gap: 1rem;
margin-top: 0;
white-space: nowrap;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
justify-content: center; justify-content: center;
} }
} }
.subscribers {
align-items: center;
cursor: pointer;
display: inline-flex;
margin: 0 2% 1rem;
vertical-align: top;
border-bottom: unset !important;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
.subscribersItem {
position: relative;
&:nth-child(1) {
z-index: 2;
}
&:nth-child(2) {
z-index: 1;
}
&:not(:last-child) {
margin-right: -4px;
box-shadow: 0 0 0 1px var(--background-color);
}
}
.subscribersCounter {
font-weight: 500;
margin-left: 1rem;
}
&:hover {
background: none !important;
.subscribersCounter {
background: var(--background-color-invert);
}
}
}
.listWrapper {
max-height: 70vh;
}

View File

@ -8,7 +8,7 @@ import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen' import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen'
import { SubscriptionFilter } from '../../../pages/types' import { FollowsFilter } from '../../../pages/types'
import { router, useRouter } from '../../../stores/router' import { router, useRouter } from '../../../stores/router'
import { isAuthor } from '../../../utils/isAuthor' import { isAuthor } from '../../../utils/isAuthor'
import { translit } from '../../../utils/ru2en' import { translit } from '../../../utils/ru2en'
@ -17,6 +17,7 @@ import { SharePopup, getShareUrl } from '../../Article/SharePopup'
import { Modal } from '../../Nav/Modal' import { Modal } from '../../Nav/Modal'
import { TopicBadge } from '../../Topic/TopicBadge' import { TopicBadge } from '../../Topic/TopicBadge'
import { Button } from '../../_shared/Button' import { Button } from '../../_shared/Button'
import { FollowingCounters } from '../../_shared/FollowingCounters/FollowingCounters'
import { ShowOnlyOnClient } from '../../_shared/ShowOnlyOnClient' import { ShowOnlyOnClient } from '../../_shared/ShowOnlyOnClient'
import { AuthorBadge } from '../AuthorBadge' import { AuthorBadge } from '../AuthorBadge'
import { Userpic } from '../Userpic' import { Userpic } from '../Userpic'
@ -27,25 +28,25 @@ import styles from './AuthorCard.module.scss'
type Props = { type Props = {
author: Author author: Author
followers?: Author[] followers?: Author[]
following?: Array<Author | Topic> flatFollows?: Array<Author | Topic>
} }
export const AuthorCard = (props: Props) => { export const AuthorCard = (props: Props) => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const { author, isSessionLoaded, requireAuthentication } = useSession() const { author, isSessionLoaded, requireAuthentication } = useSession()
const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([]) const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([])
const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all') const [followsFilter, setFollowsFilter] = createSignal<FollowsFilter>('all')
const [isSubscribed, setIsSubscribed] = createSignal<boolean>() const [isFollowed, setIsFollowed] = createSignal<boolean>()
const isProfileOwner = createMemo(() => author()?.slug === props.author.slug) const isProfileOwner = createMemo(() => author()?.slug === props.author.slug)
const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing() const { follow, unfollow, follows, following } = useFollowing()
onMount(() => { onMount(() => {
setAuthorSubs(props.following) setAuthorSubs(props.flatFollows)
}) })
createEffect(() => { createEffect(() => {
if (!(subscriptions && props.author)) return if (!(follows && props.author)) return
const subscribed = subscriptions.authors?.some((authorEntity) => authorEntity.id === props.author?.id) const followed = follows?.authors?.some((authorEntity) => authorEntity.id === props.author?.id)
setIsSubscribed(subscribed) setIsFollowed(followed)
}) })
const name = createMemo(() => { const name = createMemo(() => {
@ -71,33 +72,33 @@ export const AuthorCard = (props: Props) => {
} }
createEffect(() => { createEffect(() => {
if (props.following) { if (props.flatFollows) {
if (subscriptionFilter() === 'authors') { if (followsFilter() === 'authors') {
setAuthorSubs(props.following.filter((s) => 'name' in s)) setAuthorSubs(props.flatFollows.filter((s) => 'name' in s))
} else if (subscriptionFilter() === 'topics') { } else if (followsFilter() === 'topics') {
setAuthorSubs(props.following.filter((s) => 'title' in s)) setAuthorSubs(props.flatFollows.filter((s) => 'title' in s))
} else if (subscriptionFilter() === 'communities') { } else if (followsFilter() === 'communities') {
setAuthorSubs(props.following.filter((s) => 'title' in s)) setAuthorSubs(props.flatFollows.filter((s) => 'title' in s))
} else { } else {
setAuthorSubs(props.following) setAuthorSubs(props.flatFollows)
} }
} }
}) })
const handleFollowClick = () => { const handleFollowClick = () => {
requireAuthentication(() => { requireAuthentication(() => {
isSubscribed() isFollowed()
? unfollow(FollowingEntity.Author, props.author.slug) ? unfollow(FollowingEntity.Author, props.author.slug)
: follow(FollowingEntity.Author, props.author.slug) : follow(FollowingEntity.Author, props.author.slug)
}, 'subscribe') }, 'follow')
} }
const followButtonText = createMemo(() => { const followButtonText = createMemo(() => {
if (subscribeInAction()?.slug === props.author.slug) { if (following()?.slug === props.author.slug) {
return subscribeInAction().type === 'subscribe' ? t('Subscribing...') : t('Unsubscribing...') return following().type === 'follow' ? t('Following...') : t('Unfollowing...')
} }
if (isSubscribed()) { if (isFollowed()) {
return ( return (
<> <>
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span> <span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
@ -108,6 +109,73 @@ export const AuthorCard = (props: Props) => {
return t('Follow') return t('Follow')
}) })
const FollowersModalView = () => (
<>
<h2>{t('Followers')}</h2>
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={props.followers}>{(follower: Author) => <AuthorBadge author={follower} />}</For>
</div>
</div>
</div>
</>
)
const FollowingModalView = () => (
<>
<h2>{t('Subscriptions')}</h2>
<ul class="view-switcher">
<li
class={clsx({
'view-switcher__item--selected': followsFilter() === 'all',
})}
>
<button type="button" onClick={() => setFollowsFilter('all')}>
{t('All')}
</button>
<span class="view-switcher__counter">{props.flatFollows.length}</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': followsFilter() === 'authors',
})}
>
<button type="button" onClick={() => setFollowsFilter('authors')}>
{t('Authors')}
</button>
<span class="view-switcher__counter">{props.flatFollows.filter((s) => 'name' in s).length}</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': followsFilter() === 'topics',
})}
>
<button type="button" onClick={() => setFollowsFilter('topics')}>
{t('Topics')}
</button>
<span class="view-switcher__counter">{props.flatFollows.filter((s) => 'title' in s).length}</span>
</li>
</ul>
<br />
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={authorSubs()}>
{(subscription) =>
isAuthor(subscription) ? (
<AuthorBadge author={subscription} subscriptionsMode={true} />
) : (
<TopicBadge topic={subscription} subscriptionsMode={true} />
)
}
</For>
</div>
</div>
</div>
</>
)
return ( return (
<div class={clsx(styles.author, 'row')}> <div class={clsx(styles.author, 'row')}>
<div class="col-md-5"> <div class="col-md-5">
@ -125,59 +193,14 @@ export const AuthorCard = (props: Props) => {
<Show when={props.author.bio}> <Show when={props.author.bio}>
<div class={styles.authorAbout} innerHTML={props.author.bio} /> <div class={styles.authorAbout} innerHTML={props.author.bio} />
</Show> </Show>
<Show when={props.followers?.length > 0 || props.following?.length > 0}> <Show when={props.followers?.length > 0 || props.flatFollows?.length > 0}>
<div class={styles.subscribersContainer}> <div class={styles.subscribersContainer}>
<Show when={props.followers && props.followers.length > 0}> <FollowingCounters
<a href="?m=followers" class={styles.subscribers}> followers={props.followers}
<For each={props.followers.slice(0, 3)}> followersAmount={props.author?.stat?.followers}
{(f) => ( following={props.flatFollows}
<Userpic size={'XS'} name={f.name} userpic={f.pic} class={styles.subscribersItem} /> followingAmount={props.flatFollows.length}
)}
</For>
<div class={styles.subscribersCounter}>
{t('SubscriberWithCount', {
count: props.followers.length ?? 0,
})}
</div>
</a>
</Show>
<Show when={props.following && props.following.length > 0}>
<a href="?m=following" class={styles.subscribers}>
<For each={props.following.slice(0, 3)}>
{(f) => {
if ('name' in f) {
return (
<Userpic
size={'XS'}
name={f.name}
userpic={f.pic}
class={styles.subscribersItem}
/> />
)
}
if ('title' in f) {
return (
<Userpic
size={'XS'}
name={f.title}
userpic={f.pic}
class={styles.subscribersItem}
/>
)
}
return null
}}
</For>
<div class={styles.subscribersCounter}>
{t('SubscriptionWithCount', {
count: props?.following.length ?? 0,
})}
</div>
</a>
</Show>
</div> </div>
</Show> </Show>
</div> </div>
@ -208,11 +231,11 @@ export const AuthorCard = (props: Props) => {
<Show when={authorSubs()?.length}> <Show when={authorSubs()?.length}>
<Button <Button
onClick={handleFollowClick} onClick={handleFollowClick}
disabled={Boolean(subscribeInAction())} disabled={Boolean(following())}
value={followButtonText()} value={followButtonText()}
isSubscribeButton={true} isSubscribeButton={true}
class={clsx({ class={clsx({
[stylesButton.subscribed]: isSubscribed(), [stylesButton.followed]: isFollowed(),
})} })}
/> />
</Show> </Show>
@ -251,77 +274,12 @@ export const AuthorCard = (props: Props) => {
</ShowOnlyOnClient> </ShowOnlyOnClient>
<Show when={props.followers}> <Show when={props.followers}>
<Modal variant="medium" isResponsive={true} name="followers" maxHeight> <Modal variant="medium" isResponsive={true} name="followers" maxHeight>
<> <FollowersModalView />
<h2>{t('Followers')}</h2>
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={props.followers}>
{(follower: Author) => <AuthorBadge author={follower} />}
</For>
</div>
</div>
</div>
</>
</Modal> </Modal>
</Show> </Show>
<Show when={props.following}> <Show when={props.flatFollows}>
<Modal variant="medium" isResponsive={true} name="following" maxHeight> <Modal variant="medium" isResponsive={true} name="following" maxHeight>
<> <FollowingModalView />
<h2>{t('Subscriptions')}</h2>
<ul class="view-switcher">
<li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'all',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('all')}>
{t('All')}
</button>
<span class="view-switcher__counter">{props.following.length}</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'authors',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('authors')}>
{t('Authors')}
</button>
<span class="view-switcher__counter">
{props.following.filter((s) => 'name' in s).length}
</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'topics',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('topics')}>
{t('Topics')}
</button>
<span class="view-switcher__counter">
{props.following.filter((s) => 'title' in s).length}
</span>
</li>
</ul>
<br />
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={authorSubs()}>
{(subscription) =>
isAuthor(subscription) ? (
<AuthorBadge author={subscription} subscriptionsMode={true} />
) : (
<TopicBadge topic={subscription} subscriptionsMode={true} />
)
}
</For>
</div>
</div>
</div>
</>
</Modal> </Modal>
</Show> </Show>
</div> </div>

View File

@ -3,7 +3,7 @@ import { For, createMemo } from 'solid-js'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { Subscribe } from '../_shared/Subscribe' import { Newsletter } from '../_shared/Newsletter'
import styles from './Footer.module.scss' import styles from './Footer.module.scss'
@ -133,7 +133,7 @@ export const Footer = () => {
<div class="col-md-6"> <div class="col-md-6">
<h5>{t('Subscription')}</h5> <h5>{t('Subscription')}</h5>
<p>{t('Join our maillist')}</p> <p>{t('Join our maillist')}</p>
<Subscribe /> <Newsletter />
</div> </div>
</div> </div>

View File

@ -34,6 +34,7 @@ export const AudioUploader = (props: Props) => {
const handleChangeIndex = (direction: 'up' | 'down', index: number) => { const handleChangeIndex = (direction: 'up' | 'down', index: number) => {
const media = [...props.audio] const media = [...props.audio]
if (media?.length > 0) {
if (direction === 'up' && index > 0) { if (direction === 'up' && index > 0) {
const copy = media.splice(index, 1)[0] const copy = media.splice(index, 1)[0]
media.splice(index - 1, 0, copy) media.splice(index - 1, 0, copy)
@ -41,6 +42,7 @@ export const AudioUploader = (props: Props) => {
const copy = media.splice(index, 1)[0] const copy = media.splice(index, 1)[0]
media.splice(index + 1, 0, copy) media.splice(index + 1, 0, copy)
} }
}
props.onAudioSorted(media) props.onAudioSorted(media)
} }

View File

@ -12,11 +12,6 @@ declare module '@tiptap/core' {
export default Node.create({ export default Node.create({
name: 'article', name: 'article',
defaultOptions: {
HTMLAttributes: {
'data-type': 'incut',
},
},
group: 'block', group: 'block',
content: 'block+', content: 'block+',
@ -32,6 +27,12 @@ export default Node.create({
return ['article', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] return ['article', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
}, },
addOptions() {
return {
'data-type': 'incut',
}
},
addAttributes() { addAttributes() {
return { return {
'data-float': { 'data-float': {

View File

@ -1,4 +1,4 @@
import { Blockquote } from '@tiptap/extension-blockquote' import { Blockquote, BlockquoteOptions } from '@tiptap/extension-blockquote'
export type QuoteTypes = 'quote' | 'punchline' export type QuoteTypes = 'quote' | 'punchline'
@ -13,11 +13,13 @@ declare module '@tiptap/core' {
export const CustomBlockquote = Blockquote.extend({ export const CustomBlockquote = Blockquote.extend({
name: 'blockquote', name: 'blockquote',
defaultOptions: {
HTMLAttributes: {},
},
group: 'block', group: 'block',
content: 'block+', content: 'block+',
addOptions(): BlockquoteOptions {
return {} as BlockquoteOptions
},
addAttributes() { addAttributes() {
return { return {
'data-float': { 'data-float': {
@ -34,14 +36,12 @@ export const CustomBlockquote = Blockquote.extend({
return { return {
toggleBlockquote: toggleBlockquote:
(type) => (type) =>
({ commands }) => { ({ commands }) =>
return commands.toggleWrap(this.name, { 'data-type': type }) commands.toggleWrap(this.name, { 'data-type': type }),
},
setBlockQuoteFloat: setBlockQuoteFloat:
(value) => (value) =>
({ commands }) => { ({ commands }) =>
return commands.updateAttributes(this.name, { 'data-float': value }) commands.updateAttributes(this.name, { 'data-float': value }),
},
} }
}, },
}) })

View File

@ -0,0 +1,268 @@
.placeholder {
border-radius: 2.2rem;
display: flex;
font-size: 1.4rem;
font-weight: 500;
overflow: hidden;
position: relative;
h3 {
font-size: 2.4rem;
}
button,
.button {
align-items: center;
border-radius: 1.2rem;
display: flex;
@include font-size(1.5rem);
gap: 0.6rem;
justify-content: center;
margin-top: 3rem;
padding: 1rem 2rem;
width: 100%;
.icon {
height: 2.4rem;
width: 2.4rem;
}
}
}
.placeholder--feed-mode {
flex-direction: column;
min-height: 40rem;
text-align: center;
@include media-breakpoint-up(lg) {
aspect-ratio: 1 / 0.8;
}
.placeholderCover {
flex: 1 100%;
position: relative;
&::after {
bottom: 0;
content: '';
height: 20%;
left: 0;
position: absolute;
width: 100%;
}
img {
position: absolute;
}
}
&.placeholder--feedMy .placeholderCover::after {
background: linear-gradient(to top, #171032, rgb(23 16 50 / 0%));
}
&.placeholder--feedCollaborations .placeholderCover::after {
background: linear-gradient(to top, #070709, rgb(7 7 9 / 0%));
}
}
.placeholder--profile-mode {
min-height: 40rem;
@include media-breakpoint-down(lg) {
display: block;
}
@include media-breakpoint-up(lg) {
max-height: 30rem;
min-height: auto;
}
.placeholderCover {
flex: 1;
padding: 1.6rem;
@include media-breakpoint-up(lg) {
order: 2;
position: static;
}
img {
aspect-ratio: 16/10;
min-width: 40rem;
object-fit: contain;
width: 100%;
@include media-breakpoint-up(lg) {
object-position: right;
}
}
}
.placeholderContent {
display: flex;
flex-direction: column;
justify-content: space-between;
font-size: 1.4rem;
line-height: 1.2;
min-width: 60%;
padding: 0 2rem 2rem;
@include media-breakpoint-up(md) {
font-size: 1.6rem;
padding: 3rem;
}
@include media-breakpoint-up(lg) {
font-size: 2rem;
}
}
h3 {
@include font-size(3.8rem);
}
.button {
background: var(--background-color-invert);
bottom: 2rem;
color: var(--default-color-invert);
font-size: 1.6rem;
left: 2rem;
right: 2rem;
width: 100%;
@include media-breakpoint-up(lg) {
left: auto;
position: absolute;
width: auto;
}
.icon {
filter: invert(1);
}
}
}
.placeholderCover {
position: relative;
img {
left: 0;
height: 100%;
object-fit: cover;
width: 100%;
}
}
.placeholderContent {
padding: 1.6rem;
@include media-breakpoint-down(lg) {
br {
display: none;
}
}
}
.placeholder--feedMy,
.placeholder--feedCollaborations {
color: var(--default-color-invert);
button,
.button {
background: var(--background-color);
color: var(--default-color);
}
}
.placeholder--feedMy {
background: #171032;
.placeholderCover {
img {
object-position: top;
}
}
}
.placeholder--feedCollaborations {
background: #070709;
.placeholderCover {
img {
object-position: bottom;
}
}
}
.placeholder--feedDiscussions {
background: #E9E9EE;
.placeholderCover {
padding: 2rem;
text-align: center;
img {
height: 90%;
mix-blend-mode: multiply;
object-fit: contain;
top: 10%;
}
}
button,
.button {
background: var(--background-color-invert);
color: var(--default-color-invert);
}
}
.placeholder--author {
background: #E58B72;
}
.placeholder--authorComments {
background: #E9E9EE;
.placeholderCover {
img {
mix-blend-mode: multiply;
}
}
}
.bottomLinks {
display: flex;
@include font-size(1.6rem);
gap: 4rem;
@include media-breakpoint-down(sm) {
flex-direction: column;
gap: 1.4rem;
}
a {
border: none !important;
padding-left: 2.6rem;
position: relative;
&:hover {
.icon {
filter: invert(0);
}
}
}
.icon {
filter: invert(1);
height: 1.8rem;
left: 0;
position: absolute;
transition: filter 0.2s;
width: 1.8rem;
}
}

View File

@ -0,0 +1,120 @@
import { clsx } from 'clsx'
import { For, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
import { Icon } from '../../_shared/Icon'
import styles from './Placeholder.module.scss'
export type PlaceholderProps = {
type: string
mode: 'feed' | 'profile'
}
export const Placeholder = (props: PlaceholderProps) => {
const { t } = useLocalize()
const { author } = useSession()
const data = {
feedMy: {
image: 'placeholder-feed.webp',
header: t('Feed settings'),
text: t('Placeholder feed'),
buttonLabel: author() ? t('Popular authors') : t('Create own feed'),
href: '/authors?by=followers',
},
feedCollaborations: {
image: 'placeholder-experts.webp',
header: t('Find collaborators'),
text: t('Placeholder feedCollaborations'),
buttonLabel: t('Find co-authors'),
href: '/authors?by=name',
},
feedDiscussions: {
image: 'placeholder-discussions.webp',
header: t('Participate in discussions'),
text: t('Placeholder feedDiscussions'),
buttonLabel: author() ? t('Current discussions') : t('Enter'),
href: '/feed?by=last_comment',
},
author: {
image: 'placeholder-join.webp',
header: t('Join our team of authors'),
text: t('Join our team of authors text'),
buttonLabel: t('Create post'),
href: '/create',
profileLinks: [
{
href: '/how-to-write-a-good-article',
label: t('How to write a good article'),
},
],
},
authorComments: {
image: 'placeholder-discussions.webp',
header: t('Join discussions'),
text: t('Placeholder feedDiscussions'),
buttonLabel: t('Go to discussions'),
href: '/feed?by=last_comment',
profileLinks: [
{
href: '/about/discussion-rules',
label: t('Discussion rules'),
},
{
href: '/about/discussion-rules#ban',
label: t('Block rules'),
},
],
},
}
return (
<div
class={clsx(
styles.placeholder,
styles[`placeholder--${props.type}`],
styles[`placeholder--${props.mode}-mode`],
)}
>
<div class={styles.placeholderCover}>
<img src={`/${data[props.type].image}`} />
</div>
<div class={styles.placeholderContent}>
<div>
<h3 innerHTML={data[props.type].header} />
<p innerHTML={data[props.type].text} />
</div>
<Show when={data[props.type].profileLinks}>
<div class={styles.bottomLinks}>
<For each={data[props.type].profileLinks}>
{(link) => (
<a href={link.href}>
<Icon name="link-white" class={styles.icon} />
{link.label}
</a>
)}
</For>
</div>
</Show>
<Show
when={author()}
fallback={
<a class={styles.button} href="?m=auth&mode=login">
{data[props.type].buttonLabel}
</a>
}
>
<a class={styles.button} href={data[props.type].href}>
{data[props.type].buttonLabel}
<Show when={props.mode === 'profile'}>
<Icon name="arrow-right-2" class={styles.icon} />
</Show>
</a>
</Show>
</div>
</div>
)
}

View File

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

View File

@ -15,7 +15,7 @@ import styles from './Sidebar.module.scss'
export const Sidebar = () => { export const Sidebar = () => {
const { t } = useLocalize() const { t } = useLocalize()
const { seen } = useSeen() const { seen } = useSeen()
const { subscriptions } = useFollowing() const { follows } = useFollowing()
const { page } = useRouter() const { page } = useRouter()
const { articlesByTopic, articlesByAuthor } = useArticlesStore() const { articlesByTopic, articlesByAuthor } = useArticlesStore()
const [isSubscriptionsVisible, setSubscriptionsVisible] = createSignal(true) const [isSubscriptionsVisible, setSubscriptionsVisible] = createSignal(true)
@ -83,35 +83,9 @@ export const Sidebar = () => {
</span> </span>
</a> </a>
</li> </li>
<li>
<a
href={getPagePath(router, 'feedBookmarks')}
class={clsx({
[styles.selected]: page().route === 'feedBookmarks',
})}
>
<span class={styles.sidebarItemName}>
<Icon name="bookmark" class={styles.icon} />
{t('Bookmarks')}
</span>
</a>
</li>
<li>
<a
href={getPagePath(router, 'feedNotifications')}
class={clsx({
[styles.selected]: page().route === 'feedNotifications',
})}
>
<span class={styles.sidebarItemName}>
<Icon name="feed-notifications" class={styles.icon} />
{t('Notifications')}
</span>
</a>
</li>
</ul> </ul>
<Show when={subscriptions.authors.length > 0 || subscriptions.topics.length > 0}> <Show when={follows?.authors?.length > 0 || follows?.topics?.length > 0}>
<h4 <h4
classList={{ [styles.opened]: isSubscriptionsVisible() }} classList={{ [styles.opened]: isSubscriptionsVisible() }}
onClick={() => { onClick={() => {
@ -123,7 +97,7 @@ export const Sidebar = () => {
</h4> </h4>
<ul class={clsx(styles.subscriptions, { [styles.hidden]: !isSubscriptionsVisible() })}> <ul class={clsx(styles.subscriptions, { [styles.hidden]: !isSubscriptionsVisible() })}>
<For each={subscriptions.authors}> <For each={follows.authors}>
{(a: Author) => ( {(a: Author) => (
<li> <li>
<a href={`/author/${a.slug}`} classList={{ [styles.unread]: checkAuthorIsSeen(a.slug) }}> <a href={`/author/${a.slug}`} classList={{ [styles.unread]: checkAuthorIsSeen(a.slug) }}>
@ -135,7 +109,7 @@ export const Sidebar = () => {
</li> </li>
)} )}
</For> </For>
<For each={subscriptions.topics}> <For each={follows.topics}>
{(topic) => ( {(topic) => (
<li> <li>
<a <a

View File

@ -63,18 +63,8 @@ export const PasswordField = (props: Props) => {
} }
} }
createEffect( createEffect(on(error, (er) => er && props.errorMessage?.(er), { defer: true }))
on( createEffect(() => setError(props.setError))
() => error(),
() => {
props.errorMessage?.(error())
},
{ defer: true },
),
)
createEffect(() => {
setError(props.setError)
})
return ( return (
<div class={clsx(styles.PassportField, props.class)}> <div class={clsx(styles.PassportField, props.class)}>

View File

@ -11,7 +11,7 @@ import { useModalStore } from '../../../stores/ui'
import { getDescription } from '../../../utils/meta' import { getDescription } from '../../../utils/meta'
import { SharePopup, getShareUrl } from '../../Article/SharePopup' import { SharePopup, getShareUrl } from '../../Article/SharePopup'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../_shared/Icon'
import { Subscribe } from '../../_shared/Subscribe' import { Newsletter } from '../../_shared/Newsletter'
import { AuthModal } from '../AuthModal' import { AuthModal } from '../AuthModal'
import { ConfirmModal } from '../ConfirmModal' import { ConfirmModal } from '../ConfirmModal'
import { HeaderAuth } from '../HeaderAuth' import { HeaderAuth } from '../HeaderAuth'
@ -301,7 +301,7 @@ export const Header = (props: Props) => {
</ul> </ul>
<h4>{t('Newsletter')}</h4> <h4>{t('Newsletter')}</h4>
<Subscribe variant={'mobileSubscription'} /> <Newsletter variant={'mobileSubscription'} />
<h4>{t('Language')}</h4> <h4>{t('Language')}</h4>
<select <select

View File

@ -21,7 +21,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
<Popup {...props} horizontalAnchor="right" popupCssClass={styles.profilePopup}> <Popup {...props} horizontalAnchor="right" popupCssClass={styles.profilePopup}>
<ul class="nodash"> <ul class="nodash">
<li> <li>
<a class={styles.action} href={getPagePath(router, 'author', { slug: author().slug })}> <a class={styles.action} href={getPagePath(router, 'author', { slug: author()?.slug })}>
<Icon name="profile" class={styles.icon} /> <Icon name="profile" class={styles.icon} />
{t('Profile')} {t('Profile')}
</a> </a>
@ -35,7 +35,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
<li> <li>
<a <a
class={styles.action} class={styles.action}
href={`${getPagePath(router, 'author', { slug: author().slug })}?m=following`} href={`${getPagePath(router, 'author', { slug: author()?.slug })}?m=following`}
> >
<Icon name="feed-all" class={styles.icon} /> <Icon name="feed-all" class={styles.icon} />
{t('Subscriptions')} {t('Subscriptions')}
@ -44,7 +44,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
<li> <li>
<a <a
class={styles.action} class={styles.action}
href={`${getPagePath(router, 'authorComments', { slug: author().slug })}`} href={`${getPagePath(router, 'authorComments', { slug: author()?.slug })}`}
> >
<Icon name="comment" class={styles.icon} /> <Icon name="comment" class={styles.icon} />
{t('Comments')} {t('Comments')}

View File

@ -57,7 +57,7 @@ export const ProfileSettings = () => {
const [nameError, setNameError] = createSignal<string>() const [nameError, setNameError] = createSignal<string>()
const { form, submit, updateFormField, setForm } = useProfileForm() const { form, submit, updateFormField, setForm } = useProfileForm()
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const { loadAuthor, session } = useSession() const { loadSession, session } = useSession()
const { showConfirm } = useConfirm() const { showConfirm } = useConfirm()
const [clearAbout, setClearAbout] = createSignal(false) const [clearAbout, setClearAbout] = createSignal(false)
@ -112,7 +112,7 @@ export const ProfileSettings = () => {
setIsSaving(false) setIsSaving(false)
} }
await loadAuthor() // renews author's profile setTimeout(loadSession, 5000) // renews author's profile
} }
const handleCancel = async () => { const handleCancel = async () => {

View File

@ -62,7 +62,7 @@ export const TableOfContents = (props: Props) => {
createEffect( createEffect(
on( on(
() => props.body, () => props.body,
() => debouncedUpdateHeadings(), (_) => debouncedUpdateHeadings(),
), ),
) )

View File

@ -123,12 +123,12 @@
width: 9em; width: 9em;
} }
.isSubscribing { .isFollowing {
opacity: 0.5; opacity: 0.5;
} }
/* /*
.isSubscribed { .isFollowed {
background: #000; background: #000;
color: #fff; color: #fff;
transition: transition:

View File

@ -1,5 +1,5 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createEffect, createMemo, createSignal } from 'solid-js' import { Show, createEffect, createMemo, createSignal, on } from 'solid-js'
import { useFollowing } from '../../context/following' import { useFollowing } from '../../context/following'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
@ -7,18 +7,16 @@ import { useSession } from '../../context/session'
import { FollowingEntity, type Topic } from '../../graphql/schema/core.gen' import { FollowingEntity, type Topic } from '../../graphql/schema/core.gen'
import { capitalize } from '../../utils/capitalize' import { capitalize } from '../../utils/capitalize'
import { CardTopic } from '../Feed/CardTopic' import { CardTopic } from '../Feed/CardTopic'
import { Button } from '../_shared/Button'
import { CheckButton } from '../_shared/CheckButton' import { CheckButton } from '../_shared/CheckButton'
import { Icon } from '../_shared/Icon' import { FollowingButton } from '../_shared/FollowingButton'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient' import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import stylesButton from '../_shared/Button/Button.module.scss'
import styles from './Card.module.scss' import styles from './Card.module.scss'
interface TopicProps { interface TopicProps {
topic: Topic topic: Topic
compact?: boolean compact?: boolean
subscribed?: boolean followed?: boolean
shortDescription?: boolean shortDescription?: boolean
subscribeButtonBottom?: boolean subscribeButtonBottom?: boolean
additionalClass?: string additionalClass?: string
@ -27,7 +25,7 @@ interface TopicProps {
showPublications?: boolean showPublications?: boolean
showDescription?: boolean showDescription?: boolean
isCardMode?: boolean isCardMode?: boolean
minimizeSubscribeButton?: boolean minimize?: boolean
isNarrow?: boolean isNarrow?: boolean
withIcon?: boolean withIcon?: boolean
} }
@ -38,39 +36,23 @@ export const TopicCard = (props: TopicProps) => {
capitalize(lang() === 'en' ? props.topic.slug.replaceAll('-', ' ') : props.topic.title || ''), capitalize(lang() === 'en' ? props.topic.slug.replaceAll('-', ' ') : props.topic.title || ''),
) )
const { author, requireAuthentication } = useSession() const { author, requireAuthentication } = useSession()
const [isSubscribed, setIsSubscribed] = createSignal() const { follow, unfollow, follows } = useFollowing()
const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing() const [isFollowed, setIsFollowed] = createSignal(false)
createEffect(
createEffect(() => { on([() => follows, () => props.topic], ([flws, tpc]) => {
if (!(subscriptions && props.topic)) return if (flws && tpc) {
const subscribed = subscriptions.topics?.some((topics) => topics.id === props.topic?.id) const followed = follows?.topics?.some((topics) => topics.id === props.topic?.id)
setIsSubscribed(subscribed) setIsFollowed(followed)
}) }
}),
)
const handleFollowClick = () => { const handleFollowClick = () => {
requireAuthentication(() => { requireAuthentication(() => {
isSubscribed() isFollowed()
? unfollow(FollowingEntity.Topic, props.topic.slug) ? unfollow(FollowingEntity.Topic, props.topic.slug)
: follow(FollowingEntity.Topic, props.topic.slug) : follow(FollowingEntity.Topic, props.topic.slug)
}, 'subscribe') }, 'follow')
}
const subscribeValue = () => {
return (
<>
<Show when={props.iconButton}>
<Show when={isSubscribed()} fallback="+">
<Icon name="check-subscribed" />
</Show>
</Show>
<Show when={!props.iconButton}>
<Show when={isSubscribed()} fallback={t('Follow')}>
<span class={stylesButton.buttonSubscribeLabelHovered}>{t('Unfollow')}</span>
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
</Show>
</Show>
</>
)
} }
return ( return (
@ -132,27 +114,12 @@ export const TopicCard = (props: TopicProps) => {
<ShowOnlyOnClient> <ShowOnlyOnClient>
<Show when={author()}> <Show when={author()}>
<Show <Show
when={!props.minimizeSubscribeButton} when={!props.minimize}
fallback={ fallback={
<CheckButton <CheckButton text={t('Follow')} checked={isFollowed()} onClick={handleFollowClick} />
text={t('Follow')}
checked={Boolean(isSubscribed())}
onClick={handleFollowClick}
/>
} }
> >
<Button <FollowingButton action={handleFollowClick} isFollowed={isFollowed()} />
variant="bordered"
size="M"
value={subscribeValue()}
onClick={handleFollowClick}
isSubscribeButton={true}
class={clsx(styles.actionButton, {
[styles.isSubscribing]:
subscribeInAction()?.slug === props.topic.slug ? subscribeInAction().type : undefined,
[stylesButton.subscribed]: isSubscribed(),
})}
/>
</Show> </Show>
</Show> </Show>
</ShowOnlyOnClient> </ShowOnlyOnClient>

View File

@ -1,6 +1,5 @@
.topicHeader { .topicHeader {
@include font-size(1.7rem); font-weight: 500;
padding: 2.8rem $container-padding-x 0; padding: 2.8rem $container-padding-x 0;
text-align: center; text-align: center;
@ -12,10 +11,17 @@
} }
} }
.topicDescription {
@include font-size(1.8rem);
line-height: 1.4;
margin: 1rem 0 2rem;
}
.topicActions { .topicActions {
margin-top: 2.8rem; margin-top: 2.8rem;
.write { .writeControl {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -23,13 +29,38 @@
min-width: 64px; min-width: 64px;
font-size: 17px; font-size: 17px;
padding: 8px 16px; padding: 8px 16px;
background: var(--background-color-invert); border: 1px solid #f7f7f7;
color: var(--default-color-invert); background: #f7f7f7;
border: none; color: var(--default-color);
font-weight: 500; font-weight: 500;
border-radius: 2px;
cursor: pointer; cursor: pointer;
margin: 0 1.2rem 1em; margin: 0 1.2rem 1em;
white-space: nowrap; white-space: nowrap;
} }
.followControl,
.writeControl {
border-radius: 0.8rem;
}
}
.topicDetails {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
font-size: 1.4rem;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.topicDetailsItem {
align-items: center;
display: flex;
margin-right: 1rem;
white-space: nowrap;
}
.topicDetailsIcon {
display: block;
} }

View File

@ -1,4 +1,4 @@
import type { Topic } from '../../graphql/schema/core.gen' import type { Author, Topic } from '../../graphql/schema/core.gen'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createEffect, createSignal } from 'solid-js' import { Show, createEffect, createSignal } from 'solid-js'
@ -9,22 +9,25 @@ import { useSession } from '../../context/session'
import { FollowingEntity } from '../../graphql/schema/core.gen' import { FollowingEntity } from '../../graphql/schema/core.gen'
import { Button } from '../_shared/Button' import { Button } from '../_shared/Button'
import { FollowingCounters } from '../_shared/FollowingCounters/FollowingCounters'
import { Icon } from '../_shared/Icon'
import styles from './Full.module.scss' import styles from './Full.module.scss'
type Props = { type Props = {
topic: Topic topic: Topic
followers?: Author[]
authors?: Author[]
} }
export const FullTopic = (props: Props) => { export const FullTopic = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { subscriptions, setFollowing } = useFollowing() const { follows, changeFollowing } = useFollowing()
const { requireAuthentication } = useSession() const { requireAuthentication } = useSession()
const [followed, setFollowed] = createSignal() const [followed, setFollowed] = createSignal()
createEffect(() => { createEffect(() => {
const subs = subscriptions if (follows?.topics.length !== 0) {
if (subs?.topics.length !== 0) { const items = follows.topics || []
const items = subs.topics || []
setFollowed(items.some((x: Topic) => x?.slug === props.topic?.slug)) setFollowed(items.some((x: Topic) => x?.slug === props.topic?.slug))
} }
}) })
@ -33,26 +36,46 @@ export const FullTopic = (props: Props) => {
const really = !followed() const really = !followed()
setFollowed(really) setFollowed(really)
requireAuthentication(() => { requireAuthentication(() => {
setFollowing(FollowingEntity.Topic, props.topic.slug, really) changeFollowing(FollowingEntity.Topic, props.topic.slug, really)
}, 'follow') }, 'follow')
} }
return ( return (
<div class={clsx(styles.topicHeader, 'col-md-16 col-lg-12 offset-md-4 offset-lg-6')}> <div class={clsx(styles.topicHeader, 'col-md-16 col-lg-12 offset-md-4 offset-lg-6')}>
<h1>#{props.topic?.title}</h1> <h1>#{props.topic?.title}</h1>
<p innerHTML={props.topic?.body} /> <p class={styles.topicDescription} innerHTML={props.topic?.body} />
<div class={styles.topicDetails}>
<Show when={props.topic?.stat}>
<div class={styles.topicDetailsItem}>
<Icon name="feed-all" class={styles.topicDetailsIcon} />
{t('some posts', {
count: props.topic?.stat.shouts ?? 0,
})}
</div>
</Show>
<FollowingCounters
followers={props.followers}
followersAmount={props.topic?.stat?.followers}
authors={props.authors}
authorsAmount={props.topic?.stat?.authors || props.authors?.length || 0}
/>
</div>
<div class={clsx(styles.topicActions)}> <div class={clsx(styles.topicActions)}>
<Button <Button
variant="primary" variant="primary"
onClick={handleFollowClick} onClick={handleFollowClick}
value={followed() ? t('Unfollow the topic') : t('Follow the topic')} value={followed() ? t('Unfollow the topic') : t('Follow the topic')}
class={styles.followControl}
/> />
<a class={styles.write} href={`/create/?topicId=${props.topic?.id}`}> <a class={styles.writeControl} href={`/create/?topicId=${props.topic?.id}`}>
{t('Write about the topic')} {t('Write about the topic')}
</a> </a>
</div> </div>
<Show when={props.topic?.pic}> <Show when={props.topic?.pic}>
<img src={props.topic.pic} alt={props.topic?.title} /> <img src={props.topic?.pic} alt={props.topic?.title} />
</Show> </Show>
</div> </div>
) )

View File

@ -45,6 +45,7 @@
.info { .info {
@include font-size(1.4rem); @include font-size(1.4rem);
border: none; border: none;
// display: flex; // display: flex;
@ -62,11 +63,13 @@
.title { .title {
@include font-size(2.2rem); @include font-size(2.2rem);
font-weight: bold; font-weight: bold;
} }
.description { .description {
@include font-size(1.6rem); @include font-size(1.6rem);
line-height: 1.4; line-height: 1.4;
margin: 0.8rem 0; margin: 0.8rem 0;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
@ -104,6 +107,7 @@
.title { .title {
@include font-size(1.4rem); @include font-size(1.4rem);
font-weight: 500; font-weight: 500;
line-height: 1em; line-height: 1em;
color: var(--blue-500); color: var(--blue-500);
@ -111,8 +115,9 @@
} }
.description { .description {
color: var(--black-400);
@include font-size(1.2rem); @include font-size(1.2rem);
color: var(--black-400);
font-weight: 500; font-weight: 500;
margin: 0; margin: 0;
} }

View File

@ -1,5 +1,5 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createEffect, createSignal } from 'solid-js' import { Show, createEffect, createSignal, on } from 'solid-js'
import { useFollowing } from '../../../context/following' import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
@ -8,12 +8,12 @@ import { useSession } from '../../../context/session'
import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen' import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen'
import { capitalize } from '../../../utils/capitalize' import { capitalize } from '../../../utils/capitalize'
import { getImageUrl } from '../../../utils/getImageUrl' import { getImageUrl } from '../../../utils/getImageUrl'
import { BadgeSubscribeButton } from '../../_shared/BadgeSubscribeButton' import { FollowingButton } from '../../_shared/FollowingButton'
import styles from './TopicBadge.module.scss' import styles from './TopicBadge.module.scss'
type Props = { type Props = {
topic: Topic topic: Topic
minimizeSubscribeButton?: boolean minimize?: boolean
showStat?: boolean showStat?: boolean
subscriptionsMode?: boolean subscriptionsMode?: boolean
} }
@ -23,18 +23,21 @@ export const TopicBadge = (props: Props) => {
const { mediaMatches } = useMediaQuery() const { mediaMatches } = useMediaQuery()
const [isMobileView, setIsMobileView] = createSignal(false) const [isMobileView, setIsMobileView] = createSignal(false)
const { requireAuthentication } = useSession() const { requireAuthentication } = useSession()
const [isSubscribed, setIsSubscribed] = createSignal<boolean>() const [isFollowed, setIsFollowed] = createSignal<boolean>()
const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing() const { follow, unfollow, follows, following } = useFollowing()
createEffect(() => { createEffect(
if (!(subscriptions && props.topic)) return on([() => follows, () => props.topic], ([flws, tpc]) => {
const subscribed = subscriptions.topics?.some((topics) => topics.id === props.topic?.id) if (flws && tpc) {
setIsSubscribed(subscribed) const followed = follows?.topics?.some((topics) => topics.id === props.topic?.id)
}) setIsFollowed(followed)
}
}),
)
const handleFollowClick = () => { const handleFollowClick = () => {
requireAuthentication(() => { requireAuthentication(() => {
isSubscribed() isFollowed()
? follow(FollowingEntity.Topic, props.topic.slug) ? follow(FollowingEntity.Topic, props.topic.slug)
: unfollow(FollowingEntity.Topic, props.topic.slug) : unfollow(FollowingEntity.Topic, props.topic.slug)
}, 'subscribe') }, 'subscribe')
@ -73,7 +76,7 @@ export const TopicBadge = (props: Props) => {
when={props.topic.body} when={props.topic.body}
fallback={ fallback={
<div class={styles.description}> <div class={styles.description}>
{t('PublicationsWithCount', { count: props.topic?.stat?.shouts ?? 0 })} {t('some posts', { count: props.topic?.stat?.shouts ?? 0 })}
</div> </div>
} }
> >
@ -82,28 +85,24 @@ export const TopicBadge = (props: Props) => {
</a> </a>
</div> </div>
<div class={styles.actions}> <div class={styles.actions}>
<BadgeSubscribeButton <FollowingButton
isSubscribed={isSubscribed()} isFollowed={isFollowed()}
action={handleFollowClick} action={handleFollowClick}
actionMessageType={ actionMessageType={following()?.slug === props.topic.slug ? following().type : undefined}
subscribeInAction()?.slug === props.topic.slug ? subscribeInAction().type : undefined
}
/> />
</div> </div>
</div> </div>
<Show when={!props.subscriptionsMode}> <Show when={!props.subscriptionsMode}>
<div class={styles.stats}> <div class={styles.stats}>
<span class={styles.statsItem}>{t('shoutsWithCount', { count: props.topic?.stat?.shouts })}</span> <span class={styles.statsItem}>{t('some shouts', { count: props.topic?.stat?.shouts })}</span>
<span class={styles.statsItem}>{t('some authors', { count: props.topic?.stat?.authors })}</span>
<span class={styles.statsItem}> <span class={styles.statsItem}>
{t('authorsWithCount', { count: props.topic?.stat?.authors })} {t('some followers', { count: props.topic?.stat?.followers })}
</span>
<span class={styles.statsItem}>
{t('FollowersWithCount', { count: props.topic?.stat?.followers })}
</span> </span>
<Show when={props.topic?.stat?.comments}> <Show when={props.topic?.stat?.comments}>
<span class={styles.statsItem}> <span class={styles.statsItem}>
{t('CommentsWithCount', { count: props.topic?.stat?.comments ?? 0 })} {t('some comments', { count: props.topic?.stat?.comments ?? 0 })}
</span> </span>
</Show> </Show>
</div> </div>

View File

@ -1,32 +1,30 @@
import type { Author, Reaction, Shout, Topic } from '../../../graphql/schema/core.gen'
import { getPagePath } from '@nanostores/router' import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on, onMount } from 'solid-js' import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
import { useFollowing } from '../../../context/following' import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { Meta, Title } from '../../../context/meta' import { Meta, Title } from '../../../context/meta'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { apiClient } from '../../../graphql/client/core' import { apiClient } from '../../../graphql/client/core'
import type { Author, Reaction, Shout, Topic } from '../../../graphql/schema/core.gen'
import { router, useRouter } from '../../../stores/router' import { router, useRouter } from '../../../stores/router'
import { MODALS, hideModal } from '../../../stores/ui'
import { loadShouts, useArticlesStore } from '../../../stores/zine/articles' import { loadShouts, useArticlesStore } from '../../../stores/zine/articles'
import { loadAuthor } from '../../../stores/zine/authors' import { loadAuthor } from '../../../stores/zine/authors'
import { getImageUrl } from '../../../utils/getImageUrl' import { getImageUrl } from '../../../utils/getImageUrl'
import { getDescription } from '../../../utils/meta' import { getDescription } from '../../../utils/meta'
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll' import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
import { byCreated } from '../../../utils/sortby'
import { splitToPages } from '../../../utils/splitToPages' import { splitToPages } from '../../../utils/splitToPages'
import stylesArticle from '../../Article/Article.module.scss'
import { Comment } from '../../Article/Comment' import { Comment } from '../../Article/Comment'
import { AuthorCard } from '../../Author/AuthorCard' import { AuthorCard } from '../../Author/AuthorCard'
import { AuthorShoutsRating } from '../../Author/AuthorShoutsRating' import { AuthorShoutsRating } from '../../Author/AuthorShoutsRating'
import { Placeholder } from '../../Feed/Placeholder'
import { Row1 } from '../../Feed/Row1' import { Row1 } from '../../Feed/Row1'
import { Row2 } from '../../Feed/Row2' import { Row2 } from '../../Feed/Row2'
import { Row3 } from '../../Feed/Row3' import { Row3 } from '../../Feed/Row3'
import { Loading } from '../../_shared/Loading' import { Loading } from '../../_shared/Loading'
import { MODALS, hideModal } from '../../../stores/ui'
import { byCreated } from '../../../utils/sortby'
import stylesArticle from '../../Article/Article.module.scss'
import styles from './Author.module.scss' import styles from './Author.module.scss'
type Props = { type Props = {
@ -34,71 +32,26 @@ type Props = {
shouts?: Shout[] shouts?: Shout[]
author?: Author author?: Author
} }
export const PRERENDERED_ARTICLES_COUNT = 12 export const PRERENDERED_ARTICLES_COUNT = 12
const LOAD_MORE_PAGE_SIZE = 9 const LOAD_MORE_PAGE_SIZE = 9
export const AuthorView = (props: Props) => { export const AuthorView = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { followers: myFollowers } = useFollowing() const { followers: myFollowers, follows: myFollows } = useFollowing()
const { session } = useSession() const { author: me } = useSession()
const { sortedArticles } = useArticlesStore({ shouts: props.shouts }) const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
const { page: getPage, searchParams } = useRouter() const { page: getPage, searchParams } = useRouter()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [isBioExpanded, setIsBioExpanded] = createSignal(false) const [isBioExpanded, setIsBioExpanded] = createSignal(false)
const [author, setAuthor] = createSignal<Author>() const [author, setAuthor] = createSignal<Author>(props.author)
const [followers, setFollowers] = createSignal([]) const [followers, setFollowers] = createSignal([])
const [following, setFollowing] = createSignal<Array<Author | Topic>>([]) // flat AuthorFollowsResult const [following, changeFollowing] = createSignal<Array<Author | Topic>>([]) // flat AuthorFollowsResult
const [showExpandBioControl, setShowExpandBioControl] = createSignal(false) const [showExpandBioControl, setShowExpandBioControl] = createSignal(false)
const [commented, setCommented] = createSignal<Reaction[]>() const [commented, setCommented] = createSignal<Reaction[]>()
const modal = MODALS[searchParams().m] const modal = MODALS[searchParams().m]
const [sessionChecked, setSessionChecked] = createSignal(false) // пагинация загрузки ленты постов
createEffect(() => {
if (
!sessionChecked() &&
props.authorSlug &&
session()?.user?.app_data?.profile?.slug === props.authorSlug
) {
setSessionChecked(true)
const appdata = session()?.user.app_data
if (appdata) {
console.info('preloaded my own profile')
const { authors, profile, topics } = appdata
setFollowers(myFollowers)
setAuthor(profile)
setFollowing([...(authors || []), ...(topics || [])])
}
}
})
const bioContainerRef: { current: HTMLDivElement } = { current: null }
const bioWrapperRef: { current: HTMLDivElement } = { current: null }
const fetchData = async (slug: string) => {
if (author()?.stat.followers || author()?.stat.followers === (followers() || [])?.length) return
try {
const [subscriptionsResult, followersResult, authorResult] = await Promise.all([
apiClient.getAuthorFollows({ slug }),
apiClient.getAuthorFollowers({ slug }),
loadAuthor({ slug }),
])
const { authors, topics } = subscriptionsResult
setAuthor(authorResult)
setFollowing([...(authors || []), ...(topics || [])])
setFollowers(followersResult || [])
console.info('[components.Author] data loaded')
} catch (error) {
console.error('[components.Author] fetch error', error)
}
}
const checkBioHeight = () => {
if (bioContainerRef.current) {
setShowExpandBioControl(bioContainerRef.current.offsetHeight > bioWrapperRef.current.offsetHeight)
}
}
const loadMore = async () => { const loadMore = async () => {
saveScrollPosition() saveScrollPosition()
const { hasMore } = await loadShouts({ const { hasMore } = await loadShouts({
@ -110,36 +63,72 @@ export const AuthorView = (props: Props) => {
restoreScrollPosition() restoreScrollPosition()
} }
// загружает профиль и подписки
const [isFetching, setIsFetching] = createSignal(false)
const fetchData = async (slug) => {
setIsFetching(true)
const authorResult = await loadAuthor({ slug })
setAuthor(authorResult)
console.info(`[Author] profile for @${slug} fetched`)
const followsResult = await apiClient.getAuthorFollows({ slug })
const { authors, topics } = followsResult
changeFollowing([...(authors || []), ...(topics || [])])
console.info(`[Author] follows for @${slug} fetched`)
const followersResult = await apiClient.getAuthorFollowers({ slug })
setFollowers(followersResult || [])
console.info(`[Author] followers for @${slug} fetched`)
setIsFetching(false)
}
// проверяет не собственный ли это профиль, иначе - загружает
createEffect(
on([() => me(), () => props.authorSlug], ([myProfile, slug]) => {
const my = slug && myProfile?.slug === slug
if (my) {
console.debug('[Author] my profile precached')
myProfile && setAuthor(myProfile)
setFollowers(myFollowers() || [])
changeFollowing([...(myFollows?.authors || []), ...(myFollows?.topics || [])])
} else if (slug && !isFetching()) {
fetchData(slug)
}
}),
{ defer: true },
)
// догружает ленту и комментарии
createEffect(
on(author, async (profile) => {
if (!commented() && profile) {
await loadMore()
const ccc = await apiClient.getReactionsBy({
by: { comment: true, created_by: profile.id },
})
setCommented(ccc)
}
}),
)
const bioContainerRef: { current: HTMLDivElement } = { current: null }
const bioWrapperRef: { current: HTMLDivElement } = { current: null }
const checkBioHeight = () => {
if (bioContainerRef.current) {
setShowExpandBioControl(bioContainerRef.current.offsetHeight > bioWrapperRef.current.offsetHeight)
}
}
onMount(() => { onMount(() => {
if (!modal) hideModal() if (!modal) hideModal()
fetchData(props.authorSlug)
checkBioHeight() checkBioHeight()
loadMore()
}) })
const pages = createMemo<Shout[][]>(() => const pages = createMemo<Shout[][]>(() =>
splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE), splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE),
) )
const fetchComments = async (commenter: Author) => {
const data = await apiClient.getReactionsBy({
by: { comment: true, created_by: commenter.id },
})
setCommented(data)
}
const authorSlug = createMemo(() => author()?.slug)
createEffect(
on(
() => authorSlug(),
() => {
fetchData(authorSlug())
fetchComments(author())
},
{ defer: true },
),
)
const ogImage = createMemo(() => const ogImage = createMemo(() =>
author()?.pic author()?.pic
? getImageUrl(author()?.pic, { width: 1200 }) ? getImageUrl(author()?.pic, { width: 1200 })
@ -168,16 +157,12 @@ export const AuthorView = (props: Props) => {
<Show when={author()} fallback={<Loading />}> <Show when={author()} fallback={<Loading />}>
<> <>
<div class={styles.authorHeader}> <div class={styles.authorHeader}>
<AuthorCard author={author()} followers={followers() || []} following={following() || []} /> <AuthorCard author={author()} followers={followers() || []} flatFollows={following() || []} />
</div> </div>
<div class={clsx(styles.groupControls, 'row')}> <div class={clsx(styles.groupControls, 'row')}>
<div class="col-md-16"> <div class="col-md-16">
<ul class="view-switcher"> <ul class="view-switcher">
<li <li classList={{ 'view-switcher__item--selected': getPage().route === 'author' }}>
classList={{
'view-switcher__item--selected': getPage().route === 'author',
}}
>
<a <a
href={getPagePath(router, 'author', { href={getPagePath(router, 'author', {
slug: props.authorSlug, slug: props.authorSlug,
@ -189,11 +174,7 @@ export const AuthorView = (props: Props) => {
<span class="view-switcher__counter">{author().stat.shouts}</span> <span class="view-switcher__counter">{author().stat.shouts}</span>
</Show> </Show>
</li> </li>
<li <li classList={{ 'view-switcher__item--selected': getPage().route === 'authorComments' }}>
classList={{
'view-switcher__item--selected': getPage().route === 'authorComments',
}}
>
<a <a
href={getPagePath(router, 'authorComments', { href={getPagePath(router, 'authorComments', {
slug: props.authorSlug, slug: props.authorSlug,
@ -205,11 +186,7 @@ export const AuthorView = (props: Props) => {
<span class="view-switcher__counter">{author().stat.comments}</span> <span class="view-switcher__counter">{author().stat.comments}</span>
</Show> </Show>
</li> </li>
<li <li classList={{ 'view-switcher__item--selected': getPage().route === 'authorAbout' }}>
classList={{
'view-switcher__item--selected': getPage().route === 'authorAbout',
}}
>
<a <a
onClick={() => checkBioHeight()} onClick={() => checkBioHeight()}
href={getPagePath(router, 'authorAbout', { href={getPagePath(router, 'authorAbout', {
@ -260,6 +237,12 @@ export const AuthorView = (props: Props) => {
</div> </div>
</Match> </Match>
<Match when={getPage().route === 'authorComments'}> <Match when={getPage().route === 'authorComments'}>
<Show when={me()?.slug === props.authorSlug && !me().stat?.comments}>
<div class="wide-container">
<Placeholder type={getPage().route} mode="profile" />
</div>
</Show>
<div class="wide-container"> <div class="wide-container">
<div class="row"> <div class="row">
<div class="col-md-20 col-lg-18"> <div class="col-md-20 col-lg-18">
@ -280,26 +263,24 @@ export const AuthorView = (props: Props) => {
</div> </div>
</Match> </Match>
<Match when={getPage().route === 'author'}> <Match when={getPage().route === 'author'}>
<Show when={sortedArticles().length === 1}> <Show when={me()?.slug === props.authorSlug && !me().stat?.shouts}>
<Row1 article={sortedArticles()[0]} noauthor={true} nodate={true} /> <div class="wide-container">
<Placeholder type={getPage().route} mode="profile" />
</div>
</Show> </Show>
<Show when={sortedArticles().length === 2}> <Show when={sortedArticles().length > 0}>
<Row1 article={sortedArticles()[0]} noauthor={true} nodate={true} />
<Show when={sortedArticles().length > 1}>
<Switch>
<Match when={sortedArticles().length === 2}>
<Row2 articles={sortedArticles()} isEqual={true} noauthor={true} nodate={true} /> <Row2 articles={sortedArticles()} isEqual={true} noauthor={true} nodate={true} />
</Show> </Match>
<Match when={sortedArticles().length === 3}>
<Show when={sortedArticles().length === 3}>
<Row3 articles={sortedArticles()} noauthor={true} nodate={true} /> <Row3 articles={sortedArticles()} noauthor={true} nodate={true} />
</Show> </Match>
<Match when={sortedArticles().length > 3}>
<Show when={sortedArticles().length > 3}>
<Row1 article={sortedArticles()[0]} noauthor={true} nodate={true} />
<Row2 articles={sortedArticles().slice(1, 3)} isEqual={true} noauthor={true} />
<Row1 article={sortedArticles()[3]} noauthor={true} nodate={true} />
<Row2 articles={sortedArticles().slice(4, 6)} isEqual={true} noauthor={true} />
<Row1 article={sortedArticles()[6]} noauthor={true} nodate={true} />
<Row2 articles={sortedArticles().slice(7, 9)} isEqual={true} noauthor={true} />
<For each={pages()}> <For each={pages()}>
{(page) => ( {(page) => (
<> <>
@ -312,6 +293,8 @@ export const AuthorView = (props: Props) => {
</> </>
)} )}
</For> </For>
</Match>
</Switch>
</Show> </Show>
<Show when={isLoadMoreButtonVisible()}> <Show when={isLoadMoreButtonVisible()}>
@ -321,6 +304,7 @@ export const AuthorView = (props: Props) => {
</button> </button>
</p> </p>
</Show> </Show>
</Show>
</Match> </Match>
</Switch> </Switch>
</div> </div>

View File

@ -15,20 +15,24 @@ import styles from './DraftsView.module.scss'
export const DraftsView = () => { export const DraftsView = () => {
const { author, loadSession } = useSession() const { author, loadSession } = useSession()
const [drafts, setDrafts] = createSignal<Shout[]>([]) const [drafts, setDrafts] = createSignal<Shout[]>([])
const [loading, setLoading] = createSignal(false)
createEffect( createEffect(
on( on(
() => author(), () => author(),
async (a) => { async (a) => {
if (a) { if (a) {
setLoading(true)
const { shouts: loadedDrafts, error } = await apiClient.getDrafts() const { shouts: loadedDrafts, error } = await apiClient.getDrafts()
if (error) { if (error) {
console.warn(error) console.warn(error)
await loadSession() await loadSession()
} }
setDrafts(loadedDrafts || []) setDrafts(loadedDrafts || [])
setLoading(false)
} }
}, },
{ defer: true },
), ),
) )
@ -50,7 +54,7 @@ export const DraftsView = () => {
return ( return (
<div class={clsx(styles.DraftsView)}> <div class={clsx(styles.DraftsView)}>
<Show when={author()?.id} fallback={<Loading />}> <Show when={!loading() && author()?.id} fallback={<Loading />}>
<div class="wide-container"> <div class="wide-container">
<div class="row"> <div class="row">
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5"> <div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">

View File

@ -27,6 +27,7 @@ import { EditorSwiper } from '../../_shared/SolidSwiper'
import { PublishSettings } from '../PublishSettings' import { PublishSettings } from '../PublishSettings'
import { Loading } from '../../_shared/Loading'
import styles from './EditView.module.scss' import styles from './EditView.module.scss'
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor')) const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
@ -145,7 +146,7 @@ export const EditView = (props: Props) => {
const handleMediaDelete = (index) => { const handleMediaDelete = (index) => {
const copy = [...mediaItems()] const copy = [...mediaItems()]
copy.splice(index, 1) if (copy?.length > 0) copy.splice(index, 1)
handleInputChange('media', JSON.stringify(copy)) handleInputChange('media', JSON.stringify(copy))
} }
@ -403,7 +404,7 @@ export const EditView = (props: Props) => {
</Show> </Show>
</div> </div>
</div> </div>
<Show when={page().route === 'edit'}> <Show when={page().route === 'edit' && form?.shoutId} fallback={<Loading />}>
<Editor <Editor
shoutId={form.shoutId} shoutId={form.shoutId}
initialContent={form.body} initialContent={form.body}

View File

@ -175,6 +175,7 @@
-webkit-line-clamp: 1; -webkit-line-clamp: 1;
a { a {
border: none;
color: rgb(0 0 0 / 65%); color: rgb(0 0 0 / 65%);
&:hover { &:hover {

View File

@ -20,6 +20,7 @@ import { getShareUrl } from '../../Article/SharePopup'
import { AuthorBadge } from '../../Author/AuthorBadge' import { AuthorBadge } from '../../Author/AuthorBadge'
import { AuthorLink } from '../../Author/AuthorLink' import { AuthorLink } from '../../Author/AuthorLink'
import { ArticleCard } from '../../Feed/ArticleCard' import { ArticleCard } from '../../Feed/ArticleCard'
import { Placeholder } from '../../Feed/Placeholder'
import { Sidebar } from '../../Feed/Sidebar' import { Sidebar } from '../../Feed/Sidebar'
import { Modal } from '../../Nav/Modal' import { Modal } from '../../Nav/Modal'
import { DropDown } from '../../_shared/DropDown' import { DropDown } from '../../_shared/DropDown'
@ -100,7 +101,7 @@ export const FeedView = (props: Props) => {
const { page, searchParams, changeSearchParams } = useRouter<FeedSearchParams>() const { page, searchParams, changeSearchParams } = useRouter<FeedSearchParams>()
const [isLoading, setIsLoading] = createSignal(false) const [isLoading, setIsLoading] = createSignal(false)
const [isRightColumnLoaded, setIsRightColumnLoaded] = createSignal(false) const [isRightColumnLoaded, setIsRightColumnLoaded] = createSignal(false)
const { session } = useSession() const { author, session } = useSession()
const { loadReactionsBy } = useReactions() const { loadReactionsBy } = useReactions()
const { sortedArticles } = useArticlesStore() const { sortedArticles } = useArticlesStore()
const { topTopics } = useTopics() const { topTopics } = useTopics()
@ -143,16 +144,20 @@ export const FeedView = (props: Props) => {
Promise.all([loadTopComments()]).finally(() => setIsRightColumnLoaded(true)) Promise.all([loadTopComments()]).finally(() => setIsRightColumnLoaded(true))
}) })
createEffect(() => { createEffect(
if (session()?.access_token && !unratedArticles()) { on(
loadUnratedArticles() [() => session(), unratedArticles],
} ([s, seen]) => {
}) if (s?.access_token && !(seen?.length > 0)) loadUnratedArticles()
},
{ defer: true },
),
)
createEffect( createEffect(
on( on(
() => page().route + searchParams().by + searchParams().period + searchParams().visibility, [page, searchParams],
() => { (_, _p) => {
resetSortedArticles() resetSortedArticles()
loadMore() loadMore()
}, },
@ -234,6 +239,11 @@ export const FeedView = (props: Props) => {
</div> </div>
<div class="col-md-12 offset-xl-1"> <div class="col-md-12 offset-xl-1">
<Show when={!author() && page().route !== 'feed'}>
<Placeholder type={page().route} mode="feed" />
</Show>
<Show when={(author() || page().route === 'feed') && sortedArticles().length}>
<div class={styles.filtersContainer}> <div class={styles.filtersContainer}>
<ul class={clsx('view-switcher', styles.feedFilter)}> <ul class={clsx('view-switcher', styles.feedFilter)}>
<li <li
@ -337,6 +347,7 @@ export const FeedView = (props: Props) => {
</p> </p>
</Show> </Show>
</Show> </Show>
</Show>
</div> </div>
<aside class={clsx('col-md-7 col-xl-6 offset-xl-1', styles.feedAside)}> <aside class={clsx('col-md-7 col-xl-6 offset-xl-1', styles.feedAside)}>

View File

@ -136,9 +136,7 @@ export const InboxView = (props: Props) => {
} }
createEffect( createEffect(
on( on(messages, () => {
() => messages(),
() => {
if (!messagesContainerRef.current) { if (!messagesContainerRef.current) {
return return
} }
@ -149,8 +147,7 @@ export const InboxView = (props: Props) => {
top: messagesContainerRef.current.scrollHeight, top: messagesContainerRef.current.scrollHeight,
behavior: 'smooth', behavior: 'smooth',
}) })
}, }),
),
{ defer: true }, { defer: true },
) )
const handleScrollMessageContainer = () => { const handleScrollMessageContainer = () => {

View File

@ -1,12 +1,11 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, createEffect, createSignal } from 'solid-js' import { For, Show, createEffect, createSignal, on } from 'solid-js'
import { useFollowing } from '../../../context/following' import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { Author, Topic } from '../../../graphql/schema/core.gen' import { Author, Topic } from '../../../graphql/schema/core.gen'
import { SubscriptionFilter } from '../../../pages/types' import { FollowsFilter } from '../../../pages/types'
import { dummyFilter } from '../../../utils/dummyFilter' import { dummyFilter } from '../../../utils/dummyFilter'
// TODO: refactor styles
import { isAuthor } from '../../../utils/isAuthor' import { isAuthor } from '../../../utils/isAuthor'
import { AuthorBadge } from '../../Author/AuthorBadge' import { AuthorBadge } from '../../Author/AuthorBadge'
import { ProfileSettingsNavigation } from '../../Nav/ProfileSettingsNavigation' import { ProfileSettingsNavigation } from '../../Nav/ProfileSettingsNavigation'
@ -19,30 +18,30 @@ import stylesSettings from '../../../styles/FeedSettings.module.scss'
export const ProfileSubscriptions = () => { export const ProfileSubscriptions = () => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const { subscriptions } = useFollowing() const { follows } = useFollowing()
const [following, setFollowing] = createSignal<Array<Author | Topic>>([]) const [flatFollows, setFlatFollows] = createSignal<Array<Author | Topic>>([])
const [filtered, setFiltered] = createSignal<Array<Author | Topic>>([]) const [filtered, setFiltered] = createSignal<Array<Author | Topic>>([])
const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all') const [followsFilter, setFollowsFilter] = createSignal<FollowsFilter>('all')
const [searchQuery, setSearchQuery] = createSignal('') const [searchQuery, setSearchQuery] = createSignal('')
createEffect(() => { createEffect(() => setFlatFollows([...(follows?.authors || []), ...(follows?.topics || [])]))
const { authors, topics } = subscriptions
if (authors || topics) { createEffect(
const fdata = [...(authors || []), ...(topics || [])] on([flatFollows, followsFilter], ([flat, mode]) => {
setFollowing(fdata) if (mode === 'authors') {
if (subscriptionFilter() === 'authors') { setFiltered(flat.filter((s) => 'name' in s))
setFiltered(fdata.filter((s) => 'name' in s)) } else if (mode === 'topics') {
} else if (subscriptionFilter() === 'topics') { setFiltered(flat.filter((s) => 'title' in s))
setFiltered(fdata.filter((s) => 'title' in s))
} else { } else {
setFiltered(fdata) setFiltered(flat)
} }
} }),
}) { defer: true },
)
createEffect(() => { createEffect(() => {
if (searchQuery()) { if (searchQuery()) {
setFiltered(dummyFilter(following(), searchQuery(), lang())) setFiltered(dummyFilter(flatFollows(), searchQuery(), lang()))
} }
}) })
@ -60,32 +59,32 @@ export const ProfileSubscriptions = () => {
<div class="col-md-20 col-lg-18 col-xl-16"> <div class="col-md-20 col-lg-18 col-xl-16">
<h1>{t('My subscriptions')}</h1> <h1>{t('My subscriptions')}</h1>
<p class="description">{t('Here you can manage all your Discours subscriptions')}</p> <p class="description">{t('Here you can manage all your Discours subscriptions')}</p>
<Show when={following()} fallback={<Loading />}> <Show when={flatFollows()} fallback={<Loading />}>
<ul class="view-switcher"> <ul class="view-switcher">
<li <li
class={clsx({ class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'all', 'view-switcher__item--selected': followsFilter() === 'all',
})} })}
> >
<button type="button" onClick={() => setSubscriptionFilter('all')}> <button type="button" onClick={() => setFollowsFilter('all')}>
{t('All')} {t('All')}
</button> </button>
</li> </li>
<li <li
class={clsx({ class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'authors', 'view-switcher__item--selected': followsFilter() === 'authors',
})} })}
> >
<button type="button" onClick={() => setSubscriptionFilter('authors')}> <button type="button" onClick={() => setFollowsFilter('authors')}>
{t('Authors')} {t('Authors')}
</button> </button>
</li> </li>
<li <li
class={clsx({ class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'topics', 'view-switcher__item--selected': followsFilter() === 'topics',
})} })}
> >
<button type="button" onClick={() => setSubscriptionFilter('topics')}> <button type="button" onClick={() => setFollowsFilter('topics')}>
{t('Topics')} {t('Topics')}
</button> </button>
</li> </li>
@ -104,9 +103,9 @@ export const ProfileSubscriptions = () => {
{(followingItem) => ( {(followingItem) => (
<div> <div>
{isAuthor(followingItem) ? ( {isAuthor(followingItem) ? (
<AuthorBadge minimizeSubscribeButton={true} author={followingItem} /> <AuthorBadge minimize={true} author={followingItem} />
) : ( ) : (
<TopicBadge minimizeSubscribeButton={true} topic={followingItem} /> <TopicBadge minimize={true} topic={followingItem} />
)} )}
</div> </div>
)} )}

View File

@ -40,7 +40,18 @@ const EMPTY_TOPIC: Topic = {
id: -1, id: -1,
slug: '', slug: '',
} }
const emptyConfig = {
interface FormConfig {
coverImageUrl?: string
mainTopic?: Topic
slug?: string
title?: string
subtitle?: string
description?: string
selectedTopics?: Topic[]
}
const emptyConfig: FormConfig = {
coverImageUrl: '', coverImageUrl: '',
mainTopic: EMPTY_TOPIC, mainTopic: EMPTY_TOPIC,
slug: '', slug: '',
@ -78,7 +89,7 @@ export const PublishSettings = (props: Props) => {
} }
}) })
const [settingsForm, setSettingsForm] = createStore(emptyConfig) const [settingsForm, setSettingsForm] = createStore<FormConfig>(emptyConfig)
onMount(() => { onMount(() => {
setSettingsForm(initialData()) setSettingsForm(initialData())
@ -96,12 +107,12 @@ export const PublishSettings = (props: Props) => {
setSettingsForm('coverImageUrl', '') setSettingsForm('coverImageUrl', '')
} }
const handleTopicSelectChange = (newSelectedTopics) => { const handleTopicSelectChange = (newSelectedTopics: Topic[]) => {
if ( if (
props.form.selectedTopics.length === 0 || props.form.selectedTopics.length === 0 ||
newSelectedTopics.every((topic) => topic.id !== props.form.mainTopic?.id) newSelectedTopics.every((topic: Topic) => topic.id !== props.form.mainTopic?.id)
) { ) {
setSettingsForm((prev) => { setSettingsForm((prev: Topic) => {
return { return {
...prev, ...prev,
mainTopic: newSelectedTopics[0], mainTopic: newSelectedTopics[0],
@ -193,7 +204,8 @@ export const PublishSettings = (props: Props) => {
fieldName={t('Header')} fieldName={t('Header')}
placeholder={t('Come up with a title for your story')} placeholder={t('Come up with a title for your story')}
initialValue={settingsForm.title} initialValue={settingsForm.title}
value={(value) => setSettingsForm('title', value)} // biome-ignore lint/suspicious/noExplicitAny: <explanation>
value={(value: any) => setSettingsForm('title', value)}
allowEnterKey={false} allowEnterKey={false}
maxLength={100} maxLength={100}
/> />
@ -203,7 +215,8 @@ export const PublishSettings = (props: Props) => {
fieldName={t('Subheader')} fieldName={t('Subheader')}
placeholder={t('Come up with a subtitle for your story')} placeholder={t('Come up with a subtitle for your story')}
initialValue={settingsForm.subtitle || ''} initialValue={settingsForm.subtitle || ''}
value={(value) => setSettingsForm('subtitle', value)} // biome-ignore lint/suspicious/noExplicitAny: <explanation>
value={(value: any) => setSettingsForm('subtitle', value)}
allowEnterKey={false} allowEnterKey={false}
maxLength={100} maxLength={100}
/> />
@ -214,7 +227,8 @@ export const PublishSettings = (props: Props) => {
placeholder={t('Write a short introduction')} placeholder={t('Write a short introduction')}
label={t('Description')} label={t('Description')}
initialContent={composeDescription()} initialContent={composeDescription()}
onChange={(value) => setForm('description', value)} // biome-ignore lint/suspicious/noExplicitAny: <explanation>
onChange={(value: any) => setForm('description', value)}
maxLength={DESCRIPTION_MAX_LENGTH} maxLength={DESCRIPTION_MAX_LENGTH}
/> />
</div> </div>

View File

@ -1,4 +1,4 @@
import { LoadShoutsOptions, Shout, Topic } from '../../graphql/schema/core.gen' import { Author, AuthorsBy, LoadShoutsOptions, Shout, Topic } from '../../graphql/schema/core.gen'
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, on, onMount } from 'solid-js'
@ -33,6 +33,7 @@ interface Props {
topic: Topic topic: Topic
shouts: Shout[] shouts: Shout[]
topicSlug: string topicSlug: string
followers?: Author[]
} }
export const PRERENDERED_ARTICLES_COUNT = 28 export const PRERENDERED_ARTICLES_COUNT = 28
@ -49,13 +50,30 @@ export const TopicView = (props: Props) => {
const [reactedTopMonthArticles, setReactedTopMonthArticles] = createSignal<Shout[]>([]) const [reactedTopMonthArticles, setReactedTopMonthArticles] = createSignal<Shout[]>([])
const [topic, setTopic] = createSignal<Topic>() const [topic, setTopic] = createSignal<Topic>()
createEffect(
createEffect(() => { on([() => props.topicSlug, topic, topicEntities], async ([slug, t, ttt]) => {
const topics = topicEntities() if (slug && !t && ttt) {
if (props.topicSlug && !topic() && topics) { const current = ttt[slug]
setTopic(topics[props.topicSlug]) console.debug(current)
setTopic(current)
await loadTopicFollowers()
await loadTopicAuthors()
await loadRandom()
}
}),
)
const [followers, setFollowers] = createSignal<Author[]>(props.followers || [])
const loadTopicFollowers = async () => {
const flwrs = await apiClient.getTopicFollowers({ slug: props.topicSlug })
setFollowers(flwrs)
}
const [topicAuthors, setTopicAuthors] = createSignal<Author[]>([])
const loadTopicAuthors = async () => {
const by: AuthorsBy = { topic: props.topicSlug }
const authors = await apiClient.loadAuthorsBy({ by, limit: 10, offset: 0 })
setTopicAuthors(authors)
} }
})
const loadFavoriteTopArticles = async (topic: string) => { const loadFavoriteTopArticles = async (topic: string) => {
const options: LoadShoutsOptions = { const options: LoadShoutsOptions = {
@ -87,14 +105,6 @@ export const TopicView = (props: Props) => {
loadReactedTopMonthArticles(topic()?.slug) loadReactedTopMonthArticles(topic()?.slug)
} }
createEffect(
on(
() => topic(),
() => loadRandom(),
{ defer: true },
),
)
const title = createMemo( const title = createMemo(
() => () =>
`#${capitalize( `#${capitalize(
@ -158,7 +168,7 @@ export const TopicView = (props: Props) => {
<Meta name="twitter:card" content="summary_large_image" /> <Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={title()} /> <Meta name="twitter:title" content={title()} />
<Meta name="twitter:description" content={description()} /> <Meta name="twitter:description" content={description()} />
<FullTopic topic={topic()} /> <FullTopic topic={topic()} followers={followers()} authors={topicAuthors()} />
<div class="wide-container"> <div class="wide-container">
<div class={clsx(styles.groupControls, 'row group__controls')}> <div class={clsx(styles.groupControls, 'row group__controls')}>
<div class="col-md-16"> <div class="col-md-16">

View File

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

View File

@ -175,7 +175,7 @@
} }
} }
&.subscribed { &.followed {
background: #fff; background: #fff;
color: #000; color: #000;

View File

@ -2,35 +2,36 @@ import { clsx } from 'clsx'
import { Show, createMemo } from 'solid-js' import { Show, createMemo } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { Button } from '../Button' import { Button } from '../Button'
import stylesButton from '../Button/Button.module.scss'
import { CheckButton } from '../CheckButton' import { CheckButton } from '../CheckButton'
import { Icon } from '../Icon' import { Icon } from '../Icon'
import styles from './BadgeDubscribeButton.module.scss'
import stylesButton from '../Button/Button.module.scss'
import styles from './FollowingButton.module.scss'
type Props = { type Props = {
class?: string class?: string
isSubscribed: boolean isFollowed: boolean
minimizeSubscribeButton?: boolean minimize?: boolean
action: () => void action: () => void
iconButtons?: boolean iconButtons?: boolean
actionMessageType?: 'subscribe' | 'unsubscribe' actionMessageType?: 'follow' | 'unfollow'
} }
export const BadgeSubscribeButton = (props: Props) => { export const FollowingButton = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const inActionText = createMemo(() => { const inActionText = createMemo(() => {
return props.actionMessageType === 'subscribe' ? t('Subscribing...') : t('Unsubscribing...') return props.actionMessageType === 'follow' ? t('Following...') : t('Unfollowing...')
}) })
return ( return (
<div class={props.class}> <div class={props.class}>
<Show <Show
when={!props.minimizeSubscribeButton} when={!props.minimize}
fallback={<CheckButton text={t('Follow')} checked={props.isSubscribed} onClick={props.action} />} fallback={<CheckButton text={t('Follow')} checked={props.isFollowed} onClick={props.action} />}
> >
<Show <Show
when={props.isSubscribed} when={props.isFollowed}
fallback={ fallback={
<Button <Button
variant={props.iconButtons ? 'secondary' : 'bordered'} variant={props.iconButtons ? 'secondary' : 'bordered'}
@ -38,7 +39,7 @@ export const BadgeSubscribeButton = (props: Props) => {
value={ value={
<Show <Show
when={props.iconButtons} when={props.iconButtons}
fallback={props.actionMessageType ? inActionText() : t('Subscribe')} fallback={props.actionMessageType ? inActionText() : t('Follow')}
> >
<Icon name="author-subscribe" class={stylesButton.icon} /> <Icon name="author-subscribe" class={stylesButton.icon} />
</Show> </Show>
@ -47,7 +48,7 @@ export const BadgeSubscribeButton = (props: Props) => {
isSubscribeButton={true} isSubscribeButton={true}
class={clsx(styles.actionButton, { class={clsx(styles.actionButton, {
[styles.iconed]: props.iconButtons, [styles.iconed]: props.iconButtons,
[stylesButton.subscribed]: props.isSubscribed, [stylesButton.followed]: props.isFollowed,
})} })}
/> />
} }
@ -76,7 +77,7 @@ export const BadgeSubscribeButton = (props: Props) => {
isSubscribeButton={true} isSubscribeButton={true}
class={clsx(styles.actionButton, { class={clsx(styles.actionButton, {
[styles.iconed]: props.iconButtons, [styles.iconed]: props.iconButtons,
[stylesButton.subscribed]: props.isSubscribed, [stylesButton.followed]: props.isFollowed,
})} })}
/> />
</Show> </Show>

View File

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

View File

@ -0,0 +1,50 @@
.subscribers {
align-items: center;
cursor: pointer;
display: inline-flex;
margin: 0 1rem 0 0;
vertical-align: top;
border-bottom: unset !important;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
.subscribersItem {
position: relative;
&:nth-child(1) {
z-index: 2;
}
&:nth-child(2) {
z-index: 1;
}
&:not(:last-child) {
margin-right: -4px;
box-shadow: 0 0 0 1px var(--background-color);
}
}
.subscribersCounter {
font-weight: 500;
}
&:hover {
background: none !important;
.subscribersCounter {
background: var(--background-color-invert);
}
}
}
.subscribersList {
display: flex;
margin-right: 0.6rem;
}

View File

@ -0,0 +1,86 @@
import { For, Show, createMemo } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { Author, Topic } from '../../../graphql/schema/core.gen'
import { Userpic } from '../../Author/Userpic'
import styles from './FollowingCounters.module.scss'
type Props = {
followers?: Author[]
followersAmount?: number
following?: Array<Author | Topic>
followingAmount?: number
authors?: Author[]
authorsAmount?: number
topics?: Topic[]
topicsAmount?: number
}
const UserpicList = (props: { items: Array<Author | Topic> }) => (
<div class={styles.subscribersList}>
<For each={props.items.slice(0, 3)}>
{(item) => (
<Userpic
size="XS"
name={'name' in item ? item.name : 'title' in item ? item.title : ''}
userpic={item.pic}
class={styles.subscribersItem}
/>
)}
</For>
</div>
)
const Counter = (props: { count: number; label: string }) => (
<div class={styles.subscribersCounter}>{props.label}</div>
)
export const FollowingCounters = (props: Props) => {
const { t } = useLocalize()
const getFollowersCount = createMemo(() => props.followersAmount || props.followers?.length || 0)
const getFollowingCount = createMemo(() => props.followingAmount || props.following?.length || 0)
const getAuthorsCount = createMemo(() => props.authorsAmount || props.authors?.length || 0)
const getTopicsCount = createMemo(() => props.topicsAmount || props.topics?.length || 0)
return (
<>
<a href="?m=followers" class={styles.subscribers}>
<Show when={getFollowersCount() > 0}>
<UserpicList items={props.followers || []} />
</Show>
<Counter count={getFollowersCount()} label={t('some followers', { count: getFollowersCount() })} />
</a>
<a href="?m=following" class={styles.subscribers}>
<Show when={getFollowingCount() > 0}>
<UserpicList items={props.following || []} />
</Show>
<Show
when={getFollowingCount() > 0}
fallback={
<>
<Show when={getAuthorsCount() > 0}>
<UserpicList items={props.authors || []} />
<Counter
count={getAuthorsCount()}
label={t('some authors', { count: getAuthorsCount() })}
/>
</Show>
<Show when={getTopicsCount() > 0}>
<Counter count={getTopicsCount()} label={t('some topics', { count: getTopicsCount() })} />
</Show>
</>
}
>
<Counter
count={getFollowingCount()}
label={t('some followings', { count: getFollowingCount() })}
/>
</Show>
</a>
</>
)
}

View File

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

View File

@ -66,7 +66,7 @@ export const InviteMembers = (props: Props) => {
createEffect( createEffect(
on( on(
() => sortedAuthors(), sortedAuthors,
(currentAuthors) => { (currentAuthors) => {
setAuthorsToInvite(currentAuthors.map((author) => ({ ...author, selected: false }))) setAuthorsToInvite(currentAuthors.map((author) => ({ ...author, selected: false })))
}, },

View File

@ -129,8 +129,8 @@ export const Lightbox = (props: Props) => {
createEffect( createEffect(
on( on(
() => zoomLevel(), zoomLevel,
() => { (_) => {
clearTimeout(fadeTimer) clearTimeout(fadeTimer)
fadeTimer = setTimeout(() => { fadeTimer = setTimeout(() => {

View File

@ -6,12 +6,12 @@ import { validateEmail } from '../../../utils/validateEmail'
import { Button } from '../Button' import { Button } from '../Button'
import { Icon } from '../Icon' import { Icon } from '../Icon'
import styles from './Subscribe.module.scss' import styles from './Newsletter.module.scss'
type Props = { type Props = {
variant?: 'mobileSubscription' variant?: 'mobileSubscription'
} }
export const Subscribe = (props: Props) => { export const Newsletter = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const [title, setTitle] = createSignal('') const [title, setTitle] = createSignal('')

View File

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

View File

@ -61,7 +61,7 @@ export const EditorSwiper = (props: Props) => {
createEffect( createEffect(
on( on(
() => props.images.length, () => props.images.length,
() => { (_) => {
mainSwipeRef.current?.swiper.update() mainSwipeRef.current?.swiper.update()
thumbSwipeRef.current?.swiper.update() thumbSwipeRef.current?.swiper.update()
}, },
@ -121,6 +121,7 @@ export const EditorSwiper = (props: Props) => {
const handleChangeIndex = (direction: 'left' | 'right', index: number) => { const handleChangeIndex = (direction: 'left' | 'right', index: number) => {
const images = [...props.images] const images = [...props.images]
if (images?.length > 0) {
if (direction === 'left' && index > 0) { if (direction === 'left' && index > 0) {
const copy = images.splice(index, 1)[0] const copy = images.splice(index, 1)[0]
images.splice(index - 1, 0, copy) images.splice(index - 1, 0, copy)
@ -133,6 +134,7 @@ export const EditorSwiper = (props: Props) => {
mainSwipeRef.current.swiper.slideTo(direction === 'left' ? index - 1 : index + 1) mainSwipeRef.current.swiper.slideTo(direction === 'left' ? index - 1 : index + 1)
}, 0) }, 0)
} }
}
const handleSaveBeforeSlideChange = () => { const handleSaveBeforeSlideChange = () => {
handleSlideDescriptionChange(slideIndex(), 'body', slideBody()) handleSlideDescriptionChange(slideIndex(), 'body', slideBody())

View File

@ -45,7 +45,7 @@ export const ImageSwiper = (props: Props) => {
createEffect( createEffect(
on( on(
() => props.images.length, () => props.images.length,
() => { (_) => {
mainSwipeRef.current?.swiper.update() mainSwipeRef.current?.swiper.update()
thumbSwipeRef.current?.swiper.update() thumbSwipeRef.current?.swiper.update()
}, },

View File

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

View File

@ -3,9 +3,10 @@ import type { Accessor, JSX } from 'solid-js'
import type { Author, Reaction, Shout, Topic } from '../graphql/schema/core.gen' import type { Author, Reaction, Shout, Topic } from '../graphql/schema/core.gen'
import { EventStreamContentType, fetchEventSource } from '@microsoft/fetch-event-source' import { EventStreamContentType, fetchEventSource } from '@microsoft/fetch-event-source'
import { createContext, createEffect, createSignal, useContext } from 'solid-js' import { createContext, createEffect, createSignal, on, useContext } from 'solid-js'
import { Chat, Message } from '../graphql/schema/chat.gen' import { Chat, Message } from '../graphql/schema/chat.gen'
import { sseUrl } from '../utils/config'
import { useSession } from './session' import { useSession } from './session'
const RECONNECT_TIMES = 2 const RECONNECT_TIMES = 2
@ -38,16 +39,20 @@ export const ConnectProvider = (props: { children: JSX.Element }) => {
setHandlers((hhh) => [...hhh, handler]) setHandlers((hhh) => [...hhh, handler])
} }
createEffect(async () => { createEffect(
const token = session()?.access_token on(
if (token && !connected() && retried() <= RECONNECT_TIMES) { () => session()?.access_token,
console.info('[context.connect] init SSE connection') async (tkn) => {
if (!sseUrl) return
if (!tkn) return
if (!connected() && retried() <= RECONNECT_TIMES) {
console.info('[context.connect] got token, init SSE connection')
try { try {
await fetchEventSource('https://connect.discours.io', { await fetchEventSource(sseUrl, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: token, Authorization: tkn,
}, },
onmessage(event) { onmessage(event) {
const m: SSEMessage = JSON.parse(event.data || '{}') const m: SSEMessage = JSON.parse(event.data || '{}')
@ -61,14 +66,14 @@ export const ConnectProvider = (props: { children: JSX.Element }) => {
setRetried(0) setRetried(0)
return Promise.resolve() return Promise.resolve()
} }
return Promise.reject(`SSE: cannot connect to real-time updates, status: ${response.status}`) return Promise.reject(`SSE: cannot connect to real-time updates: ${response.status}`)
}, },
onclose() { onclose() {
console.log('[context.connect] SSE connection closed by server') console.log('[context.connect] SSE connection closed by server')
setConnected(false) setConnected(false)
if (retried() < RECONNECT_TIMES) { if (retried() < RECONNECT_TIMES) {
setRetried((r) => r + 1) setRetried((r) => r + 1)
} } else throw Error('closed by server')
}, },
onerror(err) { onerror(err) {
console.error('[context.connect] SSE connection error:', err) console.error('[context.connect] SSE connection error:', err)
@ -82,7 +87,9 @@ export const ConnectProvider = (props: { children: JSX.Element }) => {
console.error('[context.connect] SSE connection failed:', error) console.error('[context.connect] SSE connection failed:', error)
} }
} }
}) },
),
)
const value: ConnectContextType = { addHandler, connected } const value: ConnectContextType = { addHandler, connected }

View File

@ -1,30 +1,27 @@
import { Accessor, JSX, createContext, createEffect, createSignal, useContext } from 'solid-js' import { Accessor, JSX, createContext, createEffect, createSignal, on, useContext } from 'solid-js'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { apiClient } from '../graphql/client/core' import { apiClient } from '../graphql/client/core'
import { Author, AuthorFollowsResult, Community, FollowingEntity, Topic } from '../graphql/schema/core.gen' import { Author, AuthorFollowsResult, FollowingEntity } from '../graphql/schema/core.gen'
import { useSession } from './session' import { useSession } from './session'
export type SubscriptionsData = { type FollowingData = { slug: string; type: 'follow' | 'unfollow' }
topics?: Topic[]
authors?: Author[]
communities?: Community[]
}
type SubscribeAction = { slug: string; type: 'subscribe' | 'unsubscribe' }
interface FollowingContextType { interface FollowingContextType {
loading: Accessor<boolean> loading: Accessor<boolean>
followers: Accessor<Author[]> followers: Accessor<Author[]>
subscriptions: AuthorFollowsResult setFollows: (follows: AuthorFollowsResult) => void
setSubscriptions: (subscriptions: AuthorFollowsResult) => void
setFollowing: (what: FollowingEntity, slug: string, value: boolean) => void following: Accessor<FollowingData>
loadSubscriptions: () => void changeFollowing: (what: FollowingEntity, slug: string, value: boolean) => void
follows: AuthorFollowsResult
loadFollows: () => void
follow: (what: FollowingEntity, slug: string) => Promise<void> follow: (what: FollowingEntity, slug: string) => Promise<void>
unfollow: (what: FollowingEntity, slug: string) => Promise<void> unfollow: (what: FollowingEntity, slug: string) => Promise<void>
// followers: Accessor<Author[]>
subscribeInAction?: Accessor<SubscribeAction>
} }
const FollowingContext = createContext<FollowingContextType>() const FollowingContext = createContext<FollowingContextType>()
@ -42,7 +39,7 @@ const EMPTY_SUBSCRIPTIONS: AuthorFollowsResult = {
export const FollowingProvider = (props: { children: JSX.Element }) => { export const FollowingProvider = (props: { children: JSX.Element }) => {
const [loading, setLoading] = createSignal<boolean>(false) const [loading, setLoading] = createSignal<boolean>(false)
const [followers, setFollowers] = createSignal<Author[]>([]) const [followers, setFollowers] = createSignal<Author[]>([])
const [subscriptions, setSubscriptions] = createStore<AuthorFollowsResult>(EMPTY_SUBSCRIPTIONS) const [follows, setFollows] = createStore<AuthorFollowsResult>(EMPTY_SUBSCRIPTIONS)
const { author, session } = useSession() const { author, session } = useSession()
const fetchData = async () => { const fetchData = async () => {
@ -51,68 +48,67 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
if (apiClient.private) { if (apiClient.private) {
console.debug('[context.following] fetching subs data...') console.debug('[context.following] fetching subs data...')
const result = await apiClient.getAuthorFollows({ user: session()?.user.id }) const result = await apiClient.getAuthorFollows({ user: session()?.user.id })
setSubscriptions(result || EMPTY_SUBSCRIPTIONS) setFollows(result || EMPTY_SUBSCRIPTIONS)
} }
} catch (error) { } catch (error) {
console.info('[context.following] cannot get subs', error) console.warn('[context.following] cannot get subs', error)
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
createEffect(() => { const [following, setFollowing] = createSignal<FollowingData>()
console.info('[context.following] subs:', subscriptions)
})
const [subscribeInAction, setSubscribeInAction] = createSignal<SubscribeAction>()
const follow = async (what: FollowingEntity, slug: string) => { const follow = async (what: FollowingEntity, slug: string) => {
if (!author()) return if (!author()) return
setSubscribeInAction({ slug, type: 'subscribe' }) setFollowing({ slug, type: 'follow' })
try { try {
const subscriptionData = await apiClient.follow({ what, slug }) const result = await apiClient.follow({ what, slug })
setSubscriptions((prevSubscriptions) => { setFollows((subs) => {
if (!prevSubscriptions[what]) prevSubscriptions[what] = [] if (result.authors) subs['authors'] = result.authors || []
prevSubscriptions[what].push(subscriptionData) if (result.topics) subs['topics'] = result.topics || []
return prevSubscriptions return subs
}) })
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
setSubscribeInAction() // Сбрасываем состояние действия подписки. setFollowing() // Сбрасываем состояние действия подписки.
} }
} }
const unfollow = async (what: FollowingEntity, slug: string) => { const unfollow = async (what: FollowingEntity, slug: string) => {
if (!author()) return if (!author()) return
setSubscribeInAction({ slug: slug, type: 'unsubscribe' }) setFollowing({ slug: slug, type: 'unfollow' })
try { try {
await apiClient.unfollow({ what, slug }) const result = await apiClient.unfollow({ what, slug })
setFollows((subs) => {
if (result.authors) subs['authors'] = result.authors || []
if (result.topics) subs['topics'] = result.topics || []
return subs
})
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
setSubscribeInAction() setFollowing()
} }
} }
createEffect(() => { createEffect(
if (author()) { on(
try { () => session()?.user.app_data,
const appdata = session()?.user.app_data (appdata) => {
if (appdata) { if (appdata) {
const { authors, followers, topics } = appdata const { authors, followers, topics } = appdata
setSubscriptions({ authors, topics }) setFollows({ authors, topics })
setFollowers(followers) setFollowers(followers)
if (!authors) fetchData() if (!authors) fetchData()
} }
} catch (e) { },
console.error(e) ),
} )
}
})
const setFollowing = (what: FollowingEntity, slug: string, value = true) => { const changeFollowing = (what: FollowingEntity, slug: string, value = true) => {
setSubscriptions((prevSubscriptions) => { setFollows((fff) => {
const updatedSubs = { ...prevSubscriptions } const updatedSubs = { ...fff }
if (!updatedSubs[what]) updatedSubs[what] = [] if (!updatedSubs[what]) updatedSubs[what] = []
if (value) { if (value) {
const exists = updatedSubs[what]?.some((entity) => entity.slug === slug) const exists = updatedSubs[what]?.some((entity) => entity.slug === slug)
@ -133,15 +129,14 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
const value: FollowingContextType = { const value: FollowingContextType = {
loading, loading,
subscriptions, follows,
setSubscriptions, setFollows,
setFollowing, following,
changeFollowing,
followers, followers,
loadSubscriptions: fetchData, loadFollows: fetchData,
follow, follow,
unfollow, unfollow,
// followers,
subscribeInAction,
} }
return <FollowingContext.Provider value={value}>{props.children}</FollowingContext.Provider> return <FollowingContext.Provider value={value}>{props.children}</FollowingContext.Provider>

View File

@ -184,7 +184,7 @@ function initServerProvider() {
const index = tags.findIndex( const index = tags.findIndex(
(prev) => prev.tag === tagDesc.tag && getTagKey(prev, properties) === tagDescKey, (prev) => prev.tag === tagDesc.tag && getTagKey(prev, properties) === tagDescKey,
) )
if (index !== -1) { if (index !== -1 && tags?.length > 0) {
tags.splice(index, 1) tags.splice(index, 1)
} }
} }

View File

@ -32,27 +32,26 @@ import { inboxClient } from '../graphql/client/chat'
import { apiClient } from '../graphql/client/core' import { apiClient } from '../graphql/client/core'
import { useRouter } from '../stores/router' import { useRouter } from '../stores/router'
import { showModal } from '../stores/ui' import { showModal } from '../stores/ui'
import { addAuthors } from '../stores/zine/authors' import { addAuthors, loadAuthor } from '../stores/zine/authors'
import { authApiUrl } from '../utils/config'
import { useLocalize } from './localize' import { useLocalize } from './localize'
import { useSnackbar } from './snackbar' import { useSnackbar } from './snackbar'
const defaultConfig: ConfigType = { const defaultConfig: ConfigType = {
authorizerURL: 'https://auth.discours.io', authorizerURL: authApiUrl.replace('/graphql', ''),
redirectURL: 'https://testing.discours.io', redirectURL: 'https://testing.discours.io',
clientID: 'b9038a34-ca59-41ae-a105-c7fbea603e24', // FIXME: use env? clientID: '',
} }
export type SessionContextType = { export type SessionContextType = {
config: Accessor<ConfigType> config: Accessor<ConfigType>
session: Resource<AuthToken> session: Resource<AuthToken>
author: Resource<Author | null> author: Accessor<Author>
authError: Accessor<string> authError: Accessor<string>
isSessionLoaded: Accessor<boolean> isSessionLoaded: Accessor<boolean>
loadSession: () => AuthToken | Promise<AuthToken> loadSession: () => AuthToken | Promise<AuthToken>
setSession: (token: AuthToken | null) => void // setSession setSession: (token: AuthToken | null) => void // setSession
loadAuthor: (info?: unknown) => Author | Promise<Author>
setAuthor: (a: Author) => void
requireAuthentication: ( requireAuthentication: (
callback: (() => Promise<void>) | (() => void), callback: (() => Promise<void>) | (() => void),
modalSource: AuthModalSource, modalSource: AuthModalSource,
@ -66,16 +65,39 @@ export type SessionContextType = {
params: ForgotPasswordInput, params: ForgotPasswordInput,
) => Promise<{ data: ForgotPasswordResponse; errors: Error[] }> ) => Promise<{ data: ForgotPasswordResponse; errors: Error[] }>
changePassword: (password: string, token: string) => void changePassword: (password: string, token: string) => void
confirmEmail: (input: VerifyEmailInput) => Promise<AuthToken> // email confirm callback is in auth.discours.io confirmEmail: (input: VerifyEmailInput) => Promise<AuthToken> // email confirm callback is in authorizer
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<GenericResponse> resendVerifyEmail: (params: ResendVerifyEmailInput) => Promise<GenericResponse>
} }
// biome-ignore lint/suspicious/noEmptyBlockStatements: <explanation> const noop = () => null
const noop = () => {} const metaRes = {
data: {
meta: {
version: 'latest',
// client_id: 'b9038a34-ca59-41ae-a105-c7fbea603e24',
is_google_login_enabled: true,
is_facebook_login_enabled: true,
is_github_login_enabled: true,
is_linkedin_login_enabled: false,
is_apple_login_enabled: false,
is_twitter_login_enabled: true,
is_microsoft_login_enabled: false,
is_twitch_login_enabled: false,
is_roblox_login_enabled: false,
is_email_verification_enabled: true,
is_basic_authentication_enabled: true,
is_magic_link_login_enabled: true,
is_sign_up_enabled: true,
is_strong_password_enabled: false,
is_multi_factor_auth_enabled: true,
is_mobile_basic_authentication_enabled: true,
is_phone_verification_enabled: false,
},
},
}
const SessionContext = createContext<SessionContextType>() const SessionContext = createContext<SessionContextType>()
export function useSession() { export function useSession() {
@ -96,15 +118,15 @@ export const SessionProvider = (props: {
// handle auth state callback // handle auth state callback
createEffect( createEffect(
on( on(
() => searchParams()?.state, searchParams,
(state) => { (params) => {
if (state) { if (params?.state) {
setOauthState((_s) => state) setOauthState((_s) => params?.state)
const scope = searchParams()?.scope const scope = params?.scope
? searchParams()?.scope?.toString().split(' ') ? params?.scope?.toString().split(' ')
: ['openid', 'profile', 'email'] : ['openid', 'profile', 'email']
if (scope) console.info(`[context.session] scope: ${scope}`) if (scope) console.info(`[context.session] scope: ${scope}`)
const url = searchParams()?.redirect_uri || searchParams()?.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' }, true) changeSearchParams({ mode: 'confirm-email', m: 'auth' }, true)
} }
@ -202,75 +224,56 @@ export const SessionProvider = (props: {
onCleanup(() => clearTimeout(minuteLater)) onCleanup(() => clearTimeout(minuteLater))
const authorData = async () => { const [author, setAuthor] = createSignal<Author>()
const u = session()?.user
return u ? (await apiClient.getAuthorId({ user: u.id.trim() })) || null : null
}
const [author, { refetch: loadAuthor, mutate: setAuthor }] = createResource<Author | null>(authorData, {
ssrLoadFrom: 'initial',
initialValue: null,
})
// when session is loaded // when session is loaded
createEffect(() => { createEffect(
if (session()) { on(
const token = session()?.access_token () => session(),
if (token) { async (s: AuthToken) => {
if (!inboxClient.private) { if (s) {
const token = s?.access_token
const profile = s?.user?.app_data?.profile
if (token && !inboxClient.private) {
apiClient.connect(token) apiClient.connect(token)
inboxClient.connect(token) inboxClient.connect(token)
} }
try {
const appdata = session()?.user.app_data
if (appdata) {
const { profile } = appdata
if (profile?.id) { if (profile?.id) {
setAuthor(profile)
addAuthors([profile]) addAuthors([profile])
} else { setAuthor(profile)
setTimeout(loadAuthor, 15)
}
}
} catch (e) {
console.error(e)
}
setIsSessionLoaded(true) setIsSessionLoaded(true)
}
}
})
// when author is loaded
createEffect(() => {
if (author()) {
addAuthors([author()])
} else { } else {
reset() console.warn('app_data is empty')
if (s?.user) {
try {
console.info('Loading author:', s?.user?.nickname)
const a = await loadAuthor({ slug: s?.user?.nickname })
addAuthors([a])
setAuthor(a)
s.user.app_data.profile = a
} catch (error) {
console.error('Error loading author:', error)
} }
}) } else {
console.warn(s)
const reset = () => {
setIsSessionLoaded(true)
setSession(null) setSession(null)
setAuthor(null) setAuthor(null)
setIsSessionLoaded(true)
} }
}
}
},
{ defer: true },
),
)
// initial effect // initial effect
onMount(async () => { onMount(() => {
const metaRes = await authorizer().getMetaData()
setConfig({ setConfig({
...defaultConfig, ...defaultConfig,
...metaRes, ...metaRes,
redirectURL: window.location.origin, redirectURL: window.location.origin,
}) })
let s: AuthToken loadSession()
try {
s = await loadSession()
} catch (error) {
console.warn('[context.session] load session failed', error)
}
if (!s) reset()
}) })
// callback state updater // callback state updater
@ -316,8 +319,10 @@ export const SessionProvider = (props: {
const signOut = async () => { const signOut = async () => {
const authResult: ApiResponse<GenericResponse> = await authorizer().logout() const authResult: ApiResponse<GenericResponse> = await authorizer().logout()
console.debug(authResult) console.debug(authResult)
reset() setSession(null)
setIsSessionLoaded(true)
showSnackbar({ body: t("You've successfully logged out") }) showSnackbar({ body: t("You've successfully logged out") })
console.debug(session())
} }
const changePassword = async (password: string, token: string) => { const changePassword = async (password: string, token: string) => {
@ -391,9 +396,7 @@ export const SessionProvider = (props: {
updateProfile, updateProfile,
setIsSessionLoaded, setIsSessionLoaded,
setSession, setSession,
setAuthor,
authorizer, authorizer,
loadAuthor,
forgotPassword, forgotPassword,
changePassword, changePassword,
oauth, oauth,

View File

@ -5,6 +5,7 @@ import type {
LoadShoutsOptions, LoadShoutsOptions,
MutationDelete_ShoutArgs, MutationDelete_ShoutArgs,
ProfileInput, ProfileInput,
QueryGet_Topic_FollowersArgs,
QueryLoad_Authors_ByArgs, QueryLoad_Authors_ByArgs,
QueryLoad_Shouts_Random_TopArgs, QueryLoad_Shouts_Random_TopArgs,
QueryLoad_Shouts_SearchArgs, QueryLoad_Shouts_SearchArgs,
@ -39,11 +40,11 @@ import loadShoutsUnrated from '../query/core/articles-load-unrated'
import authorBy from '../query/core/author-by' import authorBy from '../query/core/author-by'
import authorFollowers from '../query/core/author-followers' import authorFollowers from '../query/core/author-followers'
import authorFollows from '../query/core/author-follows' import authorFollows from '../query/core/author-follows'
import authorId from '../query/core/author-id'
import authorsAll from '../query/core/authors-all' import authorsAll from '../query/core/authors-all'
import authorsLoadBy from '../query/core/authors-load-by' import authorsLoadBy from '../query/core/authors-load-by'
import reactionsLoadBy from '../query/core/reactions-load-by' import reactionsLoadBy from '../query/core/reactions-load-by'
import topicBySlug from '../query/core/topic-by-slug' import topicBySlug from '../query/core/topic-by-slug'
import topicFollowers from '../query/core/topic-followers'
import topicsAll from '../query/core/topics-all' import topicsAll from '../query/core/topics-all'
import topicsRandomQuery from '../query/core/topics-random' import topicsRandomQuery from '../query/core/topics-random'
@ -119,16 +120,16 @@ export const apiClient = {
return response.data.get_author return response.data.get_author
}, },
getAuthorId: async (params: { user: string }): Promise<Author> => {
const response = await publicGraphQLClient.query(authorId, params).toPromise()
return response.data.get_author_id
},
getAuthorFollowers: async ({ slug }: { slug: string }): Promise<Author[]> => { getAuthorFollowers: async ({ slug }: { slug: string }): Promise<Author[]> => {
const response = await publicGraphQLClient.query(authorFollowers, { slug }).toPromise() const response = await publicGraphQLClient.query(authorFollowers, { slug }).toPromise()
return response.data.get_author_followers return response.data.get_author_followers
}, },
getTopicFollowers: async ({ slug }: QueryGet_Topic_FollowersArgs): Promise<Author[]> => {
const response = await publicGraphQLClient.query(topicFollowers, { slug }).toPromise()
return response.data.get_topic_followers
},
getAuthorFollows: async (params: { getAuthorFollows: async (params: {
slug?: string slug?: string
author_id?: number author_id?: number

View File

@ -6,7 +6,24 @@ export default gql`
error error
authors { authors {
id id
name
slug slug
pic
bio
stat {
followers
shouts
comments
}
}
topics {
body
slug
stat {
shouts
authors
followers
}
} }
} }
} }

View File

@ -3,6 +3,27 @@ export default gql`
mutation UnfollowMutation($what: FollowingEntity!, $slug: String!) { mutation UnfollowMutation($what: FollowingEntity!, $slug: String!) {
unfollow(what: $what, slug: $slug) { unfollow(what: $what, slug: $slug) {
error error
authors {
id
name
slug
pic
bio
stat {
followers
shouts
comments
}
}
topics {
body
slug
stat {
shouts
authors
followers
}
}
} }
} }
` `

View File

@ -1,7 +1,7 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
query UserSubscribersQuery($slug: String, $user: String, $author_id: Int) { query UserFollowingCountersQuery($slug: String, $user: String, $author_id: Int) {
get_author_followers(slug: $slug, user: $user, author_id: $author_id) { get_author_followers(slug: $slug, user: $user, author_id: $author_id) {
id id
slug slug

View File

@ -1,14 +1,15 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
query GetAuthorId($user: String!) { query TopicFollowersQuery($slug: String) {
get_author_id(user: $user) { get_topic_followers(slug: $slug) {
id id
slug slug
name name
bio bio
about about
pic pic
# communities
links links
created_at created_at
last_seen last_seen
@ -18,8 +19,6 @@ export default gql`
followers followers
rating rating
comments comments
rating_shouts
rating_comments
} }
} }
} }

View File

@ -40,7 +40,7 @@ export const DiscussionRulesPage = () => {
людей рождается истина. людей рождается истина.
</p> </p>
<h3>За&nbsp;что можно получить дырку в&nbsp;карме и&nbsp;выиграть бан в&nbsp;сообществе</h3> <h3 id="ban">За&nbsp;что можно получить дырку в&nbsp;карме и&nbsp;выиграть бан в&nbsp;сообществе</h3>
<ol> <ol>
<li> <li>
<p> <p>

View File

@ -4,7 +4,7 @@ import { Feedback } from '../../components/Discours/Feedback'
import { Modal } from '../../components/Nav/Modal' import { Modal } from '../../components/Nav/Modal'
import Opener from '../../components/Nav/Modal/Opener' import Opener from '../../components/Nav/Modal/Opener'
import { StaticPage } from '../../components/Views/StaticPage' import { StaticPage } from '../../components/Views/StaticPage'
import { Subscribe } from '../../components/_shared/Subscribe' import { Newsletter } from '../../components/_shared/Newsletter'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { getImageUrl } from '../../utils/getImageUrl' import { getImageUrl } from '../../utils/getImageUrl'
@ -24,7 +24,7 @@ export const ManifestPage = () => {
<Feedback /> <Feedback />
</Modal> </Modal>
<Modal variant="wide" name="subscribe"> <Modal variant="wide" name="subscribe">
<Subscribe /> <Newsletter />
</Modal> </Modal>
<Meta name="descprition" content={description} /> <Meta name="descprition" content={description} />
<Meta name="keywords" content={t('keywords')} /> <Meta name="keywords" content={t('keywords')} />

View File

@ -1,6 +1,6 @@
import type { PageProps } from './types' import type { PageProps } from './types'
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js' import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js'
import { AuthorView, PRERENDERED_ARTICLES_COUNT } from '../components/Views/Author' import { AuthorView, PRERENDERED_ARTICLES_COUNT } from '../components/Views/Author'
import { Loading } from '../components/_shared/Loading' import { Loading } from '../components/_shared/Loading'
@ -20,38 +20,19 @@ export const AuthorPage = (props: PageProps) => {
Boolean(props.authorShouts) && Boolean(props.author) && props.author.slug === slug(), Boolean(props.authorShouts) && Boolean(props.author) && props.author.slug === slug(),
) )
const preload = () => {
return Promise.all([
loadShouts({
filters: { author: slug(), featured: false },
limit: PRERENDERED_ARTICLES_COUNT,
}),
loadAuthor({ slug: slug() }),
])
}
onMount(async () => {
if (isLoaded()) {
return
}
resetSortedArticles()
await preload()
setIsLoaded(true)
})
createEffect( createEffect(
on( on(slug, async (s) => {
() => slug(), if (s) {
async () => {
setIsLoaded(false) setIsLoaded(false)
resetSortedArticles() resetSortedArticles()
await preload() await loadShouts({
filters: { author: s, featured: false },
limit: PRERENDERED_ARTICLES_COUNT,
})
await loadAuthor({ slug: s })
setIsLoaded(true) setIsLoaded(true)
}, }
{ defer: true }, }),
),
) )
onCleanup(() => resetSortedArticles()) onCleanup(() => resetSortedArticles())

View File

@ -17,6 +17,7 @@ import styles from '../styles/Create.module.scss'
const handleCreate = async (layout: LayoutType) => { const handleCreate = async (layout: LayoutType) => {
const shout = await apiClient.createArticle({ article: { layout: layout } }) const shout = await apiClient.createArticle({ article: { layout: layout } })
shout?.id &&
redirectPage(router, 'edit', { redirectPage(router, 'edit', {
shoutId: shout?.id.toString(), shoutId: shout?.id.toString(),
}) })

View File

@ -7,7 +7,7 @@ import { useLocalize } from '../context/localize'
import { useSession } from '../context/session' import { useSession } from '../context/session'
import { apiClient } from '../graphql/client/core' import { apiClient } from '../graphql/client/core'
import { Shout } from '../graphql/schema/core.gen' import { Shout } from '../graphql/schema/core.gen'
import { router, useRouter } from '../stores/router' import { router } from '../stores/router'
import { redirectPage } from '@nanostores/router' import { redirectPage } from '@nanostores/router'
import { useSnackbar } from '../context/snackbar' import { useSnackbar } from '../context/snackbar'
@ -33,7 +33,6 @@ const getContentTypeTitle = (layout: LayoutType) => {
export const EditPage = () => { export const EditPage = () => {
const { t } = useLocalize() const { t } = useLocalize()
const { session } = useSession() const { session } = useSession()
const { page } = useRouter()
const snackbar = useSnackbar() const snackbar = useSnackbar()
const fail = async (error: string) => { const fail = async (error: string) => {
@ -48,18 +47,21 @@ export const EditPage = () => {
createEffect( createEffect(
on( on(
() => page(), () => window?.location.pathname,
(p) => { (p) => {
if (p?.path) { if (p) {
console.debug(p?.path) console.debug(p)
const shoutId = p?.path.split('/').pop() const shoutId = p.split('/').pop()
if (shoutId) {
const shoutIdFromUrl = Number.parseInt(shoutId ?? '0', 10) const shoutIdFromUrl = Number.parseInt(shoutId ?? '0', 10)
console.debug(`editing shout ${shoutIdFromUrl}`) console.debug(`editing shout ${shoutIdFromUrl}`)
if (shoutIdFromUrl) { if (shoutIdFromUrl) {
setShoutId(shoutIdFromUrl) setShoutId(shoutIdFromUrl)
} }
} }
}
}, },
{ defer: true },
), ),
) )

View File

@ -12,10 +12,9 @@ import { LayoutType } from '../types'
export const ExpoPage = (props: PageProps) => { export const ExpoPage = (props: PageProps) => {
const { t } = useLocalize() const { t } = useLocalize()
const { page } = useRouter() const { page } = useRouter()
const getLayout = createMemo<LayoutType>(() => page().params['layout'] as LayoutType) const layout = createMemo(() => page().params['layout'] as LayoutType)
const title = createMemo(() => {
const getTitle = () => { switch (layout()) {
switch (getLayout()) {
case 'audio': { case 'audio': {
return t('Audio') return t('Audio')
} }
@ -32,22 +31,14 @@ export const ExpoPage = (props: PageProps) => {
return t('Art') return t('Art')
} }
} }
} })
createEffect( createEffect(on(title, (t) => (document.title = t), { defer: true }))
on(
() => getLayout(),
() => {
document.title = getTitle()
},
{ defer: true },
),
)
return ( return (
<PageLayout withPadding={true} zeroBottomPadding={true} title={getTitle()}> <PageLayout withPadding={true} zeroBottomPadding={true} title={title()}>
<Topics /> <Topics />
<Expo shouts={props.expoShouts} layout={getLayout()} /> <Expo shouts={props.expoShouts} layout={layout()} />
</PageLayout> </PageLayout>
) )
} }

View File

@ -1,6 +1,5 @@
import { Match, Switch, createEffect, on, onCleanup } from 'solid-js' import { createEffect, on, onCleanup } from 'solid-js'
import { AuthGuard } from '../components/AuthGuard'
import { Feed } from '../components/Views/Feed' import { Feed } from '../components/Views/Feed'
import { PageLayout } from '../components/_shared/PageLayout' import { PageLayout } from '../components/_shared/PageLayout'
import { useLocalize } from '../context/localize' import { useLocalize } from '../context/localize'
@ -25,34 +24,14 @@ const handleMyFeedLoadShouts = (options: LoadShoutsOptions) => {
export const FeedPage = () => { export const FeedPage = () => {
const { t } = useLocalize() const { t } = useLocalize()
onCleanup(() => resetSortedArticles())
const { page } = useRouter() const { page } = useRouter()
createEffect(on(page, (_) => resetSortedArticles(), { defer: true }))
createEffect( onCleanup(() => resetSortedArticles())
on(
() => page().route,
() => {
resetSortedArticles()
},
{ defer: true },
),
)
return ( return (
<PageLayout title={t('Feed')}> <PageLayout title={t('Feed')}>
<ReactionsProvider> <ReactionsProvider>
<Switch fallback={<Feed loadShouts={handleFeedLoadShouts} />}> <Feed loadShouts={page().route === 'feedMy' ? handleMyFeedLoadShouts : handleFeedLoadShouts} />
<Match when={page().route === 'feed'}>
<Feed loadShouts={handleFeedLoadShouts} />
</Match>
<Match when={page().route === 'feedMy'}>
<AuthGuard>
<Feed loadShouts={handleMyFeedLoadShouts} />
</AuthGuard>
</Match>
</Switch>
</ReactionsProvider> </ReactionsProvider>
</PageLayout> </PageLayout>
) )

View File

@ -44,10 +44,10 @@ export const ProfileSecurityPage = () => {
createEffect( createEffect(
on( on(
() => session()?.user?.email, () => session()?.user?.email,
() => { (email) => {
setFormData((prevData) => ({ setFormData((prevData) => ({
...prevData, ...prevData,
['email']: session()?.user?.email, email,
})) }))
}, },
), ),

View File

@ -37,24 +37,22 @@ export const TopicPage = (props: PageProps) => {
}) })
createEffect( createEffect(
on( on(slug, async (s) => {
() => slug(), if (s) {
async () => {
setIsLoaded(false) setIsLoaded(false)
resetSortedArticles() resetSortedArticles()
await preload() await preload()
setIsLoaded(true) setIsLoaded(true)
}, }
{ defer: true }, }),
),
) )
onCleanup(() => resetSortedArticles()) onCleanup(resetSortedArticles)
const usePrerenderedData = props.topic?.slug === slug() const usePrerenderedData = props.topic?.slug === slug()
return ( return (
<PageLayout title={props.seo.title}> <PageLayout title={props.seo?.title || props.topic?.title}>
<ReactionsProvider> <ReactionsProvider>
<Show when={isLoaded()} fallback={<Loading />}> <Show when={isLoaded()} fallback={<Loading />}>
<TopicView <TopicView

View File

@ -53,4 +53,4 @@ export type UploadedFile = {
originalFilename?: string originalFilename?: string
} }
export type SubscriptionFilter = 'all' | 'authors' | 'topics' | 'communities' export type FollowsFilter = 'all' | 'authors' | 'topics' | 'communities'

View File

@ -9,7 +9,7 @@ import { hydrate } from 'solid-js/web'
import { App } from '../components/App' import { App } from '../components/App'
import { initRouter } from '../stores/router' import { initRouter } from '../stores/router'
import { GLITCHTIP_DSN } from '../utils/config' import { reportDsn } from '../utils/config'
import { resolveHydrationPromise } from '../utils/hydrationPromise' import { resolveHydrationPromise } from '../utils/hydrationPromise'
let layoutReady = false let layoutReady = false
@ -22,7 +22,7 @@ export const render = async (pageContext: PageContextBuiltInClientWithClientRout
initRouter(pathname, searchParams) initRouter(pathname, searchParams)
SentryInit({ SentryInit({
dsn: GLITCHTIP_DSN, dsn: reportDsn,
tracesSampleRate: 0.01, tracesSampleRate: 0.01,
integrations: [replayIntegration()], integrations: [replayIntegration()],
// Session Replay // Session Replay

View File

@ -1,20 +1,8 @@
export const isDev = import.meta.env.MODE === 'development' export const isDev = import.meta.env.MODE === 'development'
const defaultThumborUrl = 'https://images.discours.io'
export const cdnUrl = 'https://cdn.discours.io' export const cdnUrl = 'https://cdn.discours.io'
export const thumborUrl = import.meta.env.PUBLIC_THUMBOR_URL || defaultThumborUrl 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 SENTRY_DSN = import.meta.env.PUBLIC_SENTRY_DSN || '' export const coreApiUrl = import.meta.env.PUBLIC_API_BASE || 'https://core.discours.io'
export const GLITCHTIP_DSN = import.meta.env.PUBLIC_GLITCHTIP_DSN || '' 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'
const defaultSearchUrl = 'https://search.discours.io' export const sseUrl = import.meta.env.PUBLIC_REALTIME_EVENTS || 'https://connect.discours.io'
export const searchUrl = import.meta.env.PUBLIC_SEARCH_URL || defaultSearchUrl
const defaultCoreUrl = 'https://core.discours.io'
export const coreApiUrl = import.meta.env.PUBLIC_CORE_API || defaultCoreUrl
const defaultChatUrl = 'https://chat.discours.io'
export const chatApiUrl = import.meta.env.PUBLIC_CHAT_API || defaultChatUrl
const defaultAuthUrl = 'https://auth.discours.io'
export const authApiUrl = import.meta.env.PUBLIC_AUTH_API || defaultAuthUrl

View File

@ -1,9 +1,9 @@
import { UploadFile } from '@solid-primitives/upload' import { UploadFile } from '@solid-primitives/upload'
import { UploadedFile } from '../pages/types' import { UploadedFile } from '../pages/types'
import { coreApiUrl } from './config'
const apiBaseUrl = 'https://core.discours.io' const apiUrl = `${coreApiUrl}/upload`
const apiUrl = `${apiBaseUrl}/upload`
export const handleFileUpload = async (uploadFile: UploadFile, token: string): Promise<UploadedFile> => { export const handleFileUpload = async (uploadFile: UploadFile, token: string): Promise<UploadedFile> => {
const formData = new FormData() const formData = new FormData()

View File

@ -1,15 +1,15 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
const baseHost = process.env.BASE_URL const baseHost = process.env.BASE_HOST || 'https://localhost:3000'
const pagesTitles = { const pagesTitles = {
'/': /Дискурс/, '/': /Дискурс/,
'/feed': /Дискурс/, '/feed': /Лента/,
'/create': /Дискурс/, '/create': /Выберите тип публикации/,
'/about/donate': /Дискурс/, '/about/help': /Поддержите Дискурс/,
'/authors': /Дискурс/, '/authors': /Авторы/,
'/topics': /Дискурс/, '/topics': /Темы и сюжеты/,
'/inbox': /Дискурс/, '/inbox': /Входящие/,
} }
Object.keys(pagesTitles).forEach((res: string) => { Object.keys(pagesTitles).forEach((res: string) => {

View File

@ -5,13 +5,13 @@ import { chromium } from 'playwright'
// Define the URLs to visit // Define the URLs to visit
const pagesToVisit = [ const pagesToVisit = [
'http://localhost:3000/', 'https://localhost:3000/',
'http://localhost:3000/feed', 'https://localhost:3000/feed',
'http://localhost:3000/create', 'https://localhost:3000/create',
'http://localhost:3000/about/donate', 'https://localhost:3000/about/donate',
'http://localhost:3000/authors', 'https://localhost:3000/authors',
'http://localhost:3000/topics', 'https://localhost:3000/topics',
'http://localhost:3000/inbox', 'https://localhost:3000/inbox',
] ]
// Loop through the pages and visit each one // Loop through the pages and visit each one

View File

@ -14,8 +14,9 @@ const cssModuleHMR = () => {
const { modules } = context const { modules } = context
modules.forEach((module) => { modules.forEach((module) => {
if (module.id.includes('.module.scss')) { if (module.id.includes('.scss') || module.id.includes('.css')) {
module.isSelfAccepting = true module.isSelfAccepting = true
// module.accept()
} }
}) })
}, },