Merge branch 'hotfix/editor-permission' into feature/glitchtip

This commit is contained in:
Untone 2024-05-04 14:51:13 +03:00
commit 27a9662143
72 changed files with 5082 additions and 1788 deletions

View File

@ -15,7 +15,10 @@ jobs:
- name: Check types
run: npm run check:types
- name: Check styles
- name: Lint with Biome
run: npm run check:code
- name: Lint styles
run: npm run lint:styles
- name: Test production build

View File

@ -1,8 +1,21 @@
{
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
"files": {
"include": ["*.tsx", "*.ts", "*.js", "*.json"],
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.d.ts"]
"include": [
"*.tsx",
"*.ts",
"*.js",
"*.json"
],
"ignore": [
"./dist",
"./node_modules",
".husky",
"docs",
"gen",
"*.gen.ts",
"*.d.ts"
]
},
"vcs": {
"defaultBranch": "dev",
@ -10,13 +23,19 @@
},
"organizeImports": {
"enabled": true,
"ignore": ["./api", "./gen"]
"ignore": [
"./api",
"./gen"
]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 108,
"ignore": ["./src/graphql/schema", "./gen"]
"ignore": [
"./src/graphql/schema",
"./gen"
]
},
"javascript": {
"formatter": {
@ -29,14 +48,21 @@
}
},
"linter": {
"ignore": ["*.scss", "*.md", ".DS_Store", "*.svg", "*.d.ts"],
"ignore": [
"*.scss",
"*.md",
".DS_Store",
"*.svg",
"*.d.ts"
],
"enabled": true,
"rules": {
"all": true,
"complexity": {
"noForEach": "off",
"useOptionalChain": "warn",
"useLiteralKeys": "off"
"useLiteralKeys": "off",
"noExcessiveCognitiveComplexity": "off"
},
"correctness": {
"useHookAtTopLevel": "off"
@ -54,15 +80,18 @@
"noSvgWithoutTitle": "off"
},
"nursery": {
"useImportRestrictions": "off",
"useImportType": "off",
"useFilenamingConvention": "off"
"useImportRestrictions": "off"
},
"performance": {
"noBarrelFile": "off"
},
"style": {
"useBlockStatements": "off",
"noImplicitBoolean": "off",
"useNamingConvention": "off",
"noDefaultExport": "off"
"useImportType": "off",
"noDefaultExport": "off",
"useFilenamingConvention": "off"
},
"suspicious": {
"noConsoleLog": "off",

5478
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,13 +11,12 @@
"deploy": "graphql-codegen && npm run typecheck && vite build && vercel",
"dev": "vite",
"e2e": "npx playwright test --project=chromium",
"fix": "npm run check:code:fix && stylelint **/*.{scss,css} --fix",
"fix": "npm run lint: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 src --log-kind=compact",
"check:types": "tsc --noEmit",
"check:code:fix": "npx @biomejs/biome lint src --log-kind=compact",
"lint": "npm run lint:code && stylelint **/*.{scss,css}",
"lint:code": "npx @biomejs/biome lint src --log-kind=compact --verbose",
"lint:code:fix": "npx @biomejs/biome lint src --apply-unsafe --log-kind=compact --verbose",
@ -36,7 +35,7 @@
"devDependencies": {
"@authorizerdev/authorizer-js": "2.0.0",
"@babel/core": "7.23.3",
"@biomejs/biome": "^1.5.3",
"@biomejs/biome": "^1.7.2",
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/typescript": "^4.0.1",
"@graphql-codegen/typescript-operations": "^4.0.1",
@ -130,8 +129,9 @@
"typograf": "7.3.0",
"uniqolor": "1.1.0",
"vike": "0.4.148",
"vite": "5.2.8",
"vite": "5.2.10",
"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",
@ -140,5 +140,8 @@
"overrides": {
"y-prosemirror": "1.2.2",
"yjs": "13.6.12"
}
},
"trustedDependencies": [
"@biomejs/biome"
]
}

View File

@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.25 4.125C7.14583 4.125 6.13281 4.6901 5.60937 5.60156C5.40365 5.95573 5.16406 6.53385 5.03125 6.91927C4.91146 7.2474 3.07813 11.349 1.95313 13.8568L1.95833 13.8594C1.66667 14.4349 1.5 15.0755 1.5 15.75C1.5 18.2318 3.6875 20.25 6.375 20.25C9.0625 20.25 11.25 18.2318 11.25 15.75V14.3724C11.4505 14.3099 11.7109 14.25 12 14.25C12.2891 14.25 12.5495 14.3099 12.75 14.3724V15.75C12.75 18.2318 14.9375 20.25 17.625 20.25C20.3125 20.25 22.5 18.2318 22.5 15.75C22.5 15.0755 22.3333 14.4349 22.0417 13.8594L22.0469 13.8568C20.9219 11.349 19.0885 7.2474 18.9688 6.92448C18.8359 6.53646 18.5964 5.95833 18.3906 5.60417C17.8672 4.6901 16.8542 4.125 15.75 4.125C14.1354 4.125 12.8177 5.32813 12.7552 6.82813C12.526 6.78125 12.2734 6.75 12 6.75C11.7266 6.75 11.474 6.78125 11.2448 6.82813C11.1823 5.32813 9.86458 4.125 8.25 4.125ZM8.25 5.625C9.07813 5.625 9.75 6.21354 9.75 6.9375V12.5104C8.8724 11.7318 7.6849 11.25 6.375 11.25C5.75781 11.25 5.16927 11.362 4.625 11.5547C5.48177 9.64063 6.36458 7.65365 6.45052 7.40885C6.57292 7.04688 6.77604 6.58333 6.90885 6.35156C7.16667 5.90365 7.67969 5.625 8.25 5.625ZM15.75 5.625C16.3203 5.625 16.8333 5.90365 17.0911 6.35156C17.224 6.58333 17.4271 7.04948 17.5495 7.40885C17.6354 7.65365 18.5182 9.64063 19.3724 11.5547C18.8307 11.362 18.2422 11.25 17.625 11.25C16.3151 11.25 15.1276 11.7318 14.25 12.5104V6.9375C14.25 6.21354 14.9219 5.625 15.75 5.625ZM12 8.25C12.2891 8.25 12.5495 8.3099 12.75 8.3724V9.82552C12.5208 9.78125 12.2708 9.75 12 9.75C11.7292 9.75 11.4792 9.78125 11.25 9.82552V8.3724C11.4505 8.3099 11.7109 8.25 12 8.25ZM12 11.25C12.2891 11.25 12.5495 11.3099 12.75 11.3724V12.8255C12.5208 12.7812 12.2708 12.75 12 12.75C11.7292 12.75 11.4792 12.7812 11.25 12.8255V11.3724C11.4505 11.3099 11.7109 11.25 12 11.25ZM6.375 12.75C8.23698 12.75 9.75 14.0964 9.75 15.75C9.75 17.4036 8.23698 18.75 6.375 18.75C4.51302 18.75 3 17.4036 3 15.75C3 14.0964 4.51302 12.75 6.375 12.75ZM17.625 12.75C19.487 12.75 21 14.0964 21 15.75C21 17.4036 19.487 18.75 17.625 18.75C15.763 18.75 14.25 17.4036 14.25 15.75C14.25 14.0964 15.763 12.75 17.625 12.75Z" fill="#141414"/>
<path d="M8.625 4.5C7.59115 4.5 6.75 5.34115 6.75 6.375V8.25H5.625C4.59115 8.25 3.75 9.09115 3.75 10.125V17.25C3.75 18.4896 4.76042 19.5 6 19.5H18C19.2396 19.5 20.25 18.4896 20.25 17.25V6.375C20.25 5.34115 19.4089 4.5 18.375 4.5H8.625ZM8.625 6H18.375C18.5807 6 18.75 6.16927 18.75 6.375V17.25C18.75 17.6641 18.4141 18 18 18H8.1224C8.20313 17.7656 8.25 17.513 8.25 17.25V6.375C8.25 6.16927 8.41927 6 8.625 6ZM10.125 7.5C9.71094 7.5 9.375 7.83594 9.375 8.25C9.375 8.66406 9.71094 9 10.125 9H16.875C17.2891 9 17.625 8.66406 17.625 8.25C17.625 7.83594 17.2891 7.5 16.875 7.5H10.125ZM5.625 9.75H6.75V17.25C6.75 17.6641 6.41406 18 6 18C5.58594 18 5.25 17.6641 5.25 17.25V10.125C5.25 9.91927 5.41927 9.75 5.625 9.75ZM10.125 10.125C9.71094 10.125 9.375 10.4609 9.375 10.875C9.375 11.2891 9.71094 11.625 10.125 11.625H16.875C17.2891 11.625 17.625 11.2891 17.625 10.875C17.625 10.4609 17.2891 10.125 16.875 10.125H10.125ZM10.125 12.75C9.71094 12.75 9.375 13.0859 9.375 13.5V16.125C9.375 16.5391 9.71094 16.875 10.125 16.875H12.375C12.7891 16.875 13.125 16.5391 13.125 16.125V13.5C13.125 13.0859 12.7891 12.75 12.375 12.75H10.125ZM15 12.75C14.5859 12.75 14.25 13.0859 14.25 13.5C14.25 13.9141 14.5859 14.25 15 14.25H16.875C17.2891 14.25 17.625 13.9141 17.625 13.5C17.625 13.0859 17.2891 12.75 16.875 12.75H15ZM15 15.375C14.5859 15.375 14.25 15.7109 14.25 16.125C14.25 16.5391 14.5859 16.875 15 16.875H16.875C17.2891 16.875 17.625 16.5391 17.625 16.125C17.625 15.7109 17.2891 15.375 16.875 15.375H15Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,3 @@
<svg width="18" height="10" viewBox="0 0 18 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.1042 9.90633C16.9063 9.90633 16.7136 9.82821 16.5626 9.67716L8.98965 1.91675L1.43236 9.66154C1.1459 9.95841 0.671944 9.96362 0.375069 9.67716C0.0781948 9.3855 0.0729868 8.91154 0.359444 8.61467L8.45319 0.322998C8.73965 0.0313314 9.24486 0.0313314 9.53132 0.322998L17.6407 8.63029C17.9272 8.92716 17.9219 9.40112 17.6251 9.69279C17.4792 9.83342 17.2917 9.90633 17.1042 9.90633Z" fill="#9FA1A7"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B

View File

@ -528,5 +528,7 @@
"yesterday": "yesterday",
"Failed to delete comment": "Failed to delete comment",
"It's OK. Just enter your email to receive a link to change your password": "It's OK. Just enter your email to receive a link to change your password",
"Restore password": "Restore password"
"Restore password": "Restore password",
"Subscribing...": "Subscribing...",
"Unsubscribing...": "Unsubscribing..."
}

View File

@ -31,7 +31,7 @@
"All posts rating": "Рейтинг всех постов",
"All posts": "Все публикации",
"All topics": "Все темы",
"All": "Все",
"All": "Общая лента",
"Almost done! Check your email.": "Почти готово! Осталось подтвердить вашу почту.",
"Are you sure you want to delete this comment?": "Уверены, что хотите удалить этот комментарий?",
"Are you sure you want to delete this draft?": "Уверены, что хотите удалить этот черновик?",
@ -156,7 +156,7 @@
"FAQ": "Советы и предложения",
"Favorite topics": "Избранные темы",
"Favorite": "Избранное",
"Feed settings": "Настройки ленты",
"Feed settings": "Настроить ленту",
"Feed": "Лента",
"Feedback": "Обратная связь",
"Fill email": "Введите почту",
@ -555,5 +555,7 @@
"yesterday": "вчера",
"Failed to delete comment": "Не удалось удалить комментарий",
"It's OK. Just enter your email to receive a link to change your password": "Ничего страшного. Просто укажите свою почту, чтобы получить ссылку для смены пароля",
"Restore password": "Восстановить пароль"
"Restore password": "Восстановить пароль",
"Subscribing...": "Подписываем...",
"Unsubscribing...": "Отписываем..."
}

View File

@ -44,10 +44,7 @@ import { ProfileSubscriptionsPage } from '../pages/profile/profileSubscriptions.
import { SearchPage } from '../pages/search.page'
import { TopicPage } from '../pages/topic.page'
import { ROUTES, useRouter } from '../stores/router'
import { MODALS, hideModal, showModal } from '../stores/ui'
// TODO: lazy load
// const SomePage = lazy(() => import('./Pages/SomePage'))
import { MODALS, showModal } from '../stores/ui'
const pagesMap: Record<keyof typeof ROUTES, Component<PageProps>> = {
author: AuthorPage,

View File

@ -22,6 +22,7 @@ img {
.articleContent {
img:not([data-disable-lightbox='true']) {
cursor: zoom-in;
width: 100%;
}
}

View File

@ -179,6 +179,10 @@
@include font-size(1.2rem);
}
.commentAuthor {
margin-right: 2rem;
}
.articleAuthor {
@include font-size(1.2rem);

View File

@ -3,14 +3,11 @@
color: var(--secondary-color);
display: flex;
align-items: flex-start;
justify-content: flex-start;
justify-content: center;
flex-direction: column;
gap: .5rem;
flex: 1;
flex-wrap: wrap;
font-size: 1.2rem;
margin-bottom: .5rem;
.date {
font-weight: 500;

View File

@ -14,7 +14,7 @@ type Props = {
}
export const CommentDate = (props: Props) => {
const { t, formatDate } = useLocalize()
const { formatDate } = useLocalize()
const formattedDate = (date: number) => {
const formatDateOptions: Intl.DateTimeFormatOptions = props.isShort

View File

@ -54,12 +54,13 @@ type IframeSize = {
export type ArticlePageSearchParams = {
scrollTo: 'comments'
commentId: string
slide?: string
}
const scrollTo = (el: HTMLElement) => {
const { top } = el.getBoundingClientRect()
window.scrollTo({
top: top - DEFAULT_HEADER_OFFSET,
top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
left: 0,
behavior: 'smooth',
})
@ -74,7 +75,7 @@ export const FullArticle = (props: Props) => {
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const { t, formatDate, lang } = useLocalize()
const { author, session, isAuthenticated, requireAuthentication } = useSession()
const { author, session, requireAuthentication } = useSession()
const formattedDate = createMemo(() => formatDate(new Date(props.article.published_at * 1000)))
@ -151,19 +152,6 @@ export const FullArticle = (props: Props) => {
current: HTMLDivElement
} = { current: null }
createEffect(() => {
if (props.scrollToComments) {
scrollTo(commentsRef.current)
}
})
createEffect(() => {
if (searchParams()?.scrollTo === 'comments' && commentsRef.current) {
requestAnimationFrame(() => scrollTo(commentsRef.current))
changeSearchParams({ scrollTo: null })
}
})
createEffect(() => {
if (searchParams().commentId && isReactionsLoaded()) {
const commentElement = document.querySelector<HTMLElement>(
@ -319,6 +307,19 @@ export const FullArticle = (props: Props) => {
window?.addEventListener('resize', updateIframeSizes)
onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
createEffect(() => {
if (props.scrollToComments) {
scrollTo(commentsRef.current)
}
})
createEffect(() => {
if (searchParams()?.scrollTo === 'comments' && commentsRef.current) {
requestAnimationFrame(() => scrollTo(commentsRef.current))
changeSearchParams({ scrollTo: null })
}
})
})
const cover = props.article.cover ?? 'production/image/logo_image.png'
@ -329,7 +330,7 @@ export const FullArticle = (props: Props) => {
width: 1200,
})
const description = getDescription(props.article.description || body())
const description = getDescription(props.article.description || body() || media()[0]?.body)
const ogTitle = props.article.title
const keywords = getKeywords(props.article)
const shareUrl = getShareUrl({ pathname: `/${props.article.slug}` })
@ -560,7 +561,7 @@ export const FullArticle = (props: Props) => {
/>
</div>
<Show when={isAuthenticated() && !canEdit()}>
<Show when={author()?.id && !canEdit()}>
<div class={styles.help}>
<button class="button">{t('Cooperate')}</button>
</div>

View File

@ -12,7 +12,7 @@ type Props = {
}
export const AuthGuard = (props: Props) => {
const { isAuthenticated, isSessionLoaded } = useSession()
const { author, isSessionLoaded } = useSession()
const { changeSearchParams } = useRouter<RootSearchParams & AuthModalSearchParams>()
createEffect(() => {
@ -20,7 +20,7 @@ export const AuthGuard = (props: Props) => {
return
}
if (isSessionLoaded()) {
if (isAuthenticated()) {
if (author()?.id) {
hideModal()
} else {
changeSearchParams(
@ -37,5 +37,5 @@ export const AuthGuard = (props: Props) => {
}
})
return <Show when={(isSessionLoaded() && isAuthenticated()) || props.disabled}>{props.children}</Show>
return <Show when={(isSessionLoaded() && author()?.id) || props.disabled}>{props.children}</Show>
}

View File

@ -115,8 +115,4 @@
}
}
}
.actionButtonLabelHovered {
display: none;
}
}

View File

@ -1,6 +1,6 @@
import { openPage } from '@nanostores/router'
import { clsx } from 'clsx'
import { Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js'
import { Match, Show, Switch, createEffect, createMemo, createSignal } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize'
@ -10,14 +10,12 @@ 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 { Icon } from '../../_shared/Icon'
import { Userpic } from '../Userpic'
import { FollowedInfo } from '../../../pages/types'
import stylesButton from '../../_shared/Button/Button.module.scss'
import styles from './AuthorBadge.module.scss'
type Props = {
@ -29,19 +27,25 @@ type Props = {
inviteView?: boolean
onInvite?: (id: number) => void
selected?: boolean
isFollowed?: FollowedInfo
}
export const AuthorBadge = (props: Props) => {
const { mediaMatches } = useMediaQuery()
const { author, requireAuthentication } = useSession()
const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing()
const [isMobileView, setIsMobileView] = createSignal(false)
const [isFollowed, setIsFollowed] = createSignal<boolean>()
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 { setFollowing } = useFollowing()
// const { setFollowing } = useFollowing()
const { changeSearchParams } = useRouter()
const { t, formatDate, lang } = useLocalize()
@ -67,20 +71,11 @@ export const AuthorBadge = (props: Props) => {
return props.author.name
})
createEffect(
on(
() => props.isFollowed,
() => {
setIsFollowed(props.isFollowed?.value)
},
),
)
const handleFollowClick = () => {
const value = !isFollowed()
requireAuthentication(() => {
setIsFollowed(value)
setFollowing(FollowingEntity.Author, props.author.slug, value)
isSubscribed()
? unfollow(FollowingEntity.Author, props.author.slug)
: follow(FollowingEntity.Author, props.author.slug)
}, 'subscribe')
}
@ -124,6 +119,9 @@ export const AuthorBadge = (props: Props) => {
<Show when={props.author?.stat.shouts > 0}>
<div>{t('PublicationsWithCount', { 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>
</Show>
<Show when={props.author?.stat.followers > 0}>
<div>{t('FollowersWithCount', { count: props.author.stat?.followers ?? 0 })}</div>
</Show>
@ -134,55 +132,13 @@ export const AuthorBadge = (props: Props) => {
</div>
<Show when={props.author.slug !== author()?.slug && !props.nameOnly}>
<div class={styles.actions}>
<Show
when={!props.minimizeSubscribeButton}
fallback={<CheckButton text={t('Follow')} checked={isFollowed()} onClick={handleFollowClick} />}
>
<Show
when={isFollowed()}
fallback={
<Button
variant={props.iconButtons ? 'secondary' : 'bordered'}
size="S"
value={
<Show when={props.iconButtons} fallback={t('Subscribe')}>
<Icon name="author-subscribe" class={stylesButton.icon} />
</Show>
<BadgeSubscribeButton
action={() => handleFollowClick()}
isSubscribed={isSubscribed()}
actionMessageType={
subscribeInAction()?.slug === props.author.slug ? subscribeInAction().type : undefined
}
onClick={handleFollowClick}
isSubscribeButton={true}
class={clsx(styles.actionButton, {
[styles.iconed]: props.iconButtons,
[stylesButton.subscribed]: isFollowed(),
})}
/>
}
>
<Button
variant={props.iconButtons ? 'secondary' : 'bordered'}
size="S"
value={
<Show
when={props.iconButtons}
fallback={
<>
<span class={styles.actionButtonLabel}>{t('Following')}</span>
<span class={styles.actionButtonLabelHovered}>{t('Unfollow')}</span>
</>
}
>
<Icon name="author-unsubscribe" class={stylesButton.icon} />
</Show>
}
onClick={handleFollowClick}
isSubscribeButton={true}
class={clsx(styles.actionButton, {
[styles.iconed]: props.iconButtons,
[stylesButton.subscribed]: isFollowed(),
})}
/>
</Show>
</Show>
<Show when={props.showMessageButton}>
<Button
variant={props.iconButtons ? 'secondary' : 'bordered'}

View File

@ -34,16 +34,18 @@ export const AuthorCard = (props: Props) => {
const { author, isSessionLoaded, requireAuthentication } = useSession()
const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([])
const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all')
const [isFollowed, setIsFollowed] = createSignal<boolean>()
const [isSubscribed, setIsSubscribed] = createSignal<boolean>()
const isProfileOwner = createMemo(() => author()?.slug === props.author.slug)
const { setFollowing, isOwnerSubscribed } = useFollowing()
const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing()
onMount(() => {
setAuthorSubs(props.following)
})
createEffect(() => {
setIsFollowed(isOwnerSubscribed(props.author?.id))
if (!(subscriptions && props.author)) return
const subscribed = subscriptions.authors?.some((authorEntity) => authorEntity.id === props.author?.id)
setIsSubscribed(subscribed)
})
const name = createMemo(() => {
@ -83,15 +85,19 @@ export const AuthorCard = (props: Props) => {
})
const handleFollowClick = () => {
const value = !isFollowed()
requireAuthentication(() => {
setIsFollowed(value)
setFollowing(FollowingEntity.Author, props.author.slug, value)
isSubscribed()
? unfollow(FollowingEntity.Author, props.author.slug)
: follow(FollowingEntity.Author, props.author.slug)
}, 'subscribe')
}
const followButtonText = createMemo(() => {
if (isOwnerSubscribed(props.author?.id)) {
if (subscribeInAction()?.slug === props.author.slug) {
return subscribeInAction().type === 'subscribe' ? t('Subscribing...') : t('Unsubscribing...')
}
if (isSubscribed()) {
return (
<>
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
@ -119,12 +125,7 @@ export const AuthorCard = (props: Props) => {
<Show when={props.author.bio}>
<div class={styles.authorAbout} innerHTML={props.author.bio} />
</Show>
<Show
when={
(props.followers && props.followers.length > 0) ||
(props.following && props.following.length > 0)
}
>
<Show when={props.followers?.length > 0 || props.following?.length > 0}>
<div class={styles.subscribersContainer}>
<Show when={props.followers && props.followers.length > 0}>
<a href="?m=followers" class={styles.subscribers}>
@ -204,13 +205,14 @@ export const AuthorCard = (props: Props) => {
when={isProfileOwner()}
fallback={
<div class={styles.authorActions}>
<Show when={authorSubs().length}>
<Show when={authorSubs()?.length}>
<Button
onClick={handleFollowClick}
disabled={Boolean(subscribeInAction())}
value={followButtonText()}
isSubscribeButton={true}
class={clsx({
[stylesButton.subscribed]: isFollowed(),
[stylesButton.subscribed]: isSubscribed(),
})}
/>
</Show>
@ -255,15 +257,7 @@ export const AuthorCard = (props: Props) => {
<div class="row">
<div class="col-24">
<For each={props.followers}>
{(follower: Author) => (
<AuthorBadge
author={follower}
isFollowed={{
loaded: Boolean(authorSubs()),
value: isOwnerSubscribed(follower.id),
}}
/>
)}
{(follower: Author) => <AuthorBadge author={follower} />}
</For>
</div>
</div>
@ -318,21 +312,9 @@ export const AuthorCard = (props: Props) => {
<For each={authorSubs()}>
{(subscription) =>
isAuthor(subscription) ? (
<AuthorBadge
isFollowed={{
loaded: Boolean(authorSubs()),
value: isOwnerSubscribed(subscription.id),
}}
author={subscription}
/>
<AuthorBadge author={subscription} />
) : (
<TopicBadge
isFollowed={{
loaded: Boolean(authorSubs()),
value: isOwnerSubscribed(subscription.id),
}}
topic={subscription}
/>
<TopicBadge topic={subscription} />
)
}
</For>

View File

@ -1,9 +1,7 @@
import { clsx } from 'clsx'
import { For, Show, createEffect, createSignal, on, onMount } from 'solid-js'
import { useFollowing } from '../../context/following'
import { For, Show, createEffect, createSignal, on } from 'solid-js'
import { useLocalize } from '../../context/localize'
import { apiClient } from '../../graphql/client/core'
import { Author } from '../../graphql/schema/core.gen'
import { setAuthorsByFollowers, setAuthorsByShouts, useAuthorsStore } from '../../stores/zine/authors'
import { AuthorBadge } from '../Author/AuthorBadge'
import { InlineLoader } from '../InlineLoader'
@ -21,7 +19,6 @@ const PAGE_SIZE = 20
export const AuthorsList = (props: Props) => {
const { t } = useLocalize()
const { isOwnerSubscribed } = useFollowing()
const { authorsByShouts, authorsByFollowers } = useAuthorsStore()
const [loading, setLoading] = createSignal(false)
const [currentPage, setCurrentPage] = createSignal({ shouts: 0, followers: 0 })
@ -83,13 +80,7 @@ export const AuthorsList = (props: Props) => {
{(author) => (
<div class="row">
<div class="col-lg-20 col-xl-18">
<AuthorBadge
author={author}
isFollowed={{
loaded: !loading(),
value: isOwnerSubscribed(author.id),
}}
/>
<AuthorBadge author={author} />
</div>
</div>
)}

View File

@ -1,7 +1,7 @@
import { useLocalize } from '../../context/localize'
import { useRouter } from '../../stores/router'
import { showModal } from '../../stores/ui'
import { AuthModalSearchParams } from '../Nav/AuthModal/types'
import type { AuthModalSearchParams } from '../Nav/AuthModal/types'
import styles from './Hero.module.scss'

View File

@ -1,5 +1,3 @@
import { Buffer } from 'buffer'
import { clsx } from 'clsx'
import { Show } from 'solid-js'
@ -9,6 +7,7 @@ import { composeMediaItems } from '../../../utils/composeMediaItems'
import { AudioPlayer } from '../../Article/AudioPlayer'
import { DropArea } from '../../_shared/DropArea'
// import { Buffer } from 'node:buffer'
import styles from './AudioUploader.module.scss'
window.Buffer = Buffer

View File

@ -1,5 +1,3 @@
import type { Doc } from 'yjs/dist/src/utils/Doc'
import { HocuspocusProvider } from '@hocuspocus/provider'
import { isTextSelection } from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold'
@ -30,7 +28,7 @@ import { Underline } from '@tiptap/extension-underline'
import { createEffect, createSignal, onCleanup } from 'solid-js'
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
import uniqolor from 'uniqolor'
import * as Y from 'yjs'
import { Doc } from 'yjs'
import { useEditorContext } from '../../context/editor'
import { useLocalize } from '../../context/localize'
@ -85,7 +83,7 @@ export const Editor = (props: Props) => {
const docName = `shout-${props.shoutId}`
if (!yDocs[docName]) {
yDocs[docName] = new Y.Doc()
yDocs[docName] = new Doc()
}
if (!providers[docName]) {

View File

@ -29,9 +29,11 @@ const embedData = (data) => {
const result: { src: string; width?: string; height?: string } = { src: '' }
for (let i = 0; i < attributes.length; i++) {
const attribute = attributes[i]
const attribute = attributes.item(i)
if (attribute) {
result[attribute.name] = attribute.value
}
}
return result
}

View File

@ -13,8 +13,6 @@ import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
import { Button } from '../../_shared/Button'
import { DarkModeToggle } from '../../_shared/DarkModeToggle'
import { Icon } from '../../_shared/Icon'
import { useSnackbar } from '../../../context/snackbar'
import styles from './Panel.module.scss'
const typograf = new Typograf({ locale: ['ru', 'en-US'] })
@ -25,8 +23,16 @@ type Props = {
export const Panel = (props: Props) => {
const { t } = useLocalize()
const { isEditorPanelVisible, wordCounter, editorRef, form, toggleEditorPanel, saveShout, publishShout } =
useEditorContext()
const {
isEditorPanelVisible,
wordCounter,
editorRef,
form,
toggleEditorPanel,
saveShout,
saveDraft,
publishShout,
} = useEditorContext()
const containerRef: { current: HTMLElement } = { current: null }
const [isShortcutsVisible, setIsShortcutsVisible] = createSignal(false)
@ -45,7 +51,12 @@ export const Panel = (props: Props) => {
})
const handleSaveClick = () => {
const hasTopics = form.selectedTopics?.length > 0
if (hasTopics) {
saveShout(form)
} else {
saveDraft(form)
}
}
const html = useEditorHTML(() => editorRef.current())

View File

@ -4,8 +4,6 @@ import type { Author, Shout, Topic } from '../../graphql/schema/core.gen'
import { clsx } from 'clsx'
import { For, Show } from 'solid-js'
import { useFollowing } from '../../context/following'
import { useLocalize } from '../../context/localize'
import { AuthorBadge } from '../Author/AuthorBadge'
import { TopicCard } from '../Topic/Card'
@ -30,7 +28,6 @@ type Props = {
export const Beside = (props: Props) => {
const { t } = useLocalize()
const { isOwnerSubscribed } = useFollowing()
return (
<Show when={!!props.beside?.slug && props.values?.length > 0}>
@ -86,12 +83,7 @@ export const Beside = (props: Props) => {
/>
</Show>
<Show when={props.wrapper === 'author'}>
<AuthorBadge
author={value as Author}
isFollowed={{
value: isOwnerSubscribed(value.id),
}}
/>
<AuthorBadge author={value as Author} />
</Show>
<Show when={props.wrapper === 'article' && value?.slug}>
<ArticleCard

View File

@ -84,6 +84,10 @@
@include media-breakpoint-down(sm) {
right: 2px;
}
a {
border: none;
}
}
.settingsLabel {
@ -136,20 +140,22 @@
text-transform: uppercase;
position: relative;
&::after {
content: '+';
font-size: 1.6em;
line-height: 1;
.icon {
margin: 0;
min-width: 1.8rem;
position: absolute;
right: 2.5rem;
top: -0.2em;
right: 1.7rem;
top: 50%;
transform: translateY(-50%) rotate(180deg);
transform-origin: center;
transition: transform 0.3s;
width: 1.8rem;
}
&.opened {
&::after {
right: 0.9rem;
transform: rotate(45deg);
.icon {
right: 0;
transform: translateY(-50%);
}
}
}

View File

@ -119,6 +119,7 @@ export const Sidebar = () => {
}}
>
{t('My subscriptions')}
<Icon name="toggle-arrow" class={styles.icon} />
</h4>
<ul class={clsx(styles.subscriptions, { [styles.hidden]: !isSubscriptionsVisible() })}>

View File

@ -1,4 +1,3 @@
import { clsx } from 'clsx'
import { useLocalize } from '../../context/localize'
import { Loading } from '../_shared/Loading'
import styles from './InlineLoader.module.scss'
@ -7,7 +6,7 @@ type Props = {
class?: string
}
export const InlineLoader = (props: Props) => {
export const InlineLoader = (_props: Props) => {
const { t } = useLocalize()
return (
<div class={styles.InlineLoader}>

View File

@ -1,7 +1,7 @@
import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx'
import { JSX, Show, createEffect, createSignal } from 'solid-js'
import { JSX, Show, createSignal } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
@ -31,8 +31,8 @@ export const LoginForm = () => {
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [password, setPassword] = createSignal('')
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const [isLinkSent, setIsLinkSent] = createSignal(false)
// FIXME: use signal or remove
const [_isLinkSent, setIsLinkSent] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null }
const { showSnackbar } = useSnackbar()
const { signIn } = useSession()

View File

@ -32,7 +32,8 @@ export const RegisterForm = () => {
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const { t } = useLocalize()
const { signUp, isRegistered, resendVerifyEmail } = useSession()
const [submitError, setSubmitError] = createSignal('')
// FIXME: use submit error data or remove signal
const [_submitError, setSubmitError] = createSignal('')
const [fullName, setFullName] = createSignal('')
const [password, setPassword] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false)
@ -116,7 +117,7 @@ export const RegisterForm = () => {
const handleCheckEmailStatus = (status: EmailStatus | string) => {
switch (status) {
case 'not verified':
case 'not verified': {
setValidationErrors((prev) => ({
...prev,
email: (
@ -129,8 +130,9 @@ export const RegisterForm = () => {
),
}))
break
case 'verified':
setValidationErrors((prev) => ({
}
case 'verified': {
setValidationErrors((_prev) => ({
email: (
<>
{t('This email is registered')}. {t('try')}
@ -142,7 +144,8 @@ export const RegisterForm = () => {
),
}))
break
case 'registered':
}
case 'registered': {
setValidationErrors((prev) => ({
...prev,
email: (
@ -156,11 +159,13 @@ export const RegisterForm = () => {
),
}))
break
default:
}
default: {
console.info('[RegisterForm] email is not registered')
break
}
}
}
const handleEmailBlur = async () => {
if (validateEmail(email())) {

View File

@ -32,14 +32,14 @@ const MD_WIDTH_BREAKPOINT = 992
export const HeaderAuth = (props: Props) => {
const { t } = useLocalize()
const { page } = useRouter()
const { session, author, isAuthenticated, isSessionLoaded } = useSession()
const { session, author, isSessionLoaded } = useSession()
const { unreadNotificationsCount, showNotificationsPanel } = useNotifications()
const { form, toggleEditorPanel, saveShout, publishShout } = useEditorContext()
const { form, toggleEditorPanel, saveShout, saveDraft, publishShout } = useEditorContext()
const handleBellIconClick = (event: Event) => {
event.preventDefault()
if (!isAuthenticated()) {
if (!author()?.id) {
showModal('auth')
return
}
@ -48,19 +48,22 @@ export const HeaderAuth = (props: Props) => {
}
const isEditorPage = createMemo(() => page().route === 'edit' || page().route === 'editSettings')
const isNotificationsVisible = createMemo(() => isAuthenticated() && !isEditorPage())
const isSaveButtonVisible = createMemo(() => isAuthenticated() && isEditorPage())
const isNotificationsVisible = createMemo(() => author()?.id && !isEditorPage())
const isSaveButtonVisible = createMemo(() => author()?.id && isEditorPage())
const isCreatePostButtonVisible = createMemo(() => !isEditorPage())
const isAuthenticatedControlsVisible = createMemo(
() => isAuthenticated() && session()?.user?.email_verified,
)
const isAuthenticatedControlsVisible = createMemo(() => author()?.id && session()?.user?.email_verified)
const handleBurgerButtonClick = () => {
toggleEditorPanel()
}
const handleSaveButtonClick = () => {
const handleSaveClick = () => {
const hasTopics = form.selectedTopics?.length > 0
if (hasTopics) {
saveShout(form)
} else {
saveDraft(form)
}
}
const [width, setWidth] = createSignal(0)
@ -106,7 +109,7 @@ export const HeaderAuth = (props: Props) => {
<Show when={isSessionLoaded()} keyed={true}>
<div class={clsx('col-auto col-lg-7', styles.usernav)}>
<div class={styles.userControl}>
<Show when={isCreatePostButtonVisible() && isAuthenticated()}>
<Show when={isCreatePostButtonVisible() && author()?.id}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
<a href={getPagePath(router, 'create')}>
<span class={styles.textLabel}>{t('Create post')}</span>
@ -214,7 +217,7 @@ export const HeaderAuth = (props: Props) => {
</div>
</Show>
<Show when={isCreatePostButtonVisible() && !isAuthenticated()}>
<Show when={isCreatePostButtonVisible() && !author()?.id}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
<a href={getPagePath(router, 'create')}>
<span class={styles.textLabel}>{t('Create post')}</span>
@ -227,6 +230,7 @@ export const HeaderAuth = (props: Props) => {
<Show
when={isAuthenticatedControlsVisible()}
fallback={
<Show when={!author()?.id}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}>
<a href="?m=auth&mode=login">
<span class={styles.textLabel}>{t('Enter')}</span>
@ -234,9 +238,12 @@ export const HeaderAuth = (props: Props) => {
{/*<Icon name="user-default" class={clsx(styles.icon, styles.iconHover)} />*/}
</a>
</div>
</Show>
}
>
<Show when={!isSaveButtonVisible()}>
<Show
when={isSaveButtonVisible()}
fallback={
<div class={clsx(styles.userControlItem, styles.userControlItemInbox)}>
<a href={getPagePath(router, 'inbox')}>
<div classList={{ entered: page().path === '/inbox' }}>
@ -245,11 +252,20 @@ export const HeaderAuth = (props: Props) => {
</div>
</a>
</div>
}
>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
<button onClick={handleSaveClick}>
<span class={styles.textLabel}>{t('Save')}</span>
<Icon name="save" class={styles.icon} />
<Icon name="save" class={clsx(styles.icon, styles.iconHover)} />
</button>
</div>
</Show>
</Show>
</div>
<Show when={isAuthenticated()}>
<Show when={author()?.id}>
<ProfilePopup
onVisibilityChange={(isVisible) => {
props.setIsProfilePopupVisible(isVisible)

View File

@ -63,7 +63,7 @@ export const NotificationGroup = (props: NotificationGroupProps) => {
return (
<>
<For each={props.notifications}>
{(n: Group, index) => (
{(n: Group, _index) => (
<>
{t(threadCaption(n.thread), { commentsCount: n.reactions.length })}{' '}
<div

View File

@ -46,7 +46,7 @@ const isEarlier = (date: Date) => {
export const NotificationsPanel = (props: Props) => {
const [isLoading, setIsLoading] = createSignal(false)
const { isAuthenticated } = useSession()
const { author } = useSession()
const { t } = useLocalize()
const {
after,
@ -150,16 +150,13 @@ export const NotificationsPanel = (props: Props) => {
})
createEffect(
on(
() => isAuthenticated(),
async () => {
if (isAuthenticated()) {
on(author, async (a) => {
if (a?.id) {
setIsLoading(true)
await loadNextPage()
setIsLoading(false)
}
},
),
}),
)
return (

View File

@ -195,7 +195,7 @@ export const ProfileSettings = () => {
<div class="col-md-20 col-lg-18 col-xl-16">
<h1>{t('Profile settings')}</h1>
<p class="description">{t('Here you can customize your profile the way you want.')}</p>
<form enctype="multipart/form-data">
<form enctype="multipart/form-data" autocomplete="off">
<h4>{t('Userpic')}</h4>
<div class="pretty-form__item">
<div
@ -262,14 +262,16 @@ export const ProfileSettings = () => {
<div class="pretty-form__item">
<input
type="text"
name="username"
id="username"
name="nameOfUser"
id="nameOfUser"
data-lpignore="true"
autocomplete="one-time-code"
placeholder={t('Name')}
onInput={(event) => updateFormField('name', event.currentTarget.value)}
value={form.name}
ref={(el) => (nameInputRef.current = el)}
/>
<label for="username">{t('Name')}</label>
<label for="nameOfUser">{t('Name')}</label>
<Show when={nameError()}>
<div
style={{ position: 'absolute', 'margin-top': '-4px' }}
@ -289,6 +291,8 @@ export const ProfileSettings = () => {
type="text"
name="user-address"
id="user-address"
data-lpignore="true"
autocomplete="one-time-code2"
onInput={(event) => updateFormField('slug', event.currentTarget.value)}
value={form.slug}
ref={(el) => (slugInputRef.current = el)}

View File

@ -17,7 +17,7 @@ interface Props {
const isInViewport = (el: Element): boolean => {
const rect = el.getBoundingClientRect()
return rect.top <= DEFAULT_HEADER_OFFSET
return rect.top <= DEFAULT_HEADER_OFFSET + 24 // default offset + 1.5em (default header margin-top)
}
const scrollToHeader = (element) => {
window.scrollTo({

View File

@ -1,5 +1,5 @@
import { clsx } from 'clsx'
import { Show, createMemo, createSignal } from 'solid-js'
import { Show, createEffect, createMemo, createSignal } from 'solid-js'
import { useFollowing } from '../../context/following'
import { useLocalize } from '../../context/localize'
@ -38,14 +38,20 @@ export const TopicCard = (props: TopicProps) => {
capitalize(lang() === 'en' ? props.topic.slug.replaceAll('-', ' ') : props.topic.title || ''),
)
const { author, requireAuthentication } = useSession()
const { setFollowing, loading: subLoading } = useFollowing()
const [followed, setFollowed] = createSignal()
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 handleFollowClick = () => {
const value = !followed()
requireAuthentication(() => {
setFollowed(value)
setFollowing(FollowingEntity.Topic, props.topic.slug, value)
isSubscribed()
? unfollow(FollowingEntity.Topic, props.topic.slug)
: follow(FollowingEntity.Topic, props.topic.slug)
}, 'subscribe')
}
@ -53,12 +59,12 @@ export const TopicCard = (props: TopicProps) => {
return (
<>
<Show when={props.iconButton}>
<Show when={followed()} fallback="+">
<Show when={isSubscribed()} fallback="+">
<Icon name="check-subscribed" />
</Show>
</Show>
<Show when={!props.iconButton}>
<Show when={followed()} fallback={t('Follow')}>
<Show when={isSubscribed()} fallback={t('Follow')}>
<span class={stylesButton.buttonSubscribeLabelHovered}>{t('Unfollow')}</span>
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
</Show>
@ -130,7 +136,7 @@ export const TopicCard = (props: TopicProps) => {
fallback={
<CheckButton
text={t('Follow')}
checked={Boolean(followed())}
checked={Boolean(isSubscribed())}
onClick={handleFollowClick}
/>
}
@ -142,10 +148,10 @@ export const TopicCard = (props: TopicProps) => {
onClick={handleFollowClick}
isSubscribeButton={true}
class={clsx(styles.actionButton, {
[styles.isSubscribing]: subLoading(),
[stylesButton.subscribed]: followed(),
[styles.isSubscribing]:
subscribeInAction()?.slug === props.topic.slug ? subscribeInAction().type : undefined,
[stylesButton.subscribed]: isSubscribed(),
})}
// disabled={subLoading()}
/>
</Show>
</Show>

View File

@ -1,5 +1,5 @@
import { clsx } from 'clsx'
import { Show, createEffect, createSignal, on } from 'solid-js'
import { Show, createEffect, createSignal } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize'
@ -8,16 +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 { Button } from '../../_shared/Button'
import { CheckButton } from '../../_shared/CheckButton'
import { FollowedInfo } from '../../../pages/types'
import { BadgeSubscribeButton } from '../../_shared/BadgeSubscribeButton'
import styles from './TopicBadge.module.scss'
type Props = {
topic: Topic
minimizeSubscribeButton?: boolean
isFollowed?: FollowedInfo
showStat?: boolean
}
@ -26,14 +22,20 @@ export const TopicBadge = (props: Props) => {
const { mediaMatches } = useMediaQuery()
const [isMobileView, setIsMobileView] = createSignal(false)
const { requireAuthentication } = useSession()
const { setFollowing, loading: subLoading } = useFollowing()
const [isFollowed, setIsFollowed] = createSignal<boolean>()
const [isSubscribed, setIsSubscribed] = createSignal<boolean>()
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 handleFollowClick = () => {
const value = !isFollowed()
requireAuthentication(() => {
setIsFollowed(value)
setFollowing(FollowingEntity.Topic, props.topic.slug, value)
isSubscribed()
? follow(FollowingEntity.Topic, props.topic.slug)
: unfollow(FollowingEntity.Topic, props.topic.slug)
}, 'subscribe')
}
@ -41,15 +43,6 @@ export const TopicBadge = (props: Props) => {
setIsMobileView(!mediaMatches.sm)
})
createEffect(
on(
() => props.isFollowed,
() => {
setIsFollowed(props.isFollowed.value)
},
),
)
const title = () =>
lang() === 'en' ? capitalize(props.topic.slug.replaceAll('-', ' ')) : props.topic.title
@ -83,35 +76,14 @@ export const TopicBadge = (props: Props) => {
</Show>
</a>
</div>
<div class={styles.actions}>
<Show
when={!props.minimizeSubscribeButton}
fallback={
<CheckButton text={t('Follow')} checked={Boolean(isFollowed())} onClick={handleFollowClick} />
<BadgeSubscribeButton
isSubscribed={isSubscribed()}
action={handleFollowClick}
actionMessageType={
subscribeInAction()?.slug === props.topic.slug ? subscribeInAction().type : undefined
}
>
<Show
when={isFollowed()}
fallback={
<Button
variant="primary"
size="S"
value={subLoading() ? t('subscribing...') : t('Subscribe')}
onClick={handleFollowClick}
class={styles.subscribeButton}
/>
}
>
<Button
onClick={handleFollowClick}
variant="bordered"
size="S"
value={t('Following')}
class={styles.subscribeButton}
/>
</Show>
</Show>
</div>
</div>
<div class={styles.stats}>

View File

@ -2,11 +2,11 @@ import type { Author } from '../../../graphql/schema/core.gen'
import { Meta } from '@solidjs/meta'
import { clsx } from 'clsx'
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'
import { For, Show, createMemo, createSignal } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { useRouter } from '../../../stores/router'
import { setAuthorsSort, useAuthorsStore } from '../../../stores/zine/authors'
import { useAuthorsStore } from '../../../stores/zine/authors'
import { getImageUrl } from '../../../utils/getImageUrl'
import { scrollHandler } from '../../../utils/scroll'
import { authorLetterReduce, translateAuthor } from '../../../utils/translate'
@ -33,7 +33,7 @@ export const AllAuthors = (props: Props) => {
const [searchQuery, setSearchQuery] = createSignal('')
const ALPHABET =
lang() === 'ru' ? [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@'] : [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ@']
const { searchParams, changeSearchParams } = useRouter<AllAuthorsPageSearchParams>()
const { searchParams } = useRouter<AllAuthorsPageSearchParams>()
const { sortedAuthors } = useAuthorsStore({
authors: props.authors,
sortBy: searchParams().by || 'name',

View File

@ -3,8 +3,6 @@ import type { Topic } from '../../../graphql/schema/core.gen'
import { Meta } from '@solidjs/meta'
import { clsx } from 'clsx'
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize'
import { useRouter } from '../../../stores/router'
import { setTopicsSort, useTopicsStore } from '../../../stores/zine/topics'
@ -74,8 +72,6 @@ export const AllTopics = (props: Props) => {
return keys
})
const { isOwnerSubscribed } = useFollowing()
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
const [searchQuery, setSearchQuery] = createSignal('')
const filteredResults = createMemo(() => {
@ -188,14 +184,7 @@ export const AllTopics = (props: Props) => {
<For each={filteredResults().slice(0, limit())}>
{(topic) => (
<>
<TopicBadge
topic={topic}
isFollowed={{
loaded: filteredResults().length > 0,
value: isOwnerSubscribed(topic.slug),
}}
showStat={true}
/>
<TopicBadge topic={topic} showStat={true} />
</>
)}
</For>

View File

@ -3,7 +3,7 @@ import type { Author, Reaction, Shout, Topic } from '../../../graphql/schema/cor
import { getPagePath } from '@nanostores/router'
import { Meta, Title } from '@solidjs/meta'
import { clsx } from 'clsx'
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onMount } from 'solid-js'
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize'
@ -11,7 +11,7 @@ import { useSession } from '../../../context/session'
import { apiClient } from '../../../graphql/client/core'
import { router, useRouter } from '../../../stores/router'
import { loadShouts, useArticlesStore } from '../../../stores/zine/articles'
import { loadAuthor, useAuthorsStore } from '../../../stores/zine/authors'
import { loadAuthor } from '../../../stores/zine/authors'
import { getImageUrl } from '../../../utils/getImageUrl'
import { getDescription } from '../../../utils/meta'
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
@ -39,10 +39,9 @@ const LOAD_MORE_PAGE_SIZE = 9
export const AuthorView = (props: Props) => {
const { t } = useLocalize()
const { subscriptions, followers: myFollowers, loadSubscriptions } = useFollowing()
const { followers: myFollowers } = useFollowing()
const { session } = useSession()
const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
const { authorEntities } = useAuthorsStore({ authors: [props.author] })
const { page: getPage, searchParams } = useRouter()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [isBioExpanded, setIsBioExpanded] = createSignal(false)
@ -53,50 +52,41 @@ export const AuthorView = (props: Props) => {
const [commented, setCommented] = createSignal<Reaction[]>()
const modal = MODALS[searchParams().m]
// current author
const [sessionChecked, setSessionChecked] = createSignal(false)
createEffect(() => {
if (props.authorSlug) {
if (session()?.user?.app_data?.profile?.slug === props.authorSlug) {
console.info('my own profile')
const { profile, authors, topics } = session().user.app_data
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])
}
} else {
try {
const a = authorEntities()[props.authorSlug]
setAuthor(a)
// TODO: add following data retrieval
console.debug('[Author] expecting following data fetched')
} catch (error) {
console.debug(error)
}
}
})
createEffect(async () => {
if (author()?.id && !author().stat) {
const a = await loadAuthor({ slug: '', author_id: author().id })
console.debug('[AuthorView] loaded author:', a)
}
})
const bioContainerRef: { current: HTMLDivElement } = { current: null }
const bioWrapperRef: { current: HTMLDivElement } = { current: null }
const fetchData = async (slug) => {
const fetchData = async (slug: string) => {
try {
const [subscriptionsResult, followersResult] = await Promise.all([
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] following data loaded')
console.debug('[components.Author] following data loaded', subscriptionsResult)
} catch (error) {
console.error('[components.Author] fetch error', error)
}
@ -108,14 +98,6 @@ export const AuthorView = (props: Props) => {
}
}
onMount(() => {
fetchData(props.authorSlug)
if (!modal) {
hideModal()
}
})
const loadMore = async () => {
saveScrollPosition()
const { hasMore } = await loadShouts({
@ -128,13 +110,10 @@ export const AuthorView = (props: Props) => {
}
onMount(() => {
if (!modal) hideModal()
fetchData(props.authorSlug)
checkBioHeight()
// pagination
if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) {
loadMore()
loadSubscriptions()
}
})
const pages = createMemo<Shout[][]>(() =>
@ -143,16 +122,22 @@ export const AuthorView = (props: Props) => {
const fetchComments = async (commenter: Author) => {
const data = await apiClient.getReactionsBy({
by: { comment: false, created_by: commenter.id },
by: { comment: true, created_by: commenter.id },
})
setCommented(data)
}
createEffect(() => {
if (author()) {
const authorSlug = createMemo(() => author()?.slug)
createEffect(
on(
() => authorSlug(),
() => {
fetchData(authorSlug())
fetchComments(author())
}
})
},
{ defer: true },
),
)
const ogImage = createMemo(() =>
author()?.pic

View File

@ -1,6 +1,6 @@
import { openPage } from '@nanostores/router'
import { clsx } from 'clsx'
import { For, Show, createEffect, createSignal } from 'solid-js'
import { For, Show, createEffect, createSignal, on } from 'solid-js'
import { useEditorContext } from '../../../context/editor'
import { useSession } from '../../../context/session'
@ -9,22 +9,24 @@ import { Shout } from '../../../graphql/schema/core.gen'
import { router } from '../../../stores/router'
import { Draft } from '../../Draft'
import { Loading } from '../../_shared/Loading'
import styles from './DraftsView.module.scss'
export const DraftsView = () => {
const { isAuthenticated, isSessionLoaded } = useSession()
const { session } = useSession()
const [drafts, setDrafts] = createSignal<Shout[]>([])
const loadDrafts = async () => {
if (apiClient.private) {
createEffect(
on(
() => session(),
async (s) => {
if (s) {
const loadedDrafts = await apiClient.getDrafts()
setDrafts(loadedDrafts.reverse() || [])
}
}
createEffect(() => {
if (isSessionLoaded()) loadDrafts()
})
},
),
)
const { publishShoutById, deleteShout } = useEditorContext()
@ -44,11 +46,10 @@ export const DraftsView = () => {
return (
<div class={clsx(styles.DraftsView)}>
<Show when={isSessionLoaded()}>
<Show when={session()?.user?.id} fallback={<Loading />}>
<div class="wide-container">
<div class="row">
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
<Show when={isAuthenticated()} fallback="Давайте авторизуемся">
<For each={drafts()}>
{(draft) => (
<Draft
@ -59,7 +60,6 @@ export const DraftsView = () => {
/>
)}
</For>
</Show>
</div>
</div>
</div>

View File

@ -2,6 +2,7 @@ import { clsx } from 'clsx'
import deepEqual from 'fast-deep-equal'
import { Accessor, Show, createMemo, createSignal, lazy, onCleanup, onMount } from 'solid-js'
import { createStore } from 'solid-js/store'
import { throttle } from 'throttle-debounce'
import { ShoutForm, useEditorContext } from '../../../context/editor'
import { useLocalize } from '../../../context/localize'
@ -41,7 +42,9 @@ export const EMPTY_TOPIC: Topic = {
slug: '',
}
const THROTTLING_INTERVAL = 2000
const AUTO_SAVE_INTERVAL = 5000
const AUTO_SAVE_DELAY = 5000
const handleScrollTopButtonClick = (e) => {
e.preventDefault()
window.scrollTo({
@ -65,12 +68,14 @@ export const EditView = (props: Props) => {
} = useEditorContext()
const shoutTopics = props.shout.topics || []
// TODO: проверить сохранение черновика в local storage (не работает)
const draft = getDraftFromLocalStorage(props.shout.id)
if (draft) {
setForm(Object.keys(draft).length !== 0 ? draft : { shoutId: props.shout.id })
const draftForm = Object.keys(draft).length !== 0 ? draft : { shoutId: props.shout.id }
setForm(draftForm)
console.debug('draft from localstorage: ', draftForm)
} else {
setForm({
const draftForm = {
slug: props.shout.slug,
shoutId: props.shout.id,
title: props.shout.title,
@ -83,7 +88,9 @@ export const EditView = (props: Props) => {
coverImageUrl: props.shout.cover,
media: props.shout.media,
layout: props.shout.layout,
})
}
setForm(draftForm)
console.debug('draft from props data: ', draftForm)
}
const subtitleInput: { current: HTMLTextAreaElement } = { current: null }
@ -106,9 +113,6 @@ export const EditView = (props: Props) => {
onCleanup(() => {
window.removeEventListener('scroll', handleScroll)
})
})
onMount(() => {
// eslint-disable-next-line unicorn/consistent-function-scoping
const handleBeforeUnload = (event) => {
if (!deepEqual(prevForm, form)) {
@ -180,42 +184,39 @@ export const EditView = (props: Props) => {
let autoSaveTimeOutId: number | string | NodeJS.Timeout
//TODO: add throttle
const autoSaveRecursive = () => {
autoSaveTimeOutId = setTimeout(async () => {
const autoSave = async () => {
const hasChanges = !deepEqual(form, prevForm)
if (hasChanges) {
const hasTopic = Boolean(form.mainTopic)
if (hasChanges || hasTopic) {
console.debug('saving draft', form)
setSaving(true)
if (props.shout?.published_at) {
saveDraftToLocalStorage(form)
} else {
await saveDraft(form)
}
setPrevForm(clone(form))
setTimeout(() => {
setSaving(false)
}, 2000)
setTimeout(() => setSaving(false), AUTO_SAVE_DELAY)
}
}
// Throttle the autoSave function
const throttledAutoSave = throttle(THROTTLING_INTERVAL, autoSave)
const autoSaveRecursive = () => {
autoSaveTimeOutId = setTimeout(() => {
throttledAutoSave()
autoSaveRecursive()
}, AUTO_SAVE_INTERVAL)
}
const stopAutoSave = () => {
clearTimeout(autoSaveTimeOutId)
}
onMount(() => {
autoSaveRecursive()
})
onCleanup(() => {
stopAutoSave()
onCleanup(() => clearTimeout(autoSaveTimeOutId))
})
const showSubtitleInput = () => {
setIsSubtitleVisible(true)
subtitleInput.current.focus()
}
const showLeadInput = () => {
setIsLeadVisible(true)
}

View File

@ -1,16 +1,14 @@
import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx'
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
import { For, Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { apiClient } from '../../../graphql/client/core'
import { LoadShoutsFilters, LoadShoutsOptions, Shout } from '../../../graphql/schema/core.gen'
import { LayoutType } from '../../../pages/types'
import { router } from '../../../stores/router'
import { loadShouts, resetSortedArticles, useArticlesStore } from '../../../stores/zine/articles'
import { getUnixtime } from '../../../utils/getServerDate'
import { restoreScrollPosition, saveScrollPosition } from '../../../utils/scroll'
import { splitToPages } from '../../../utils/splitToPages'
import { ArticleCard } from '../../Feed/ArticleCard'
import { Button } from '../../_shared/Button'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
@ -28,19 +26,12 @@ export const PRERENDERED_ARTICLES_COUNT = 36
const LOAD_MORE_PAGE_SIZE = 12
export const Expo = (props: Props) => {
const [isLoaded, setIsLoaded] = createSignal<boolean>(Boolean(props.shouts))
const { t } = useLocalize()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [favoriteTopArticles, setFavoriteTopArticles] = createSignal<Shout[]>([])
const [reactedTopMonthArticles, setReactedTopMonthArticles] = createSignal<Shout[]>([])
const { t } = useLocalize()
const { sortedArticles } = useArticlesStore({
shouts: isLoaded() ? props.shouts : [],
layout: props.layout,
})
const [articlesEndPage, setArticlesEndPage] = createSignal<number>(PRERENDERED_ARTICLES_COUNT)
const [expoShouts, setExpoShouts] = createSignal<Shout[]>([])
const getLoadShoutsFilters = (additionalFilters: LoadShoutsFilters = {}): LoadShoutsFilters => {
const filters = { ...additionalFilters }
@ -58,15 +49,18 @@ export const Expo = (props: Props) => {
const options: LoadShoutsOptions = {
filters: getLoadShoutsFilters(),
limit: count,
offset: sortedArticles().length,
offset: expoShouts().length,
}
options.filters = props.layout
? { layouts: [props.layout] }
: { layouts: ['audio', 'video', 'image', 'literature'] }
const { hasMore } = await loadShouts(options)
const newShouts = await apiClient.getShouts(options)
const hasMore = newShouts?.length !== options.limit + 1 && newShouts?.length !== 0
setIsLoadMoreButtonVisible(hasMore)
setExpoShouts((prev) => [...prev, ...newShouts])
}
const loadMoreWithoutScrolling = async (count: number) => {
@ -100,19 +94,7 @@ export const Expo = (props: Props) => {
}
onMount(() => {
if (isLoaded()) {
return
}
loadMore(PRERENDERED_ARTICLES_COUNT + LOAD_MORE_PAGE_SIZE)
setIsLoaded(true)
})
onMount(() => {
if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) {
loadMore(LOAD_MORE_PAGE_SIZE)
}
loadRandomTopArticles()
loadRandomTopMonthArticles()
})
@ -121,9 +103,11 @@ export const Expo = (props: Props) => {
on(
() => props.layout,
() => {
resetSortedArticles()
setExpoShouts([])
setIsLoadMoreButtonVisible(false)
setFavoriteTopArticles([])
setReactedTopMonthArticles([])
setArticlesEndPage(PRERENDERED_ARTICLES_COUNT)
loadMore(PRERENDERED_ARTICLES_COUNT + LOAD_MORE_PAGE_SIZE)
loadRandomTopArticles()
loadRandomTopMonthArticles()
@ -132,16 +116,17 @@ export const Expo = (props: Props) => {
)
onCleanup(() => {
resetSortedArticles()
setExpoShouts([])
})
const handleLoadMoreClick = () => {
loadMoreWithoutScrolling(LOAD_MORE_PAGE_SIZE)
setArticlesEndPage((prev) => prev + LOAD_MORE_PAGE_SIZE)
}
return (
<div class={styles.Expo}>
<Show when={sortedArticles()?.length > 0} fallback={<Loading />}>
<Show when={expoShouts().length > 0} fallback={<Loading />}>
<div class="wide-container">
<ul class={clsx('view-switcher')}>
<li class={clsx({ 'view-switcher__item--selected': !props.layout })}>
@ -194,7 +179,7 @@ export const Expo = (props: Props) => {
</li>
</ul>
<div class="row">
<For each={sortedArticles().slice(0, LOAD_MORE_PAGE_SIZE)}>
<For each={expoShouts()?.slice(0, LOAD_MORE_PAGE_SIZE)}>
{(shout) => (
<div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
<ArticleCard
@ -209,7 +194,7 @@ export const Expo = (props: Props) => {
<Show when={reactedTopMonthArticles()?.length > 0} keyed={true}>
<ArticleCardSwiper title={t('Top month articles')} slides={reactedTopMonthArticles()} />
</Show>
<For each={sortedArticles().slice(LOAD_MORE_PAGE_SIZE, LOAD_MORE_PAGE_SIZE * 2)}>
<For each={expoShouts().slice(LOAD_MORE_PAGE_SIZE, LOAD_MORE_PAGE_SIZE * 2)}>
{(shout) => (
<div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
<ArticleCard
@ -224,7 +209,7 @@ export const Expo = (props: Props) => {
<Show when={favoriteTopArticles()?.length > 0} keyed={true}>
<ArticleCardSwiper title={t('Favorite')} slides={favoriteTopArticles()} />
</Show>
<For each={sortedArticles().slice(LOAD_MORE_PAGE_SIZE * 2)}>
<For each={expoShouts().slice(LOAD_MORE_PAGE_SIZE * 2, articlesEndPage())}>
{(shout) => (
<div class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
<ArticleCard

View File

@ -170,7 +170,7 @@
}
}
.commentArticleTitle {
.comment .commentArticleTitle {
line-clamp: 1;
-webkit-line-clamp: 1;

View File

@ -49,11 +49,18 @@ type VisibilityItem = {
}
type FeedSearchParams = {
by: 'publish_date' | 'likes' | 'comments'
by: 'publish_date' | 'likes' | 'last_comment'
period: FeedPeriod
visibility: VisibilityMode
}
type Props = {
loadShouts: (options: LoadShoutsOptions) => Promise<{
hasMore: boolean
newShouts: Shout[]
}>
}
const getFromDate = (period: FeedPeriod): number => {
const now = new Date()
let d: Date = now
@ -74,18 +81,10 @@ const getFromDate = (period: FeedPeriod): number => {
return Math.floor(d.getTime() / 1000)
}
type Props = {
loadShouts: (options: LoadShoutsOptions) => Promise<{
hasMore: boolean
newShouts: Shout[]
}>
}
export const FeedView = (props: Props) => {
const { t } = useLocalize()
const monthPeriod: PeriodItem = { value: 'month', title: t('This month') }
const visibilityAll = { value: 'featured', title: t('All') }
const periods: PeriodItem[] = [
{ value: 'week', title: t('This week') },
@ -121,7 +120,7 @@ export const FeedView = (props: Props) => {
const currentVisibility = createMemo(() => {
const visibility = visibilities.find((v) => v.value === searchParams().visibility)
if (!visibility) {
return visibilityAll
return visibilities[0]
}
return visibility
})
@ -172,6 +171,7 @@ export const FeedView = (props: Props) => {
}
const visibilityMode = searchParams().visibility
if (visibilityMode === 'all') {
options.filters = { ...options.filters }
} else if (visibilityMode) {
@ -185,6 +185,7 @@ export const FeedView = (props: Props) => {
const period = searchParams().period || 'month'
options.filters = { after: getFromDate(period) }
}
return props.loadShouts(options)
}
@ -257,10 +258,10 @@ export const FeedView = (props: Props) => {
</li>
<li
class={clsx({
'view-switcher__item--selected': searchParams().by === 'comments',
'view-switcher__item--selected': searchParams().by === 'last_comment',
})}
>
<span class="link" onClick={() => changeSearchParams({ by: 'comments' })}>
<span class="link" onClick={() => changeSearchParams({ by: 'last_comment' })}>
{t('Most commented')}
</span>
</li>

View File

@ -1,8 +1,7 @@
import { getPagePath } from '@nanostores/router'
import { For, Show, batch, createMemo, createSignal, onMount } from 'solid-js'
import { For, Show, createMemo, createSignal, onMount } from 'solid-js'
import { useLocalize } from '../../context/localize'
import { apiClient } from '../../graphql/client/core'
import { Shout, Topic } from '../../graphql/schema/core.gen'
import { router } from '../../stores/router'
import {
@ -52,8 +51,8 @@ export const HomeView = (props: Props) => {
const { topAuthors } = useTopAuthorsStore()
const { t } = useLocalize()
const [randomTopic, setRandomTopic] = createSignal<Topic>(null)
const [randomTopicArticles, setRandomTopicArticles] = createSignal<Shout[]>([])
const [randomTopic, _setRandomTopic] = createSignal<Topic>(null)
const [randomTopicArticles, _setRandomTopicArticles] = createSignal<Shout[]>([])
onMount(async () => {
loadTopArticles()

View File

@ -1,10 +1,8 @@
import { clsx } from 'clsx'
import { For, Show, createEffect, createSignal, onMount } from 'solid-js'
import { For, Show, createEffect, createSignal } from 'solid-js'
import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
import { apiClient } from '../../../graphql/client/core'
import { Author, Topic } from '../../../graphql/schema/core.gen'
import { SubscriptionFilter } from '../../../pages/types'
import { dummyFilter } from '../../../utils/dummyFilter'
@ -21,7 +19,6 @@ import stylesSettings from '../../../styles/FeedSettings.module.scss'
export const ProfileSubscriptions = () => {
const { t, lang } = useLocalize()
const { author, session } = useSession()
const { subscriptions } = useFollowing()
const [following, setFollowing] = createSignal<Array<Author | Topic>>([])
const [filtered, setFiltered] = createSignal<Array<Author | Topic>>([])

View File

@ -127,10 +127,10 @@ export const PublishSettings = (props: Props) => {
}
const handlePublishSubmit = () => {
const shoutData = { ...props.form, ...settingsForm }
if (!shoutData?.mainTopic) {
showSnackbar({ body: t('Please, set the main topic first') })
} else {
if (shoutData?.mainTopic) {
publishShout(shoutData)
} else {
showSnackbar({ body: t('Please, set the main topic first') })
}
}
const handleSaveDraft = () => {

View File

@ -0,0 +1,29 @@
.actionButton {
border-radius: 0.8rem !important;
margin-right: 0 !important;
width: 9em;
&.iconed {
padding: 6px !important;
min-width: 4rem;
width: unset;
&:hover img {
filter: invert(1);
}
}
&:hover {
.actionButtonLabel {
display: none;
}
.actionButtonLabelHovered {
display: block;
}
}
}
.actionButtonLabelHovered {
display: none;
}

View File

@ -0,0 +1,86 @@
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'
type Props = {
class?: string
isSubscribed: boolean
minimizeSubscribeButton?: boolean
action: () => void
iconButtons?: boolean
actionMessageType?: 'subscribe' | 'unsubscribe'
}
export const BadgeSubscribeButton = (props: Props) => {
const { t } = useLocalize()
const inActionText = createMemo(() => {
return props.actionMessageType === 'subscribe' ? t('Subscribing...') : t('Unsubscribing...')
})
return (
<div class={props.class}>
<Show
when={!props.minimizeSubscribeButton}
fallback={<CheckButton text={t('Follow')} checked={props.isSubscribed} onClick={props.action} />}
>
<Show
when={props.isSubscribed}
fallback={
<Button
variant={props.iconButtons ? 'secondary' : 'bordered'}
size="S"
value={
<Show
when={props.iconButtons}
fallback={props.actionMessageType ? inActionText() : t('Subscribe')}
>
<Icon name="author-subscribe" class={stylesButton.icon} />
</Show>
}
onClick={props.action}
isSubscribeButton={true}
class={clsx(styles.actionButton, {
[styles.iconed]: props.iconButtons,
[stylesButton.subscribed]: props.isSubscribed,
})}
/>
}
>
<Button
variant={props.iconButtons ? 'secondary' : 'bordered'}
size="S"
value={
<Show
when={props.iconButtons}
fallback={
props.actionMessageType ? (
inActionText()
) : (
<>
<span class={styles.actionButtonLabel}>{t('Following')}</span>
<span class={styles.actionButtonLabelHovered}>{t('Unfollow')}</span>
</>
)
}
>
<Icon name="author-unsubscribe" class={stylesButton.icon} />
</Show>
}
onClick={props.action}
isSubscribeButton={true}
class={clsx(styles.actionButton, {
[styles.iconed]: props.iconButtons,
[stylesButton.subscribed]: props.isSubscribed,
})}
/>
</Show>
</Show>
</div>
)
}

View File

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

View File

@ -1 +1 @@
export * from './Icon'
export { Icon } from './Icon'

View File

@ -10,7 +10,6 @@ import { useAuthorsStore } from '../../../stores/zine/authors'
import { AuthorBadge } from '../../Author/AuthorBadge'
import { Button } from '../Button'
import { DropdownSelect } from '../DropdownSelect'
import { Loading } from '../Loading'
import { InlineLoader } from '../../InlineLoader'
import styles from './InviteMembers.module.scss'

View File

@ -1 +1 @@
export * from './Popup'
export { Popup, type PopupProps } from './Popup'

View File

@ -1,7 +1,7 @@
import { clsx } from 'clsx'
import { For, Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
import SwiperCore from 'swiper'
import { Manipulation, Navigation, Pagination } from 'swiper/modules'
import { HashNavigation, Manipulation, Navigation, Pagination } from 'swiper/modules'
import { throttle } from 'throttle-debounce'
import { MediaItem } from '../../../pages/types'
@ -12,6 +12,8 @@ import { Lightbox } from '../Lightbox'
import { SwiperRef } from './swiper'
import { useRouter } from '../../../stores/router'
import { ArticlePageSearchParams } from '../../Article/FullArticle'
import styles from './Swiper.module.scss'
type Props = {
@ -31,10 +33,13 @@ export const ImageSwiper = (props: Props) => {
const [slideIndex, setSlideIndex] = createSignal(0)
const [isMobileView, setIsMobileView] = createSignal(false)
const [selectedImage, setSelectedImage] = createSignal('')
const { searchParams, changeSearchParams } = useRouter<ArticlePageSearchParams>()
const handleSlideChange = () => {
thumbSwipeRef.current.swiper.slideTo(mainSwipeRef.current.swiper.activeIndex)
setSlideIndex(mainSwipeRef.current.swiper.activeIndex)
const activeIndex = mainSwipeRef.current.swiper.activeIndex
thumbSwipeRef.current.swiper.slideTo(activeIndex)
setSlideIndex(activeIndex)
changeSearchParams({ slide: `${activeIndex + 1}` })
}
createEffect(
@ -51,8 +56,19 @@ export const ImageSwiper = (props: Props) => {
onMount(async () => {
const { register } = await import('swiper/element/bundle')
register()
SwiperCore.use([Pagination, Navigation, Manipulation])
mainSwipeRef.current?.swiper?.on('slideChange', handleSlideChange)
SwiperCore.use([Pagination, Navigation, Manipulation, HashNavigation])
while (!mainSwipeRef.current?.swiper) {
await new Promise((resolve) => setTimeout(resolve, 10)) // wait 10 ms
}
mainSwipeRef.current.swiper.on('slideChange', handleSlideChange)
const initialSlide = Number.parseInt(searchParams().slide) - 1
if (initialSlide && !Number.isNaN(initialSlide) && initialSlide < props.images.length) {
mainSwipeRef.current.swiper.slideTo(initialSlide, 0)
} else {
changeSearchParams({ slide: '1' })
}
mainSwipeRef.current.swiper.init()
})
onMount(() => {
@ -103,6 +119,9 @@ export const ImageSwiper = (props: Props) => {
watch-slides-visibility={true}
direction={'horizontal'}
slides-per-group-auto={true}
hash-navigation={{
watchState: true,
}}
>
<For each={props.images}>
{(slide, index) => (
@ -149,7 +168,7 @@ export const ImageSwiper = (props: Props) => {
{(slide, index) => (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<swiper-slide lazy="true" virtual-index={index()}>
<swiper-slide lazy="true" virtual-index={index()} data-hash={index() + 1}>
<div class={styles.image} onClick={handleImageClick}>
<Image src={slide.url} alt={slide.title} width={800} />
</div>

View File

@ -135,9 +135,13 @@
.counter {
@include font-size(1.2rem);
@include media-breakpoint-up(sm) {
top: 477px;
}
position: absolute;
z-index: 2;
top: 477px;
top: 276px;
right: 0;
font-weight: 600;
padding: 0.2rem 0.8rem;

View File

@ -2,20 +2,29 @@ import { Accessor, JSX, createContext, createEffect, createSignal, useContext }
import { createStore } from 'solid-js/store'
import { apiClient } from '../graphql/client/core'
import { Author, AuthorFollowsResult, FollowingEntity } from '../graphql/schema/core.gen'
import { Author, AuthorFollowsResult, Community, FollowingEntity, Topic } 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' }
interface FollowingContextType {
loading: Accessor<boolean>
followers: Accessor<Array<Author>>
followers: Accessor<Author[]>
subscriptions: AuthorFollowsResult
setSubscriptions: (subscriptions: AuthorFollowsResult) => void
setFollowing: (what: FollowingEntity, slug: string, value: boolean) => void
loadSubscriptions: () => void
follow: (what: FollowingEntity, slug: string) => Promise<void>
unfollow: (what: FollowingEntity, slug: string) => Promise<void>
isOwnerSubscribed: (id: number | string) => boolean
// followers: Accessor<Author[]>
subscribeInAction?: Accessor<SubscribeAction>
}
const FollowingContext = createContext<FollowingContextType>()
@ -32,7 +41,7 @@ const EMPTY_SUBSCRIPTIONS: AuthorFollowsResult = {
export const FollowingProvider = (props: { children: JSX.Element }) => {
const [loading, setLoading] = createSignal<boolean>(false)
const [followers, setFollowers] = createSignal<Array<Author>>([])
const [followers, setFollowers] = createSignal<Author[]>([])
const [subscriptions, setSubscriptions] = createStore<AuthorFollowsResult>(EMPTY_SUBSCRIPTIONS)
const { author, session } = useSession()
@ -43,7 +52,6 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
console.debug('[context.following] fetching subs data...')
const result = await apiClient.getAuthorFollows({ user: session()?.user.id })
setSubscriptions(result || EMPTY_SUBSCRIPTIONS)
console.info('[context.following] subs:', subscriptions)
}
} catch (error) {
console.info('[context.following] cannot get subs', error)
@ -52,38 +60,50 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
}
}
createEffect(() => {
console.info('[context.following] subs:', subscriptions)
})
const [subscribeInAction, setSubscribeInAction] = createSignal<SubscribeAction>()
const follow = async (what: FollowingEntity, slug: string) => {
if (!author()) return
setSubscribeInAction({ slug, type: 'subscribe' })
try {
await apiClient.follow({ what, slug })
const subscriptionData = await apiClient.follow({ what, slug })
setSubscriptions((prevSubscriptions) => {
const updatedSubs = { ...prevSubscriptions }
if (!updatedSubs[what]) updatedSubs[what] = []
const exists = updatedSubs[what]?.some((entity) => entity.slug === slug)
if (!exists) updatedSubs[what].push(slug)
return updatedSubs
if (!prevSubscriptions[what]) prevSubscriptions[what] = []
prevSubscriptions[what].push(subscriptionData)
return prevSubscriptions
})
} catch (error) {
console.error(error)
} finally {
setSubscribeInAction() // Сбрасываем состояние действия подписки.
}
}
const unfollow = async (what: FollowingEntity, slug: string) => {
if (!author()) return
setSubscribeInAction({ slug: slug, type: 'unsubscribe' })
try {
await apiClient.unfollow({ what, slug })
} catch (error) {
console.error(error)
} finally {
setSubscribeInAction()
}
}
createEffect(() => {
if (author()) {
try {
const { authors, followers, topics } = session().user.app_data
const appdata = session()?.user.app_data
if (appdata) {
const { authors, followers, topics } = appdata
setSubscriptions({ authors, topics })
setFollowers(followers)
if (!authors) fetchData()
}
} catch (e) {
console.error(e)
}
@ -111,23 +131,17 @@ export const FollowingProvider = (props: { children: JSX.Element }) => {
}
}
const isOwnerSubscribed = (id?: number | string) => {
if (!author() || !subscriptions) return
const isAuthorSubscribed = subscriptions.authors?.some((authorEntity) => authorEntity.id === id)
const isTopicSubscribed = subscriptions.topics?.some((topicEntity) => topicEntity.slug === id)
return !!isAuthorSubscribed || !!isTopicSubscribed
}
const value: FollowingContextType = {
loading,
subscriptions,
setSubscriptions,
isOwnerSubscribed,
setFollowing,
followers,
loadSubscriptions: fetchData,
follow,
unfollow,
// followers,
subscribeInAction,
}
return <FollowingContext.Provider value={value}>{props.children}</FollowingContext.Provider>

View File

@ -40,11 +40,11 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
const [unreadNotificationsCount, setUnreadNotificationsCount] = createSignal(0)
const [totalNotificationsCount, setTotalNotificationsCount] = createSignal(0)
const [notificationEntities, setNotificationEntities] = createStore<Record<string, NotificationGroup>>({})
const { isAuthenticated } = useSession()
const { author } = useSession()
const { addHandler } = useConnect()
const loadNotificationsGrouped = async (options: { after: number; limit?: number; offset?: number }) => {
if (isAuthenticated() && notifierClient?.private) {
if (author()?.id && notifierClient?.private) {
const notificationsResult = await notifierClient.getNotifications(options)
const groups = notificationsResult?.notifications || []
const total = notificationsResult?.total || 0
@ -74,7 +74,7 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
onMount(() => {
addHandler((data: SSEMessage) => {
if (data.entity === 'reaction' && isAuthenticated()) {
if (data.entity === 'reaction' && author()?.id) {
console.info('[context.notifications] event', data)
loadNotificationsGrouped({ after: after(), limit: Math.max(PAGE_SIZE, loadedNotificationsCount()) })
}
@ -91,14 +91,14 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
}
const markSeenAll = async () => {
if (isAuthenticated() && notifierClient.private) {
if (author()?.id && notifierClient.private) {
await notifierClient.markSeenAfter({ after: after() })
await loadNotificationsGrouped({ after: after(), limit: loadedNotificationsCount() })
}
}
const markSeen = async (notification_id: number) => {
if (isAuthenticated() && notifierClient.private) {
if (author()?.id && notifierClient.private) {
await notifierClient.markSeen(notification_id)
await loadNotificationsGrouped({ after: after(), limit: loadedNotificationsCount() })
}

View File

@ -1,5 +1,5 @@
import type { Accessor, JSX, Resource } from 'solid-js'
import type { AuthModalSource } from '../components/Nav/AuthModal/types'
import type { AuthModalSearchParams, AuthModalSource } from '../components/Nav/AuthModal/types'
import type { Author } from '../graphql/schema/core.gen'
import {
@ -29,7 +29,6 @@ import {
import { inboxClient } from '../graphql/client/chat'
import { apiClient } from '../graphql/client/core'
import { notifierClient } from '../graphql/client/notifier'
import { useRouter } from '../stores/router'
import { showModal } from '../stores/ui'
import { addAuthors } from '../stores/zine/authors'
@ -49,7 +48,6 @@ export type SessionContextType = {
author: Resource<Author | null>
authError: Accessor<string>
isSessionLoaded: Accessor<boolean>
isAuthenticated: Accessor<boolean>
loadSession: () => AuthToken | Promise<AuthToken>
setSession: (token: AuthToken | null) => void // setSession
loadAuthor: (info?: unknown) => Author | Promise<Author>
@ -73,6 +71,7 @@ export type SessionContextType = {
resendVerifyEmail: (params: ResendVerifyEmailInput) => Promise<GenericResponse>
}
// biome-ignore lint/suspicious/noEmptyBlockStatements: <explanation>
const noop = () => {}
const SessionContext = createContext<SessionContextType>()
@ -136,6 +135,7 @@ export const SessionProvider = (props: {
const [isSessionLoaded, setIsSessionLoaded] = createSignal(false)
const [authError, setAuthError] = createSignal('')
const { clearSearchParams } = useRouter<AuthModalSearchParams>()
// Function to load session data
const sessionData = async () => {
@ -143,7 +143,7 @@ export const SessionProvider = (props: {
const s: ApiResponse<AuthToken> = await authorizer().getSession()
if (s?.data) {
console.info('[context.session] loading session', s)
clearSearchParams()
// Set session expiration time in local storage
const expires_at = new Date(Date.now() + s.data.expires_in * 1000)
localStorage.setItem('expires_at', `${expires_at.getTime()}`)
@ -220,10 +220,13 @@ export const SessionProvider = (props: {
}
try {
const { profile } = session().user.app_data
const appdata = session()?.user.app_data
if (appdata) {
const { profile } = appdata
setAuthor(profile)
addAuthors([profile])
if (!profile) loadAuthor()
}
} catch (e) {
console.error(e)
}
@ -267,12 +270,9 @@ export const SessionProvider = (props: {
// callback state updater
createEffect(
on(
() => props.onStateChangeCallback,
() => {
props.onStateChangeCallback(session())
},
),
on([() => props.onStateChangeCallback, session], ([_, ses]) => {
ses?.user?.id && props.onStateChangeCallback(ses)
}),
)
const [authCallback, setAuthCallback] = createSignal<() => void>(noop)
@ -374,8 +374,6 @@ export const SessionProvider = (props: {
console.warn(error)
}
}
const isAuthenticated = createMemo(() => Boolean(author()))
const actions = {
loadSession,
requireAuthentication,
@ -400,7 +398,6 @@ export const SessionProvider = (props: {
isSessionLoaded,
author,
...actions,
isAuthenticated,
resendVerifyEmail,
}

View File

@ -25,7 +25,7 @@ const setupIndexedDB = async () => {
})
}
const getTopicsFromIndexedDB = async (db) => {
const getTopicsFromIndexedDB = (db) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
return store.getAll()
@ -44,7 +44,7 @@ export const TopicsProvider = (props: { children: JSX.Element }) => {
onMount(async () => {
const db = await setupIndexedDB()
let topics = await getTopicsFromIndexedDB(db)
let topics = getTopicsFromIndexedDB(db)
if (topics.length === 0) {
topics = await apiClient.getAllTopics()

View File

@ -1,4 +1,3 @@
import { createGraphQLClient } from '../createGraphQLClient'
import markSeenMutation from '../mutation/notifier/mark-seen'
import markSeenAfterMutation from '../mutation/notifier/mark-seen-after'
import markThreadSeenMutation from '../mutation/notifier/mark-seen-thread'

View File

@ -4,6 +4,10 @@ export default gql`
mutation FollowMutation($what: FollowingEntity!, $slug: String!) {
follow(what: $what, slug: $slug) {
error
authors {
id
slug
}
}
}
`

View File

@ -1,6 +1,6 @@
import type { PageProps } from './types'
import { createEffect, createSignal, onMount } from 'solid-js'
import { createSignal, onMount } from 'solid-js'
import { AllAuthors } from '../components/Views/AllAuthors/'
import { PAGE_SIZE } from '../components/Views/AllTopics/AllTopics'

View File

@ -1,12 +1,12 @@
import { Show, Suspense, createMemo, createSignal, lazy, onMount } from 'solid-js'
import { Show, Suspense, createEffect, createMemo, createSignal, lazy, on, onMount } from 'solid-js'
import { AuthGuard } from '../components/AuthGuard'
import { Loading } from '../components/_shared/Loading'
import { PageLayout } from '../components/_shared/PageLayout'
import { useLocalize } from '../context/localize'
import { useSession } from '../context/session'
import { apiClient } from '../graphql/client/core'
import { Shout } from '../graphql/schema/core.gen'
import { useRouter } from '../stores/router'
import { router } from '../stores/router'
import { redirectPage } from '@nanostores/router'
@ -15,68 +15,70 @@ import { LayoutType } from './types'
const EditView = lazy(() => import('../components/Views/EditView/EditView'))
export const EditPage = () => {
const { page } = useRouter()
const snackbar = useSnackbar()
const { t } = useLocalize()
const getContentTypeTitle = (layout: LayoutType) => {
switch (layout) {
case 'audio':
return 'Publish Album'
case 'image':
return 'Create gallery'
case 'video':
return 'Create video'
case 'literature':
return 'New literary work'
default:
return 'Write an article'
}
}
const [shout, setShout] = createSignal<Shout>(null)
const loadMyShout = async (shout_id: number) => {
if (shout_id) {
const { shout: loadedShout, error } = await apiClient.getMyShout(shout_id)
console.log(loadedShout)
if (error) {
await snackbar?.showSnackbar({ type: 'error', body: t('This content is not published yet') })
export const EditPage = () => {
const { t } = useLocalize()
const { session } = useSession()
const snackbar = useSnackbar()
const fail = async (error: string) => {
console.error(error)
await snackbar?.showSnackbar({ type: 'error', body: t(error) })
redirectPage(router, 'drafts')
}
const [shoutId, setShoutId] = createSignal<number>(0)
const [shout, setShout] = createSignal<Shout>()
onMount(() => {
const shoutId = window.location.pathname.split('/').pop()
const shoutIdFromUrl = Number.parseInt(shoutId ?? '0', 10)
console.debug(`editing shout ${shoutIdFromUrl}`)
if (shoutIdFromUrl) setShoutId(shoutIdFromUrl)
})
createEffect(
on([session, shout, shoutId], async ([ses, sh, shid]) => {
if (ses?.user && !sh && shid) {
const { shout: loadedShout, error } = await apiClient.getMyShout(shid)
if (error) {
fail(error)
} else {
setShout(loadedShout)
}
}
}
onMount(async () => {
const shout_id = window.location.pathname.split('/').pop()
if (shout_id) {
try {
await loadMyShout(parseInt(shout_id, 10))
} catch (e) {
console.error(e)
}
}
})
}),
)
const title = createMemo(() => {
if (!shout()) {
return t('Create post')
}
switch (shout().layout as LayoutType) {
case 'audio': {
return t('Publish Album')
}
case 'image': {
return t('Create gallery')
}
case 'video': {
return t('Create video')
}
case 'literature': {
return t('New literary work')
}
default: {
return t('Write an article')
}
}
return t(getContentTypeTitle(shout()?.layout as LayoutType))
})
return (
<PageLayout title={title()}>
<AuthGuard>
<Show when={shout()}>
<Suspense fallback={<Loading />}>
<EditView shout={shout()} />
</Suspense>
<Show when={shout()} fallback={<Loading />}>
<EditView shout={shout() as Shout} />
</Show>
</Suspense>
</AuthGuard>
</PageLayout>
)

View File

@ -320,3 +320,14 @@ h5 {
margin-bottom: 0;
}
}
// disable last pass extention
div[data-lastpass-icon-root="true"] {
opacity: 0 !important;
}
div[data-lastpass-infield="true"] {
opacity: 0 !important;
}

View File

@ -53,9 +53,4 @@ export type UploadedFile = {
originalFilename?: string
}
export type FollowedInfo = {
value?: boolean
loaded?: boolean
}
export type SubscriptionFilter = 'all' | 'authors' | 'topics' | 'communities'

View File

@ -69,7 +69,8 @@ const checkOpenOnClient = (link: HTMLAnchorElement, event) => {
)
}
const scrollToHash = (hash: string) => {
// TODO: use scrollToHash or remove
const _scrollToHash = (hash: string) => {
let selector = hash
if (/^#\d+/.test(selector)) {
@ -114,8 +115,8 @@ const handleClientRouteLinkClick = async (event) => {
}
if (url.hash) {
scrollToHash(url.hash)
return
// scrollToHash(url.hash)
// return
}
window.scrollTo({

View File

@ -6,8 +6,8 @@ import { Author, QueryLoad_Authors_ByArgs } from '../../graphql/schema/core.gen'
export type AuthorsSortBy = 'shouts' | 'name' | 'followers'
type SortedAuthorsSetter = (prev: Author[]) => Author[]
const [sortAllBy, setSortAllBy] = createSignal<AuthorsSortBy>('name')
// FIXME: use signal or remove
const [_sortAllBy, setSortAllBy] = createSignal<AuthorsSortBy>('name')
export const setAuthorsSort = (sortBy: AuthorsSortBy) => setSortAllBy(sortBy)

View File

@ -588,6 +588,7 @@ figure {
display: block;
max-height: 90vh;
margin: auto;
width: 100%;
}
}

View File

@ -1,31 +1,41 @@
import { cdnUrl, thumborUrl } from './config'
const getSizeUrlPart = (options: { width?: number; height?: number; noSizeUrlPart?: boolean } = {}) => {
const widthString = options.width ? options.width.toString() : ''
const heightString = options.height ? options.height.toString() : ''
const URL_CONFIG = {
cdnUrl: cdnUrl,
thumborUrl: `${thumborUrl}/unsafe/`,
audioSubfolder: 'audio',
imageSubfolder: 'image',
productionFolder: 'production/',
}
if (!(widthString || heightString) || options.noSizeUrlPart) {
return ''
}
const AUDIO_EXTENSIONS = new Set(['wav', 'mp3', 'ogg', 'aif', 'flac'])
return `${widthString}x${heightString}/`
const isAudioFile = (filename: string): boolean => {
const extension = filename.split('.').pop()?.toLowerCase()
return AUDIO_EXTENSIONS.has(extension ?? '')
}
const getLastSegment = (url: string): string => url.toLowerCase().split('/').pop() || ''
const buildSizePart = (width?: number, height?: number, includeSize = true): string => {
if (!includeSize) return ''
const widthPart = width ? width.toString() : ''
const heightPart = height ? height.toString() : ''
return widthPart || heightPart ? `${widthPart}x${heightPart}/` : ''
}
export const getImageUrl = (
src: string,
options: { width?: number; height?: number; noSizeUrlPart?: boolean } = {},
) => {
): string => {
if (!src.includes('discours.io') && src.includes('http')) {
return src
}
const filename = src.toLowerCase().split('/').pop()
const ext = filename.split('.').pop()
const isAudio = ext in ['wav', 'mp3', 'ogg', 'aif', 'flac']
const base = isAudio ? cdnUrl : `${thumborUrl}/unsafe/`
const suffix = isAudio || options.noSizeUrlPart ? '' : getSizeUrlPart(options)
const subfolder = isAudio ? 'audio' : 'image'
const filename = getLastSegment(src)
const base = isAudioFile(filename) ? URL_CONFIG.cdnUrl : URL_CONFIG.thumborUrl
const suffix = options.noSizeUrlPart ? '' : buildSizePart(options.width, options.height)
const subfolder = isAudioFile(filename) ? URL_CONFIG.audioSubfolder : URL_CONFIG.imageSubfolder
return `${base}${suffix}production/${subfolder}/${filename}`
return `${base}${suffix}${URL_CONFIG.productionFolder}${subfolder}/${filename}`
}
export const getOpenGraphImageUrl = (
@ -37,17 +47,16 @@ export const getOpenGraphImageUrl = (
width?: number
height?: number
},
) => {
const sizeUrlPart = getSizeUrlPart(options)
): string => {
const sizeUrlPart = buildSizePart(options.width, options.height)
const filtersPart = `filters:discourstext('${encodeURIComponent(options.topic)}','${encodeURIComponent(
options.author,
)}','${encodeURIComponent(options.title)}')/`
if (src.startsWith(thumborUrl)) {
const thumborKey = src.replace(`${thumborUrl}/unsafe`, '')
return `${thumborUrl}/unsafe/${sizeUrlPart}${filtersPart}${thumborKey}`
if (src.startsWith(URL_CONFIG.thumborUrl)) {
const thumborKey = src.replace(URL_CONFIG.thumborUrl, '')
return `${URL_CONFIG.thumborUrl}${sizeUrlPart}${filtersPart}${thumborKey}`
}
return `${thumborUrl}/unsafe/${sizeUrlPart}${filtersPart}${src}`
return `${URL_CONFIG.thumborUrl}${sizeUrlPart}${filtersPart}${src}`
}

View File

@ -2,6 +2,7 @@ import { RANDOM_TOPICS_COUNT } from '../components/Views/Home'
import { Topic } from '../graphql/schema/core.gen'
export const getRandomTopicsFromArray = (topics: Topic[], count: number = RANDOM_TOPICS_COUNT): Topic[] => {
if (!Array.isArray(topics)) return []
const shuffledTopics = [...topics].sort(() => 0.5 - Math.random())
return shuffledTopics.slice(0, count)
}

View File

@ -1,6 +1,7 @@
import ssrPlugin from 'vike/plugin'
import { defineConfig } from 'vite'
import mkcert from 'vite-plugin-mkcert'
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import sassDts from 'vite-plugin-sass-dts'
import solidPlugin from 'vite-plugin-solid'
@ -39,6 +40,19 @@ export default defineConfig(({ mode, command }) => {
ssrPlugin({ includeAssetsImportedByServer: true }),
sassDts(),
cssModuleHMR(),
nodePolyfills({
include: ['path', 'stream', 'util'],
exclude: ['http'],
globals: {
Buffer: true,
//global: true,
//process: true,
},
overrides: {
fs: 'memfs',
},
protocolImports: true,
}),
]
if (command === 'serve') {