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
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:
runs-on: ubuntu-latest
name: Update templates on Mailgun

View File

@ -10,6 +10,9 @@ jobs:
- uses: actions/setup-node@v4
- name: Install dependencies
run: npm i
- name: Install CI checks
run: npm ci
- name: Check types
@ -23,20 +26,3 @@ jobs:
- name: Test production 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:
```
npm run fix
```
## Code generation
generate new SolidJS component:
```
npm run hygen component new NewComponentName
```
generate new SolidJS context:
```
npm run hygen context new NewContextName
npm run format
```

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

View File

@ -47,7 +47,7 @@ export default defineConfig({
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
/* Test against many viewports.
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
@ -68,10 +68,10 @@ export default defineConfig({
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
/* Run local dev server before starting the tests */
//webServer: {
// command: 'npm run dev',
// url: 'https://localhost:3000',
// 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 currentTack = createMemo(() => props.media[currentTrackIndex()])
createEffect(
on(
() => currentTrackIndex(),
() => {
setCurrentTrackDuration(0)
},
{ defer: true },
),
)
createEffect(on(currentTrackIndex, () => setCurrentTrackDuration(0), { defer: true }))
const handlePlayMedia = async (trackIndex: number) => {
setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex())

View File

@ -48,7 +48,7 @@ export const Comment = (props: Props) => {
const canEdit = createMemo(
() =>
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() || ''))

View File

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

View File

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

View File

@ -20,7 +20,7 @@
@include font-size(4rem);
font-weight: 700;
margin-bottom: 0.2em;
margin-bottom: 1.2rem;
}
.authorAbout {
@ -429,64 +429,19 @@
}
}
.listWrapper {
max-height: 70vh;
}
.subscribersContainer {
display: flex;
flex-wrap: wrap;
font-size: 1.4rem;
margin-top: 1.5rem;
gap: 1rem;
margin-top: 0;
white-space: nowrap;
@include media-breakpoint-down(md) {
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 { useSession } from '../../../context/session'
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 { isAuthor } from '../../../utils/isAuthor'
import { translit } from '../../../utils/ru2en'
@ -17,6 +17,7 @@ import { SharePopup, getShareUrl } from '../../Article/SharePopup'
import { Modal } from '../../Nav/Modal'
import { TopicBadge } from '../../Topic/TopicBadge'
import { Button } from '../../_shared/Button'
import { FollowingCounters } from '../../_shared/FollowingCounters/FollowingCounters'
import { ShowOnlyOnClient } from '../../_shared/ShowOnlyOnClient'
import { AuthorBadge } from '../AuthorBadge'
import { Userpic } from '../Userpic'
@ -27,25 +28,25 @@ import styles from './AuthorCard.module.scss'
type Props = {
author: Author
followers?: Author[]
following?: Array<Author | Topic>
flatFollows?: Array<Author | Topic>
}
export const AuthorCard = (props: Props) => {
const { t, lang } = useLocalize()
const { author, isSessionLoaded, requireAuthentication } = useSession()
const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([])
const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all')
const [isSubscribed, setIsSubscribed] = createSignal<boolean>()
const [followsFilter, setFollowsFilter] = createSignal<FollowsFilter>('all')
const [isFollowed, setIsFollowed] = createSignal<boolean>()
const isProfileOwner = createMemo(() => author()?.slug === props.author.slug)
const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing()
const { follow, unfollow, follows, following } = useFollowing()
onMount(() => {
setAuthorSubs(props.following)
setAuthorSubs(props.flatFollows)
})
createEffect(() => {
if (!(subscriptions && props.author)) return
const subscribed = subscriptions.authors?.some((authorEntity) => authorEntity.id === props.author?.id)
setIsSubscribed(subscribed)
if (!(follows && props.author)) return
const followed = follows?.authors?.some((authorEntity) => authorEntity.id === props.author?.id)
setIsFollowed(followed)
})
const name = createMemo(() => {
@ -71,33 +72,33 @@ export const AuthorCard = (props: Props) => {
}
createEffect(() => {
if (props.following) {
if (subscriptionFilter() === 'authors') {
setAuthorSubs(props.following.filter((s) => 'name' in s))
} else if (subscriptionFilter() === 'topics') {
setAuthorSubs(props.following.filter((s) => 'title' in s))
} else if (subscriptionFilter() === 'communities') {
setAuthorSubs(props.following.filter((s) => 'title' in s))
if (props.flatFollows) {
if (followsFilter() === 'authors') {
setAuthorSubs(props.flatFollows.filter((s) => 'name' in s))
} else if (followsFilter() === 'topics') {
setAuthorSubs(props.flatFollows.filter((s) => 'title' in s))
} else if (followsFilter() === 'communities') {
setAuthorSubs(props.flatFollows.filter((s) => 'title' in s))
} else {
setAuthorSubs(props.following)
setAuthorSubs(props.flatFollows)
}
}
})
const handleFollowClick = () => {
requireAuthentication(() => {
isSubscribed()
isFollowed()
? unfollow(FollowingEntity.Author, props.author.slug)
: follow(FollowingEntity.Author, props.author.slug)
}, 'subscribe')
}, 'follow')
}
const followButtonText = createMemo(() => {
if (subscribeInAction()?.slug === props.author.slug) {
return subscribeInAction().type === 'subscribe' ? t('Subscribing...') : t('Unsubscribing...')
if (following()?.slug === props.author.slug) {
return following().type === 'follow' ? t('Following...') : t('Unfollowing...')
}
if (isSubscribed()) {
if (isFollowed()) {
return (
<>
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
@ -108,6 +109,73 @@ export const AuthorCard = (props: Props) => {
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 (
<div class={clsx(styles.author, 'row')}>
<div class="col-md-5">
@ -125,59 +193,14 @@ export const AuthorCard = (props: Props) => {
<Show when={props.author.bio}>
<div class={styles.authorAbout} innerHTML={props.author.bio} />
</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}>
<Show when={props.followers && props.followers.length > 0}>
<a href="?m=followers" class={styles.subscribers}>
<For each={props.followers.slice(0, 3)}>
{(f) => (
<Userpic size={'XS'} name={f.name} userpic={f.pic} class={styles.subscribersItem} />
)}
</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>
<FollowingCounters
followers={props.followers}
followersAmount={props.author?.stat?.followers}
following={props.flatFollows}
followingAmount={props.flatFollows.length}
/>
</div>
</Show>
</div>
@ -208,11 +231,11 @@ export const AuthorCard = (props: Props) => {
<Show when={authorSubs()?.length}>
<Button
onClick={handleFollowClick}
disabled={Boolean(subscribeInAction())}
disabled={Boolean(following())}
value={followButtonText()}
isSubscribeButton={true}
class={clsx({
[stylesButton.subscribed]: isSubscribed(),
[stylesButton.followed]: isFollowed(),
})}
/>
</Show>
@ -251,77 +274,12 @@ export const AuthorCard = (props: Props) => {
</ShowOnlyOnClient>
<Show when={props.followers}>
<Modal variant="medium" isResponsive={true} name="followers" maxHeight>
<>
<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>
</>
<FollowersModalView />
</Modal>
</Show>
<Show when={props.following}>
<Show when={props.flatFollows}>
<Modal variant="medium" isResponsive={true} name="following" maxHeight>
<>
<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>
</>
<FollowingModalView />
</Modal>
</Show>
</div>

View File

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

View File

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

View File

@ -12,11 +12,6 @@ declare module '@tiptap/core' {
export default Node.create({
name: 'article',
defaultOptions: {
HTMLAttributes: {
'data-type': 'incut',
},
},
group: 'block',
content: 'block+',
@ -32,6 +27,12 @@ export default Node.create({
return ['article', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
addOptions() {
return {
'data-type': 'incut',
}
},
addAttributes() {
return {
'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'
@ -13,11 +13,13 @@ declare module '@tiptap/core' {
export const CustomBlockquote = Blockquote.extend({
name: 'blockquote',
defaultOptions: {
HTMLAttributes: {},
},
group: 'block',
content: 'block+',
addOptions(): BlockquoteOptions {
return {} as BlockquoteOptions
},
addAttributes() {
return {
'data-float': {
@ -34,14 +36,12 @@ export const CustomBlockquote = Blockquote.extend({
return {
toggleBlockquote:
(type) =>
({ commands }) => {
return commands.toggleWrap(this.name, { 'data-type': type })
},
({ commands }) =>
commands.toggleWrap(this.name, { 'data-type': type }),
setBlockQuoteFloat:
(value) =>
({ commands }) => {
return commands.updateAttributes(this.name, { 'data-float': value })
},
({ commands }) =>
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 = () => {
const { t } = useLocalize()
const { seen } = useSeen()
const { subscriptions } = useFollowing()
const { follows } = useFollowing()
const { page } = useRouter()
const { articlesByTopic, articlesByAuthor } = useArticlesStore()
const [isSubscriptionsVisible, setSubscriptionsVisible] = createSignal(true)
@ -83,35 +83,9 @@ export const Sidebar = () => {
</span>
</a>
</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>
<Show when={subscriptions.authors.length > 0 || subscriptions.topics.length > 0}>
<Show when={follows?.authors?.length > 0 || follows?.topics?.length > 0}>
<h4
classList={{ [styles.opened]: isSubscriptionsVisible() }}
onClick={() => {
@ -123,7 +97,7 @@ export const Sidebar = () => {
</h4>
<ul class={clsx(styles.subscriptions, { [styles.hidden]: !isSubscriptionsVisible() })}>
<For each={subscriptions.authors}>
<For each={follows.authors}>
{(a: Author) => (
<li>
<a href={`/author/${a.slug}`} classList={{ [styles.unread]: checkAuthorIsSeen(a.slug) }}>
@ -135,7 +109,7 @@ export const Sidebar = () => {
</li>
)}
</For>
<For each={subscriptions.topics}>
<For each={follows.topics}>
{(topic) => (
<li>
<a

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
.topicHeader {
@include font-size(1.7rem);
font-weight: 500;
padding: 2.8rem $container-padding-x 0;
text-align: center;
@ -12,10 +11,17 @@
}
}
.topicDescription {
@include font-size(1.8rem);
line-height: 1.4;
margin: 1rem 0 2rem;
}
.topicActions {
margin-top: 2.8rem;
.write {
.writeControl {
display: inline-flex;
align-items: center;
justify-content: center;
@ -23,13 +29,38 @@
min-width: 64px;
font-size: 17px;
padding: 8px 16px;
background: var(--background-color-invert);
color: var(--default-color-invert);
border: none;
border: 1px solid #f7f7f7;
background: #f7f7f7;
color: var(--default-color);
font-weight: 500;
border-radius: 2px;
cursor: pointer;
margin: 0 1.2rem 1em;
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 { Show, createEffect, createSignal } from 'solid-js'
@ -9,22 +9,25 @@ import { useSession } from '../../context/session'
import { FollowingEntity } from '../../graphql/schema/core.gen'
import { Button } from '../_shared/Button'
import { FollowingCounters } from '../_shared/FollowingCounters/FollowingCounters'
import { Icon } from '../_shared/Icon'
import styles from './Full.module.scss'
type Props = {
topic: Topic
followers?: Author[]
authors?: Author[]
}
export const FullTopic = (props: Props) => {
const { t } = useLocalize()
const { subscriptions, setFollowing } = useFollowing()
const { follows, changeFollowing } = useFollowing()
const { requireAuthentication } = useSession()
const [followed, setFollowed] = createSignal()
createEffect(() => {
const subs = subscriptions
if (subs?.topics.length !== 0) {
const items = subs.topics || []
if (follows?.topics.length !== 0) {
const items = follows.topics || []
setFollowed(items.some((x: Topic) => x?.slug === props.topic?.slug))
}
})
@ -33,26 +36,46 @@ export const FullTopic = (props: Props) => {
const really = !followed()
setFollowed(really)
requireAuthentication(() => {
setFollowing(FollowingEntity.Topic, props.topic.slug, really)
changeFollowing(FollowingEntity.Topic, props.topic.slug, really)
}, 'follow')
}
return (
<div class={clsx(styles.topicHeader, 'col-md-16 col-lg-12 offset-md-4 offset-lg-6')}>
<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)}>
<Button
variant="primary"
onClick={handleFollowClick}
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')}
</a>
</div>
<Show when={props.topic?.pic}>
<img src={props.topic.pic} alt={props.topic?.title} />
<img src={props.topic?.pic} alt={props.topic?.title} />
</Show>
</div>
)

View File

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

View File

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

View File

@ -1,32 +1,30 @@
import type { Author, Reaction, Shout, Topic } from '../../../graphql/schema/core.gen'
import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx'
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize'
import { Meta, Title } from '../../../context/meta'
import { useSession } from '../../../context/session'
import { apiClient } from '../../../graphql/client/core'
import type { Author, Reaction, Shout, Topic } from '../../../graphql/schema/core.gen'
import { router, useRouter } from '../../../stores/router'
import { MODALS, hideModal } from '../../../stores/ui'
import { loadShouts, useArticlesStore } from '../../../stores/zine/articles'
import { loadAuthor } from '../../../stores/zine/authors'
import { getImageUrl } from '../../../utils/getImageUrl'
import { getDescription } from '../../../utils/meta'
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
import { byCreated } from '../../../utils/sortby'
import { splitToPages } from '../../../utils/splitToPages'
import stylesArticle from '../../Article/Article.module.scss'
import { Comment } from '../../Article/Comment'
import { AuthorCard } from '../../Author/AuthorCard'
import { AuthorShoutsRating } from '../../Author/AuthorShoutsRating'
import { Placeholder } from '../../Feed/Placeholder'
import { Row1 } from '../../Feed/Row1'
import { Row2 } from '../../Feed/Row2'
import { Row3 } from '../../Feed/Row3'
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'
type Props = {
@ -34,71 +32,26 @@ type Props = {
shouts?: Shout[]
author?: Author
}
export const PRERENDERED_ARTICLES_COUNT = 12
const LOAD_MORE_PAGE_SIZE = 9
export const AuthorView = (props: Props) => {
const { t } = useLocalize()
const { followers: myFollowers } = useFollowing()
const { session } = useSession()
const { followers: myFollowers, follows: myFollows } = useFollowing()
const { author: me } = useSession()
const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
const { page: getPage, searchParams } = useRouter()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [isBioExpanded, setIsBioExpanded] = createSignal(false)
const [author, setAuthor] = createSignal<Author>()
const [author, setAuthor] = createSignal<Author>(props.author)
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 [commented, setCommented] = createSignal<Reaction[]>()
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 () => {
saveScrollPosition()
const { hasMore } = await loadShouts({
@ -110,36 +63,72 @@ export const AuthorView = (props: Props) => {
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(() => {
if (!modal) hideModal()
fetchData(props.authorSlug)
checkBioHeight()
loadMore()
})
const pages = createMemo<Shout[][]>(() =>
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(() =>
author()?.pic
? getImageUrl(author()?.pic, { width: 1200 })
@ -168,16 +157,12 @@ export const AuthorView = (props: Props) => {
<Show when={author()} fallback={<Loading />}>
<>
<div class={styles.authorHeader}>
<AuthorCard author={author()} followers={followers() || []} following={following() || []} />
<AuthorCard author={author()} followers={followers() || []} flatFollows={following() || []} />
</div>
<div class={clsx(styles.groupControls, 'row')}>
<div class="col-md-16">
<ul class="view-switcher">
<li
classList={{
'view-switcher__item--selected': getPage().route === 'author',
}}
>
<li classList={{ 'view-switcher__item--selected': getPage().route === 'author' }}>
<a
href={getPagePath(router, 'author', {
slug: props.authorSlug,
@ -189,11 +174,7 @@ export const AuthorView = (props: Props) => {
<span class="view-switcher__counter">{author().stat.shouts}</span>
</Show>
</li>
<li
classList={{
'view-switcher__item--selected': getPage().route === 'authorComments',
}}
>
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorComments' }}>
<a
href={getPagePath(router, 'authorComments', {
slug: props.authorSlug,
@ -205,11 +186,7 @@ export const AuthorView = (props: Props) => {
<span class="view-switcher__counter">{author().stat.comments}</span>
</Show>
</li>
<li
classList={{
'view-switcher__item--selected': getPage().route === 'authorAbout',
}}
>
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorAbout' }}>
<a
onClick={() => checkBioHeight()}
href={getPagePath(router, 'authorAbout', {
@ -260,6 +237,12 @@ export const AuthorView = (props: Props) => {
</div>
</Match>
<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="row">
<div class="col-md-20 col-lg-18">
@ -280,46 +263,47 @@ export const AuthorView = (props: Props) => {
</div>
</Match>
<Match when={getPage().route === 'author'}>
<Show when={sortedArticles().length === 1}>
<Show when={me()?.slug === props.authorSlug && !me().stat?.shouts}>
<div class="wide-container">
<Placeholder type={getPage().route} mode="profile" />
</div>
</Show>
<Show when={sortedArticles().length > 0}>
<Row1 article={sortedArticles()[0]} noauthor={true} nodate={true} />
</Show>
<Show when={sortedArticles().length === 2}>
<Row2 articles={sortedArticles()} isEqual={true} noauthor={true} nodate={true} />
</Show>
<Show when={sortedArticles().length > 1}>
<Switch>
<Match when={sortedArticles().length === 2}>
<Row2 articles={sortedArticles()} isEqual={true} noauthor={true} nodate={true} />
</Match>
<Match when={sortedArticles().length === 3}>
<Row3 articles={sortedArticles()} noauthor={true} nodate={true} />
</Match>
<Match when={sortedArticles().length > 3}>
<For each={pages()}>
{(page) => (
<>
<Row1 article={page[0]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(1, 3)} isEqual={true} noauthor={true} />
<Row1 article={page[3]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(4, 6)} isEqual={true} noauthor={true} />
<Row1 article={page[6]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(7, 9)} isEqual={true} noauthor={true} />
</>
)}
</For>
</Match>
</Switch>
</Show>
<Show when={sortedArticles().length === 3}>
<Row3 articles={sortedArticles()} noauthor={true} nodate={true} />
</Show>
<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()}>
{(page) => (
<>
<Row1 article={page[0]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(1, 3)} isEqual={true} noauthor={true} />
<Row1 article={page[3]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(4, 6)} isEqual={true} noauthor={true} />
<Row1 article={page[6]} noauthor={true} nodate={true} />
<Row2 articles={page.slice(7, 9)} isEqual={true} noauthor={true} />
</>
)}
</For>
</Show>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
</Show>
</Show>
</Match>
</Switch>

View File

@ -15,20 +15,24 @@ import styles from './DraftsView.module.scss'
export const DraftsView = () => {
const { author, loadSession } = useSession()
const [drafts, setDrafts] = createSignal<Shout[]>([])
const [loading, setLoading] = createSignal(false)
createEffect(
on(
() => author(),
async (a) => {
if (a) {
setLoading(true)
const { shouts: loadedDrafts, error } = await apiClient.getDrafts()
if (error) {
console.warn(error)
await loadSession()
}
setDrafts(loadedDrafts || [])
setLoading(false)
}
},
{ defer: true },
),
)
@ -50,7 +54,7 @@ export const DraftsView = () => {
return (
<div class={clsx(styles.DraftsView)}>
<Show when={author()?.id} fallback={<Loading />}>
<Show when={!loading() && author()?.id} fallback={<Loading />}>
<div class="wide-container">
<div class="row">
<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 { Loading } from '../../_shared/Loading'
import styles from './EditView.module.scss'
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor'))
@ -145,7 +146,7 @@ export const EditView = (props: Props) => {
const handleMediaDelete = (index) => {
const copy = [...mediaItems()]
copy.splice(index, 1)
if (copy?.length > 0) copy.splice(index, 1)
handleInputChange('media', JSON.stringify(copy))
}
@ -403,7 +404,7 @@ export const EditView = (props: Props) => {
</Show>
</div>
</div>
<Show when={page().route === 'edit'}>
<Show when={page().route === 'edit' && form?.shoutId} fallback={<Loading />}>
<Editor
shoutId={form.shoutId}
initialContent={form.body}

View File

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

View File

@ -20,6 +20,7 @@ import { getShareUrl } from '../../Article/SharePopup'
import { AuthorBadge } from '../../Author/AuthorBadge'
import { AuthorLink } from '../../Author/AuthorLink'
import { ArticleCard } from '../../Feed/ArticleCard'
import { Placeholder } from '../../Feed/Placeholder'
import { Sidebar } from '../../Feed/Sidebar'
import { Modal } from '../../Nav/Modal'
import { DropDown } from '../../_shared/DropDown'
@ -100,7 +101,7 @@ export const FeedView = (props: Props) => {
const { page, searchParams, changeSearchParams } = useRouter<FeedSearchParams>()
const [isLoading, setIsLoading] = createSignal(false)
const [isRightColumnLoaded, setIsRightColumnLoaded] = createSignal(false)
const { session } = useSession()
const { author, session } = useSession()
const { loadReactionsBy } = useReactions()
const { sortedArticles } = useArticlesStore()
const { topTopics } = useTopics()
@ -143,16 +144,20 @@ export const FeedView = (props: Props) => {
Promise.all([loadTopComments()]).finally(() => setIsRightColumnLoaded(true))
})
createEffect(() => {
if (session()?.access_token && !unratedArticles()) {
loadUnratedArticles()
}
})
createEffect(
on(
[() => session(), unratedArticles],
([s, seen]) => {
if (s?.access_token && !(seen?.length > 0)) loadUnratedArticles()
},
{ defer: true },
),
)
createEffect(
on(
() => page().route + searchParams().by + searchParams().period + searchParams().visibility,
() => {
[page, searchParams],
(_, _p) => {
resetSortedArticles()
loadMore()
},
@ -234,107 +239,113 @@ export const FeedView = (props: Props) => {
</div>
<div class="col-md-12 offset-xl-1">
<div class={styles.filtersContainer}>
<ul class={clsx('view-switcher', styles.feedFilter)}>
<li
class={clsx({
'view-switcher__item--selected':
searchParams().by === 'publish_date' || !searchParams().by,
})}
>
<a href={getPagePath(router, page().route)}>{t('Recent')}</a>
</li>
{/*<li>*/}
{/* <a href="/feed/?by=views">{t('Most read')}</a>*/}
{/*</li>*/}
<li
class={clsx({
'view-switcher__item--selected': searchParams().by === 'likes',
})}
>
<span class="link" onClick={() => changeSearchParams({ by: 'likes' })}>
{t('Top rated')}
</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': searchParams().by === 'last_comment',
})}
>
<span class="link" onClick={() => changeSearchParams({ by: 'last_comment' })}>
{t('Most commented')}
</span>
</li>
</ul>
<div class={styles.dropdowns}>
<Show when={searchParams().by && searchParams().by !== 'publish_date'}>
<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}>
<ul class={clsx('view-switcher', styles.feedFilter)}>
<li
class={clsx({
'view-switcher__item--selected':
searchParams().by === 'publish_date' || !searchParams().by,
})}
>
<a href={getPagePath(router, page().route)}>{t('Recent')}</a>
</li>
{/*<li>*/}
{/* <a href="/feed/?by=views">{t('Most read')}</a>*/}
{/*</li>*/}
<li
class={clsx({
'view-switcher__item--selected': searchParams().by === 'likes',
})}
>
<span class="link" onClick={() => changeSearchParams({ by: 'likes' })}>
{t('Top rated')}
</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': searchParams().by === 'last_comment',
})}
>
<span class="link" onClick={() => changeSearchParams({ by: 'last_comment' })}>
{t('Most commented')}
</span>
</li>
</ul>
<div class={styles.dropdowns}>
<Show when={searchParams().by && searchParams().by !== 'publish_date'}>
<DropDown
popupProps={{ horizontalAnchor: 'right' }}
options={periods}
currentOption={currentPeriod()}
triggerCssClass={styles.periodSwitcher}
onChange={(period: PeriodItem) => changeSearchParams({ period: period.value })}
/>
</Show>
<DropDown
popupProps={{ horizontalAnchor: 'right' }}
options={periods}
currentOption={currentPeriod()}
options={visibilities}
currentOption={currentVisibility()}
triggerCssClass={styles.periodSwitcher}
onChange={(period: PeriodItem) => changeSearchParams({ period: period.value })}
onChange={(visibility: VisibilityItem) =>
changeSearchParams({ visibility: visibility.value })
}
/>
</Show>
<DropDown
popupProps={{ horizontalAnchor: 'right' }}
options={visibilities}
currentOption={currentVisibility()}
triggerCssClass={styles.periodSwitcher}
onChange={(visibility: VisibilityItem) =>
changeSearchParams({ visibility: visibility.value })
}
/>
</div>
</div>
</div>
<Show when={!isLoading()} fallback={<Loading />}>
<Show when={sortedArticles().length > 0}>
<For each={sortedArticles().slice(0, 4)}>
{(article) => (
<ArticleCard
onShare={(shared) => handleShare(shared)}
onInvite={() => showModal('inviteMembers')}
article={article}
settings={{ isFeedMode: true }}
desktopCoverSize="M"
/>
)}
</For>
<Show when={!isLoading()} fallback={<Loading />}>
<Show when={sortedArticles().length > 0}>
<For each={sortedArticles().slice(0, 4)}>
{(article) => (
<ArticleCard
onShare={(shared) => handleShare(shared)}
onInvite={() => showModal('inviteMembers')}
article={article}
settings={{ isFeedMode: true }}
desktopCoverSize="M"
/>
)}
</For>
<div class={styles.asideSection}>
<div class={stylesBeside.besideColumnTitle}>
<h4>{t('Popular authors')}</h4>
<a href="/authors">
{t('All authors')}
<Icon name="arrow-right" class={stylesBeside.icon} />
</a>
<div class={styles.asideSection}>
<div class={stylesBeside.besideColumnTitle}>
<h4>{t('Popular authors')}</h4>
<a href="/authors">
{t('All authors')}
<Icon name="arrow-right" class={stylesBeside.icon} />
</a>
</div>
<ul class={stylesBeside.besideColumn}>
<For each={topAuthors().slice(0, 5)}>
{(author) => (
<li>
<AuthorBadge author={author} />
</li>
)}
</For>
</ul>
</div>
<ul class={stylesBeside.besideColumn}>
<For each={topAuthors().slice(0, 5)}>
{(author) => (
<li>
<AuthorBadge author={author} />
</li>
)}
</For>
</ul>
</div>
<For each={sortedArticles().slice(4)}>
{(article) => (
<ArticleCard article={article} settings={{ isFeedMode: true }} desktopCoverSize="M" />
)}
</For>
</Show>
<For each={sortedArticles().slice(4)}>
{(article) => (
<ArticleCard article={article} settings={{ isFeedMode: true }} desktopCoverSize="M" />
)}
</For>
</Show>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
</Show>
</Show>
</Show>
</div>

View File

@ -136,21 +136,18 @@ export const InboxView = (props: Props) => {
}
createEffect(
on(
() => messages(),
() => {
if (!messagesContainerRef.current) {
return
}
if (messagesContainerRef.current.scrollTop >= messagesContainerRef.current.scrollHeight) {
return
}
messagesContainerRef.current.scroll({
top: messagesContainerRef.current.scrollHeight,
behavior: 'smooth',
})
},
),
on(messages, () => {
if (!messagesContainerRef.current) {
return
}
if (messagesContainerRef.current.scrollTop >= messagesContainerRef.current.scrollHeight) {
return
}
messagesContainerRef.current.scroll({
top: messagesContainerRef.current.scrollHeight,
behavior: 'smooth',
})
}),
{ defer: true },
)
const handleScrollMessageContainer = () => {

View File

@ -1,12 +1,11 @@
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 { useLocalize } from '../../../context/localize'
import { Author, Topic } from '../../../graphql/schema/core.gen'
import { SubscriptionFilter } from '../../../pages/types'
import { FollowsFilter } from '../../../pages/types'
import { dummyFilter } from '../../../utils/dummyFilter'
// TODO: refactor styles
import { isAuthor } from '../../../utils/isAuthor'
import { AuthorBadge } from '../../Author/AuthorBadge'
import { ProfileSettingsNavigation } from '../../Nav/ProfileSettingsNavigation'
@ -19,30 +18,30 @@ import stylesSettings from '../../../styles/FeedSettings.module.scss'
export const ProfileSubscriptions = () => {
const { t, lang } = useLocalize()
const { subscriptions } = useFollowing()
const [following, setFollowing] = createSignal<Array<Author | Topic>>([])
const { follows } = useFollowing()
const [flatFollows, setFlatFollows] = 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('')
createEffect(() => {
const { authors, topics } = subscriptions
if (authors || topics) {
const fdata = [...(authors || []), ...(topics || [])]
setFollowing(fdata)
if (subscriptionFilter() === 'authors') {
setFiltered(fdata.filter((s) => 'name' in s))
} else if (subscriptionFilter() === 'topics') {
setFiltered(fdata.filter((s) => 'title' in s))
createEffect(() => setFlatFollows([...(follows?.authors || []), ...(follows?.topics || [])]))
createEffect(
on([flatFollows, followsFilter], ([flat, mode]) => {
if (mode === 'authors') {
setFiltered(flat.filter((s) => 'name' in s))
} else if (mode === 'topics') {
setFiltered(flat.filter((s) => 'title' in s))
} else {
setFiltered(fdata)
setFiltered(flat)
}
}
})
}),
{ defer: true },
)
createEffect(() => {
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">
<h1>{t('My subscriptions')}</h1>
<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">
<li
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')}
</button>
</li>
<li
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')}
</button>
</li>
<li
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')}
</button>
</li>
@ -104,9 +103,9 @@ export const ProfileSubscriptions = () => {
{(followingItem) => (
<div>
{isAuthor(followingItem) ? (
<AuthorBadge minimizeSubscribeButton={true} author={followingItem} />
<AuthorBadge minimize={true} author={followingItem} />
) : (
<TopicBadge minimizeSubscribeButton={true} topic={followingItem} />
<TopicBadge minimize={true} topic={followingItem} />
)}
</div>
)}

View File

@ -40,7 +40,18 @@ const EMPTY_TOPIC: Topic = {
id: -1,
slug: '',
}
const emptyConfig = {
interface FormConfig {
coverImageUrl?: string
mainTopic?: Topic
slug?: string
title?: string
subtitle?: string
description?: string
selectedTopics?: Topic[]
}
const emptyConfig: FormConfig = {
coverImageUrl: '',
mainTopic: EMPTY_TOPIC,
slug: '',
@ -78,7 +89,7 @@ export const PublishSettings = (props: Props) => {
}
})
const [settingsForm, setSettingsForm] = createStore(emptyConfig)
const [settingsForm, setSettingsForm] = createStore<FormConfig>(emptyConfig)
onMount(() => {
setSettingsForm(initialData())
@ -96,12 +107,12 @@ export const PublishSettings = (props: Props) => {
setSettingsForm('coverImageUrl', '')
}
const handleTopicSelectChange = (newSelectedTopics) => {
const handleTopicSelectChange = (newSelectedTopics: Topic[]) => {
if (
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 {
...prev,
mainTopic: newSelectedTopics[0],
@ -193,7 +204,8 @@ export const PublishSettings = (props: Props) => {
fieldName={t('Header')}
placeholder={t('Come up with a title for your story')}
initialValue={settingsForm.title}
value={(value) => setSettingsForm('title', value)}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
value={(value: any) => setSettingsForm('title', value)}
allowEnterKey={false}
maxLength={100}
/>
@ -203,7 +215,8 @@ export const PublishSettings = (props: Props) => {
fieldName={t('Subheader')}
placeholder={t('Come up with a subtitle for your story')}
initialValue={settingsForm.subtitle || ''}
value={(value) => setSettingsForm('subtitle', value)}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
value={(value: any) => setSettingsForm('subtitle', value)}
allowEnterKey={false}
maxLength={100}
/>
@ -214,7 +227,8 @@ export const PublishSettings = (props: Props) => {
placeholder={t('Write a short introduction')}
label={t('Description')}
initialContent={composeDescription()}
onChange={(value) => setForm('description', value)}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
onChange={(value: any) => setForm('description', value)}
maxLength={DESCRIPTION_MAX_LENGTH}
/>
</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 { For, Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
@ -33,6 +33,7 @@ interface Props {
topic: Topic
shouts: Shout[]
topicSlug: string
followers?: Author[]
}
export const PRERENDERED_ARTICLES_COUNT = 28
@ -49,13 +50,30 @@ export const TopicView = (props: Props) => {
const [reactedTopMonthArticles, setReactedTopMonthArticles] = createSignal<Shout[]>([])
const [topic, setTopic] = createSignal<Topic>()
createEffect(
on([() => props.topicSlug, topic, topicEntities], async ([slug, t, ttt]) => {
if (slug && !t && ttt) {
const current = ttt[slug]
console.debug(current)
setTopic(current)
await loadTopicFollowers()
await loadTopicAuthors()
await loadRandom()
}
}),
)
createEffect(() => {
const topics = topicEntities()
if (props.topicSlug && !topic() && topics) {
setTopic(topics[props.topicSlug])
}
})
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 options: LoadShoutsOptions = {
@ -87,14 +105,6 @@ export const TopicView = (props: Props) => {
loadReactedTopMonthArticles(topic()?.slug)
}
createEffect(
on(
() => topic(),
() => loadRandom(),
{ defer: true },
),
)
const title = createMemo(
() =>
`#${capitalize(
@ -158,7 +168,7 @@ export const TopicView = (props: Props) => {
<Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={title()} />
<Meta name="twitter:description" content={description()} />
<FullTopic topic={topic()} />
<FullTopic topic={topic()} followers={followers()} authors={topicAuthors()} />
<div class="wide-container">
<div class={clsx(styles.groupControls, 'row group__controls')}>
<div class="col-md-16">

View File

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

View File

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

View File

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

View File

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

View File

@ -6,12 +6,12 @@ import { validateEmail } from '../../../utils/validateEmail'
import { Button } from '../Button'
import { Icon } from '../Icon'
import styles from './Subscribe.module.scss'
import styles from './Newsletter.module.scss'
type Props = {
variant?: 'mobileSubscription'
}
export const Subscribe = (props: Props) => {
export const Newsletter = (props: Props) => {
const { t } = useLocalize()
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(
on(
() => props.images.length,
() => {
(_) => {
mainSwipeRef.current?.swiper.update()
thumbSwipeRef.current?.swiper.update()
},
@ -121,17 +121,19 @@ export const EditorSwiper = (props: Props) => {
const handleChangeIndex = (direction: 'left' | 'right', index: number) => {
const images = [...props.images]
if (direction === 'left' && index > 0) {
const copy = images.splice(index, 1)[0]
images.splice(index - 1, 0, copy)
} else if (direction === 'right' && index < images.length - 1) {
const copy = images.splice(index, 1)[0]
images.splice(index + 1, 0, copy)
if (images?.length > 0) {
if (direction === 'left' && index > 0) {
const copy = images.splice(index, 1)[0]
images.splice(index - 1, 0, copy)
} else if (direction === 'right' && index < images.length - 1) {
const copy = images.splice(index, 1)[0]
images.splice(index + 1, 0, copy)
}
props.onImagesSorted(images)
setTimeout(() => {
mainSwipeRef.current.swiper.slideTo(direction === 'left' ? index - 1 : index + 1)
}, 0)
}
props.onImagesSorted(images)
setTimeout(() => {
mainSwipeRef.current.swiper.slideTo(direction === 'left' ? index - 1 : index + 1)
}, 0)
}
const handleSaveBeforeSlideChange = () => {

View File

@ -45,7 +45,7 @@ export const ImageSwiper = (props: Props) => {
createEffect(
on(
() => props.images.length,
() => {
(_) => {
mainSwipeRef.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 { 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 { sseUrl } from '../utils/config'
import { useSession } from './session'
const RECONNECT_TIMES = 2
@ -38,51 +39,57 @@ export const ConnectProvider = (props: { children: JSX.Element }) => {
setHandlers((hhh) => [...hhh, handler])
}
createEffect(async () => {
const token = session()?.access_token
if (token && !connected() && retried() <= RECONNECT_TIMES) {
console.info('[context.connect] init SSE connection')
try {
await fetchEventSource('https://connect.discours.io', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
onmessage(event) {
const m: SSEMessage = JSON.parse(event.data || '{}')
console.log('[context.connect] Received message:', m)
messageHandlers().forEach((handler) => handler(m))
},
onopen: (response) => {
console.log('[context.connect] SSE connection opened', response)
if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
setConnected(true)
setRetried(0)
return Promise.resolve()
}
return Promise.reject(`SSE: cannot connect to real-time updates, status: ${response.status}`)
},
onclose() {
console.log('[context.connect] SSE connection closed by server')
setConnected(false)
if (retried() < RECONNECT_TIMES) {
setRetried((r) => r + 1)
}
},
onerror(err) {
console.error('[context.connect] SSE connection error:', err)
setConnected(false)
if (retried() < RECONNECT_TIMES) {
setRetried((r) => r + 1)
} else throw Error(err)
},
})
} catch (error) {
console.error('[context.connect] SSE connection failed:', error)
}
}
})
createEffect(
on(
() => session()?.access_token,
async (tkn) => {
if (!sseUrl) return
if (!tkn) return
if (!connected() && retried() <= RECONNECT_TIMES) {
console.info('[context.connect] got token, init SSE connection')
try {
await fetchEventSource(sseUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: tkn,
},
onmessage(event) {
const m: SSEMessage = JSON.parse(event.data || '{}')
console.log('[context.connect] Received message:', m)
messageHandlers().forEach((handler) => handler(m))
},
onopen: (response) => {
console.log('[context.connect] SSE connection opened', response)
if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
setConnected(true)
setRetried(0)
return Promise.resolve()
}
return Promise.reject(`SSE: cannot connect to real-time updates: ${response.status}`)
},
onclose() {
console.log('[context.connect] SSE connection closed by server')
setConnected(false)
if (retried() < RECONNECT_TIMES) {
setRetried((r) => r + 1)
} else throw Error('closed by server')
},
onerror(err) {
console.error('[context.connect] SSE connection error:', err)
setConnected(false)
if (retried() < RECONNECT_TIMES) {
setRetried((r) => r + 1)
} else throw Error(err)
},
})
} catch (error) {
console.error('[context.connect] SSE connection failed:', error)
}
}
},
),
)
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 { 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'
export type SubscriptionsData = {
topics?: Topic[]
authors?: Author[]
communities?: Community[]
}
type SubscribeAction = { slug: string; type: 'subscribe' | 'unsubscribe' }
type FollowingData = { slug: string; type: 'follow' | 'unfollow' }
interface FollowingContextType {
loading: Accessor<boolean>
followers: Accessor<Author[]>
subscriptions: AuthorFollowsResult
setSubscriptions: (subscriptions: AuthorFollowsResult) => void
setFollowing: (what: FollowingEntity, slug: string, value: boolean) => void
loadSubscriptions: () => void
setFollows: (follows: AuthorFollowsResult) => void
following: Accessor<FollowingData>
changeFollowing: (what: FollowingEntity, slug: string, value: boolean) => void
follows: AuthorFollowsResult
loadFollows: () => void
follow: (what: FollowingEntity, slug: string) => Promise<void>
unfollow: (what: FollowingEntity, slug: string) => Promise<void>
// followers: Accessor<Author[]>
subscribeInAction?: Accessor<SubscribeAction>
}
const FollowingContext = createContext<FollowingContextType>()
@ -42,7 +39,7 @@ const EMPTY_SUBSCRIPTIONS: AuthorFollowsResult = {
export const FollowingProvider = (props: { children: JSX.Element }) => {
const [loading, setLoading] = createSignal<boolean>(false)
const [followers, setFollowers] = createSignal<Author[]>([])
const [subscriptions, setSubscriptions] = createStore<AuthorFollowsResult>(EMPTY_SUBSCRIPTIONS)
const [follows, setFollows] = createStore<AuthorFollowsResult>(EMPTY_SUBSCRIPTIONS)
const { author, session } = useSession()
const fetchData = async () => {
@ -51,68 +48,67 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
if (apiClient.private) {
console.debug('[context.following] fetching subs data...')
const result = await apiClient.getAuthorFollows({ user: session()?.user.id })
setSubscriptions(result || EMPTY_SUBSCRIPTIONS)
setFollows(result || EMPTY_SUBSCRIPTIONS)
}
} catch (error) {
console.info('[context.following] cannot get subs', error)
console.warn('[context.following] cannot get subs', error)
} finally {
setLoading(false)
}
}
createEffect(() => {
console.info('[context.following] subs:', subscriptions)
})
const [subscribeInAction, setSubscribeInAction] = createSignal<SubscribeAction>()
const [following, setFollowing] = createSignal<FollowingData>()
const follow = async (what: FollowingEntity, slug: string) => {
if (!author()) return
setSubscribeInAction({ slug, type: 'subscribe' })
setFollowing({ slug, type: 'follow' })
try {
const subscriptionData = await apiClient.follow({ what, slug })
setSubscriptions((prevSubscriptions) => {
if (!prevSubscriptions[what]) prevSubscriptions[what] = []
prevSubscriptions[what].push(subscriptionData)
return prevSubscriptions
const result = await apiClient.follow({ what, slug })
setFollows((subs) => {
if (result.authors) subs['authors'] = result.authors || []
if (result.topics) subs['topics'] = result.topics || []
return subs
})
} catch (error) {
console.error(error)
} finally {
setSubscribeInAction() // Сбрасываем состояние действия подписки.
setFollowing() // Сбрасываем состояние действия подписки.
}
}
const unfollow = async (what: FollowingEntity, slug: string) => {
if (!author()) return
setSubscribeInAction({ slug: slug, type: 'unsubscribe' })
setFollowing({ slug: slug, type: 'unfollow' })
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) {
console.error(error)
} finally {
setSubscribeInAction()
setFollowing()
}
}
createEffect(() => {
if (author()) {
try {
const appdata = session()?.user.app_data
createEffect(
on(
() => session()?.user.app_data,
(appdata) => {
if (appdata) {
const { authors, followers, topics } = appdata
setSubscriptions({ authors, topics })
setFollows({ authors, topics })
setFollowers(followers)
if (!authors) fetchData()
}
} catch (e) {
console.error(e)
}
}
})
},
),
)
const setFollowing = (what: FollowingEntity, slug: string, value = true) => {
setSubscriptions((prevSubscriptions) => {
const updatedSubs = { ...prevSubscriptions }
const changeFollowing = (what: FollowingEntity, slug: string, value = true) => {
setFollows((fff) => {
const updatedSubs = { ...fff }
if (!updatedSubs[what]) updatedSubs[what] = []
if (value) {
const exists = updatedSubs[what]?.some((entity) => entity.slug === slug)
@ -133,15 +129,14 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
const value: FollowingContextType = {
loading,
subscriptions,
setSubscriptions,
setFollowing,
follows,
setFollows,
following,
changeFollowing,
followers,
loadSubscriptions: fetchData,
loadFollows: fetchData,
follow,
unfollow,
// followers,
subscribeInAction,
}
return <FollowingContext.Provider value={value}>{props.children}</FollowingContext.Provider>

View File

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

View File

@ -32,27 +32,26 @@ import { inboxClient } from '../graphql/client/chat'
import { apiClient } from '../graphql/client/core'
import { useRouter } from '../stores/router'
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 { useSnackbar } from './snackbar'
const defaultConfig: ConfigType = {
authorizerURL: 'https://auth.discours.io',
authorizerURL: authApiUrl.replace('/graphql', ''),
redirectURL: 'https://testing.discours.io',
clientID: 'b9038a34-ca59-41ae-a105-c7fbea603e24', // FIXME: use env?
clientID: '',
}
export type SessionContextType = {
config: Accessor<ConfigType>
session: Resource<AuthToken>
author: Resource<Author | null>
author: Accessor<Author>
authError: Accessor<string>
isSessionLoaded: Accessor<boolean>
loadSession: () => AuthToken | Promise<AuthToken>
setSession: (token: AuthToken | null) => void // setSession
loadAuthor: (info?: unknown) => Author | Promise<Author>
setAuthor: (a: Author) => void
requireAuthentication: (
callback: (() => Promise<void>) | (() => void),
modalSource: AuthModalSource,
@ -66,16 +65,39 @@ export type SessionContextType = {
params: ForgotPasswordInput,
) => Promise<{ data: ForgotPasswordResponse; errors: Error[] }>
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
authorizer: () => Authorizer
isRegistered: (email: string) => Promise<string>
resendVerifyEmail: (params: ResendVerifyEmailInput) => Promise<GenericResponse>
}
// biome-ignore lint/suspicious/noEmptyBlockStatements: <explanation>
const noop = () => {}
const noop = () => null
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>()
export function useSession() {
@ -96,15 +118,15 @@ export const SessionProvider = (props: {
// handle auth state callback
createEffect(
on(
() => searchParams()?.state,
(state) => {
if (state) {
setOauthState((_s) => state)
const scope = searchParams()?.scope
? searchParams()?.scope?.toString().split(' ')
searchParams,
(params) => {
if (params?.state) {
setOauthState((_s) => params?.state)
const scope = params?.scope
? params?.scope?.toString().split(' ')
: ['openid', 'profile', 'email']
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] }))
changeSearchParams({ mode: 'confirm-email', m: 'auth' }, true)
}
@ -202,75 +224,56 @@ export const SessionProvider = (props: {
onCleanup(() => clearTimeout(minuteLater))
const authorData = async () => {
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,
})
const [author, setAuthor] = createSignal<Author>()
// when session is loaded
createEffect(() => {
if (session()) {
const token = session()?.access_token
if (token) {
if (!inboxClient.private) {
apiClient.connect(token)
inboxClient.connect(token)
}
try {
const appdata = session()?.user.app_data
if (appdata) {
const { profile } = appdata
if (profile?.id) {
setAuthor(profile)
addAuthors([profile])
createEffect(
on(
() => session(),
async (s: AuthToken) => {
if (s) {
const token = s?.access_token
const profile = s?.user?.app_data?.profile
if (token && !inboxClient.private) {
apiClient.connect(token)
inboxClient.connect(token)
}
if (profile?.id) {
addAuthors([profile])
setAuthor(profile)
setIsSessionLoaded(true)
} else {
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 {
setTimeout(loadAuthor, 15)
console.warn(s)
setSession(null)
setAuthor(null)
setIsSessionLoaded(true)
}
}
} catch (e) {
console.error(e)
}
setIsSessionLoaded(true)
}
}
})
// when author is loaded
createEffect(() => {
if (author()) {
addAuthors([author()])
} else {
reset()
}
})
const reset = () => {
setIsSessionLoaded(true)
setSession(null)
setAuthor(null)
}
},
{ defer: true },
),
)
// initial effect
onMount(async () => {
const metaRes = await authorizer().getMetaData()
onMount(() => {
setConfig({
...defaultConfig,
...metaRes,
redirectURL: window.location.origin,
})
let s: AuthToken
try {
s = await loadSession()
} catch (error) {
console.warn('[context.session] load session failed', error)
}
if (!s) reset()
loadSession()
})
// callback state updater
@ -316,8 +319,10 @@ export const SessionProvider = (props: {
const signOut = async () => {
const authResult: ApiResponse<GenericResponse> = await authorizer().logout()
console.debug(authResult)
reset()
setSession(null)
setIsSessionLoaded(true)
showSnackbar({ body: t("You've successfully logged out") })
console.debug(session())
}
const changePassword = async (password: string, token: string) => {
@ -391,9 +396,7 @@ export const SessionProvider = (props: {
updateProfile,
setIsSessionLoaded,
setSession,
setAuthor,
authorizer,
loadAuthor,
forgotPassword,
changePassword,
oauth,

View File

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

View File

@ -6,7 +6,24 @@ export default gql`
error
authors {
id
name
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!) {
unfollow(what: $what, slug: $slug) {
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'
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) {
id
slug

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
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 { Loading } from '../components/_shared/Loading'
@ -20,38 +20,19 @@ export const AuthorPage = (props: PageProps) => {
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(
on(
() => slug(),
async () => {
on(slug, async (s) => {
if (s) {
setIsLoaded(false)
resetSortedArticles()
await preload()
await loadShouts({
filters: { author: s, featured: false },
limit: PRERENDERED_ARTICLES_COUNT,
})
await loadAuthor({ slug: s })
setIsLoaded(true)
},
{ defer: true },
),
}
}),
)
onCleanup(() => resetSortedArticles())

View File

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

View File

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

View File

@ -12,10 +12,9 @@ import { LayoutType } from '../types'
export const ExpoPage = (props: PageProps) => {
const { t } = useLocalize()
const { page } = useRouter()
const getLayout = createMemo<LayoutType>(() => page().params['layout'] as LayoutType)
const getTitle = () => {
switch (getLayout()) {
const layout = createMemo(() => page().params['layout'] as LayoutType)
const title = createMemo(() => {
switch (layout()) {
case 'audio': {
return t('Audio')
}
@ -32,22 +31,14 @@ export const ExpoPage = (props: PageProps) => {
return t('Art')
}
}
}
})
createEffect(
on(
() => getLayout(),
() => {
document.title = getTitle()
},
{ defer: true },
),
)
createEffect(on(title, (t) => (document.title = t), { defer: true }))
return (
<PageLayout withPadding={true} zeroBottomPadding={true} title={getTitle()}>
<PageLayout withPadding={true} zeroBottomPadding={true} title={title()}>
<Topics />
<Expo shouts={props.expoShouts} layout={getLayout()} />
<Expo shouts={props.expoShouts} layout={layout()} />
</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 { PageLayout } from '../components/_shared/PageLayout'
import { useLocalize } from '../context/localize'
@ -25,34 +24,14 @@ const handleMyFeedLoadShouts = (options: LoadShoutsOptions) => {
export const FeedPage = () => {
const { t } = useLocalize()
onCleanup(() => resetSortedArticles())
const { page } = useRouter()
createEffect(
on(
() => page().route,
() => {
resetSortedArticles()
},
{ defer: true },
),
)
createEffect(on(page, (_) => resetSortedArticles(), { defer: true }))
onCleanup(() => resetSortedArticles())
return (
<PageLayout title={t('Feed')}>
<ReactionsProvider>
<Switch fallback={<Feed loadShouts={handleFeedLoadShouts} />}>
<Match when={page().route === 'feed'}>
<Feed loadShouts={handleFeedLoadShouts} />
</Match>
<Match when={page().route === 'feedMy'}>
<AuthGuard>
<Feed loadShouts={handleMyFeedLoadShouts} />
</AuthGuard>
</Match>
</Switch>
<Feed loadShouts={page().route === 'feedMy' ? handleMyFeedLoadShouts : handleFeedLoadShouts} />
</ReactionsProvider>
</PageLayout>
)

View File

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

View File

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

View File

@ -53,4 +53,4 @@ export type UploadedFile = {
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 { initRouter } from '../stores/router'
import { GLITCHTIP_DSN } from '../utils/config'
import { reportDsn } from '../utils/config'
import { resolveHydrationPromise } from '../utils/hydrationPromise'
let layoutReady = false
@ -22,7 +22,7 @@ export const render = async (pageContext: PageContextBuiltInClientWithClientRout
initRouter(pathname, searchParams)
SentryInit({
dsn: GLITCHTIP_DSN,
dsn: reportDsn,
tracesSampleRate: 0.01,
integrations: [replayIntegration()],
// Session Replay

View File

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

View File

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

View File

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

View File

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

View File

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