diff --git a/public/icons/feed-all.svg b/public/icons/feed-all.svg index 486d1409..629f6105 100644 --- a/public/icons/feed-all.svg +++ b/public/icons/feed-all.svg @@ -1,3 +1,3 @@ - + diff --git a/public/icons/toggle-arrow.svg b/public/icons/toggle-arrow.svg new file mode 100644 index 00000000..fb552529 --- /dev/null +++ b/public/icons/toggle-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 394eaacc..a6780846 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -529,5 +529,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..." } diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 16e402fa..055ec6fa 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -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": "Введите почту", @@ -556,5 +556,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...": "Отписываем..." } diff --git a/src/components/App.tsx b/src/components/App.tsx index 15ce90bf..726dd596 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -40,6 +40,7 @@ import { InboxPage } from '../pages/inbox.page' import { HomePage } from '../pages/index.page' import { ProfileSecurityPage } from '../pages/profile/profileSecurity.page' import { ProfileSettingsPage } from '../pages/profile/profileSettings.page' +//TODO: ProfileSubscriptionsPage - garbage code? import { ProfileSubscriptionsPage } from '../pages/profile/profileSubscriptions.page' import { SearchPage } from '../pages/search.page' import { TopicPage } from '../pages/topic.page' diff --git a/src/components/Article/Article.module.scss b/src/components/Article/Article.module.scss index ad85c0a3..55b8dd95 100644 --- a/src/components/Article/Article.module.scss +++ b/src/components/Article/Article.module.scss @@ -22,6 +22,7 @@ img { .articleContent { img:not([data-disable-lightbox='true']) { cursor: zoom-in; + width: 100%; } } diff --git a/src/components/Article/Comment/Comment.module.scss b/src/components/Article/Comment/Comment.module.scss index 50170022..0191442c 100644 --- a/src/components/Article/Comment/Comment.module.scss +++ b/src/components/Article/Comment/Comment.module.scss @@ -179,6 +179,10 @@ @include font-size(1.2rem); } +.commentAuthor { + margin-right: 2rem; +} + .articleAuthor { @include font-size(1.2rem); diff --git a/src/components/Article/CommentDate/CommentDate.module.scss b/src/components/Article/CommentDate/CommentDate.module.scss index 50cf7d57..c648cca1 100644 --- a/src/components/Article/CommentDate/CommentDate.module.scss +++ b/src/components/Article/CommentDate/CommentDate.module.scss @@ -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; diff --git a/src/components/Article/FullArticle.tsx b/src/components/Article/FullArticle.tsx index aee81066..6f76d47a 100644 --- a/src/components/Article/FullArticle.tsx +++ b/src/components/Article/FullArticle.tsx @@ -60,7 +60,7 @@ export type ArticlePageSearchParams = { 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', }) @@ -152,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( @@ -318,6 +305,19 @@ export const FullArticle = (props: Props) => { install('G-LQ4B87H8C2') 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 }) + } + }) }) createEffect( @@ -341,7 +341,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}` }) diff --git a/src/components/Author/AuthorBadge/AuthorBadge.module.scss b/src/components/Author/AuthorBadge/AuthorBadge.module.scss index 8dc68f4b..ebd5d145 100644 --- a/src/components/Author/AuthorBadge/AuthorBadge.module.scss +++ b/src/components/Author/AuthorBadge/AuthorBadge.module.scss @@ -115,8 +115,4 @@ } } } - - .actionButtonLabelHovered { - display: none; - } } diff --git a/src/components/Author/AuthorBadge/AuthorBadge.tsx b/src/components/Author/AuthorBadge/AuthorBadge.tsx index da72bdd7..f2a99c46 100644 --- a/src/components/Author/AuthorBadge/AuthorBadge.tsx +++ b/src/components/Author/AuthorBadge/AuthorBadge.tsx @@ -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,13 +27,19 @@ 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() + const [isSubscribed, setIsSubscribed] = createSignal() + + createEffect(() => { + if (!subscriptions || !props.author) return + const subscribed = subscriptions.authors?.some((authorEntity) => authorEntity.id === props.author?.id) + setIsSubscribed(subscribed) + }) createEffect(() => { setIsMobileView(!mediaMatches.sm) @@ -76,10 +80,10 @@ export const AuthorBadge = (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') } @@ -133,55 +137,13 @@ export const AuthorBadge = (props: Props) => {
- } - > - - - - } - onClick={handleFollowClick} - isSubscribeButton={true} - class={clsx(styles.actionButton, { - [styles.iconed]: props.iconButtons, - [stylesButton.subscribed]: isFollowed(), - })} - /> - } - > -
+ + + } > diff --git a/src/components/ProfileSettings/ProfileSettings.tsx b/src/components/ProfileSettings/ProfileSettings.tsx index 90a23b33..9d405a9b 100644 --- a/src/components/ProfileSettings/ProfileSettings.tsx +++ b/src/components/ProfileSettings/ProfileSettings.tsx @@ -195,7 +195,7 @@ export const ProfileSettings = () => {

{t('Profile settings')}

{t('Here you can customize your profile the way you want.')}

-
+

{t('Userpic')}

{
updateFormField('name', event.currentTarget.value)} value={form.name} ref={(el) => (nameInputRef.current = el)} /> - +
{ 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)} diff --git a/src/components/TableOfContents/TableOfContents.tsx b/src/components/TableOfContents/TableOfContents.tsx index f7000e20..322aa925 100644 --- a/src/components/TableOfContents/TableOfContents.tsx +++ b/src/components/TableOfContents/TableOfContents.tsx @@ -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({ diff --git a/src/components/Topic/Card.tsx b/src/components/Topic/Card.tsx index 35965f67..71a150d1 100644 --- a/src/components/Topic/Card.tsx +++ b/src/components/Topic/Card.tsx @@ -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,18 @@ 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) + follow(FollowingEntity.Topic, props.topic.slug) }, 'subscribe') } @@ -53,12 +57,12 @@ export const TopicCard = (props: TopicProps) => { return ( <> - + - + {t('Unfollow')} {t('Following')} @@ -130,7 +134,7 @@ export const TopicCard = (props: TopicProps) => { fallback={ } @@ -142,10 +146,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()} /> diff --git a/src/components/Topic/TopicBadge/TopicBadge.tsx b/src/components/Topic/TopicBadge/TopicBadge.tsx index 2fdf9c65..2ba9a5b5 100644 --- a/src/components/Topic/TopicBadge/TopicBadge.tsx +++ b/src/components/Topic/TopicBadge/TopicBadge.tsx @@ -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() + 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 = !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) => {
-
- + - - } - > -
diff --git a/src/components/Views/AllTopics/AllTopics.tsx b/src/components/Views/AllTopics/AllTopics.tsx index fdb8b532..20e14709 100644 --- a/src/components/Views/AllTopics/AllTopics.tsx +++ b/src/components/Views/AllTopics/AllTopics.tsx @@ -74,8 +74,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 +186,7 @@ export const AllTopics = (props: Props) => { {(topic) => ( <> - 0, - value: isOwnerSubscribed(topic.slug), - }} - showStat={true} - /> + )} diff --git a/src/components/Views/Author/Author.tsx b/src/components/Views/Author/Author.tsx index 4ddb5649..3c87d04b 100644 --- a/src/components/Views/Author/Author.tsx +++ b/src/components/Views/Author/Author.tsx @@ -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' @@ -75,7 +75,7 @@ export const AuthorView = (props: Props) => { 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, authorResult] = await Promise.all([ apiClient.getAuthorFollows({ slug }), @@ -112,14 +112,9 @@ export const AuthorView = (props: Props) => { onMount(() => { if (!modal) hideModal() - checkBioHeight() fetchData(props.authorSlug) - - // pagination - if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) { - loadMore() - loadSubscriptions() - } + checkBioHeight() + loadMore() }) const pages = createMemo(() => @@ -133,11 +128,17 @@ export const AuthorView = (props: Props) => { setCommented(data) } - createEffect(() => { - if (author()) { - fetchComments(author()) - } - }) + const authorSlug = createMemo(() => author()?.slug) + createEffect( + on( + () => authorSlug(), + () => { + fetchData(authorSlug()) + fetchComments(author()) + }, + { defer: true }, + ), + ) const ogImage = createMemo(() => author()?.pic diff --git a/src/components/Views/Expo/Expo.tsx b/src/components/Views/Expo/Expo.tsx index 82e43dfe..8506bf24 100644 --- a/src/components/Views/Expo/Expo.tsx +++ b/src/components/Views/Expo/Expo.tsx @@ -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(props.shouts)) + const { t } = useLocalize() const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) - const [favoriteTopArticles, setFavoriteTopArticles] = createSignal([]) const [reactedTopMonthArticles, setReactedTopMonthArticles] = createSignal([]) - - const { t } = useLocalize() - - const { sortedArticles } = useArticlesStore({ - shouts: isLoaded() ? props.shouts : [], - layout: props.layout, - }) - + const [articlesEndPage, setArticlesEndPage] = createSignal(PRERENDERED_ARTICLES_COUNT) + const [expoShouts, setExpoShouts] = createSignal([]) 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 (
- 0} fallback={}> + 0} fallback={}>
  • @@ -194,7 +179,7 @@ export const Expo = (props: Props) => {
- + {(shout) => (
{ 0} keyed={true}> - + {(shout) => (
{ 0} keyed={true}> - + {(shout) => (
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) } diff --git a/src/components/_shared/BadgeSubscribeButton/BadgeDubscribeButton.module.scss b/src/components/_shared/BadgeSubscribeButton/BadgeDubscribeButton.module.scss new file mode 100644 index 00000000..b5a7480f --- /dev/null +++ b/src/components/_shared/BadgeSubscribeButton/BadgeDubscribeButton.module.scss @@ -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; +} diff --git a/src/components/_shared/BadgeSubscribeButton/BadgeSubscribeButton.tsx b/src/components/_shared/BadgeSubscribeButton/BadgeSubscribeButton.tsx new file mode 100644 index 00000000..3b1ffded --- /dev/null +++ b/src/components/_shared/BadgeSubscribeButton/BadgeSubscribeButton.tsx @@ -0,0 +1,87 @@ +import { clsx } from 'clsx' +import { Show, createMemo } from 'solid-js' +import { useFollowing } from '../../../context/following' +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 ( +
+ } + > + + + + } + onClick={props.action} + isSubscribeButton={true} + class={clsx(styles.actionButton, { + [styles.iconed]: props.iconButtons, + [stylesButton.subscribed]: props.isSubscribed, + })} + /> + } + > +
+ ) +} diff --git a/src/components/_shared/BadgeSubscribeButton/index.ts b/src/components/_shared/BadgeSubscribeButton/index.ts new file mode 100644 index 00000000..b359ecff --- /dev/null +++ b/src/components/_shared/BadgeSubscribeButton/index.ts @@ -0,0 +1 @@ +export { BadgeSubscribeButton } from './BadgeSubscribeButton' diff --git a/src/context/following.tsx b/src/context/following.tsx index fc92565e..7859102b 100644 --- a/src/context/following.tsx +++ b/src/context/following.tsx @@ -1,11 +1,19 @@ -import { Accessor, JSX, createContext, createEffect, createSignal, useContext } from 'solid-js' +import { Accessor, JSX, createContext, createEffect, createMemo, createSignal, useContext } from 'solid-js' 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 followers: Accessor> @@ -15,7 +23,8 @@ interface FollowingContextType { loadSubscriptions: () => void follow: (what: FollowingEntity, slug: string) => Promise unfollow: (what: FollowingEntity, slug: string) => Promise - isOwnerSubscribed: (id: number | string) => boolean + // followers: Accessor + subscribeInAction?: Accessor } const FollowingContext = createContext() @@ -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,28 +60,37 @@ export const FollowingProvider = (props: { children: JSX.Element }) => { } } + createEffect(() => { + console.info('[context.following] subs:', subscriptions) + }) + + const [subscribeInAction, setSubscribeInAction] = createSignal() 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() } } @@ -114,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 {props.children} diff --git a/src/context/session.tsx b/src/context/session.tsx index fa068bc8..3a189cc1 100644 --- a/src/context/session.tsx +++ b/src/context/session.tsx @@ -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' @@ -136,6 +135,7 @@ export const SessionProvider = (props: { const [isSessionLoaded, setIsSessionLoaded] = createSignal(false) const [authError, setAuthError] = createSignal('') + const { clearSearchParams } = useRouter() // Function to load session data const sessionData = async () => { @@ -143,7 +143,7 @@ export const SessionProvider = (props: { const s: ApiResponse = 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()}`) @@ -379,6 +379,7 @@ export const SessionProvider = (props: { } const isAuthenticated = createMemo(() => Boolean(author())) + const actions = { loadSession, requireAuthentication, diff --git a/src/graphql/mutation/core/follow.ts b/src/graphql/mutation/core/follow.ts index 07ba6472..528dfd46 100644 --- a/src/graphql/mutation/core/follow.ts +++ b/src/graphql/mutation/core/follow.ts @@ -4,6 +4,10 @@ export default gql` mutation FollowMutation($what: FollowingEntity!, $slug: String!) { follow(what: $what, slug: $slug) { error + authors { + id + slug + } } } ` diff --git a/src/pages/profile/Settings.module.scss b/src/pages/profile/Settings.module.scss index 898b2496..9bd4906c 100644 --- a/src/pages/profile/Settings.module.scss +++ b/src/pages/profile/Settings.module.scss @@ -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; +} + diff --git a/src/pages/types.ts b/src/pages/types.ts index af588d9f..dc4d7132 100644 --- a/src/pages/types.ts +++ b/src/pages/types.ts @@ -53,9 +53,4 @@ export type UploadedFile = { originalFilename?: string } -export type FollowedInfo = { - value?: boolean - loaded?: boolean -} - export type SubscriptionFilter = 'all' | 'authors' | 'topics' | 'communities' diff --git a/src/styles/app.scss b/src/styles/app.scss index 7afcb070..0bb124a4 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -588,6 +588,7 @@ figure { display: block; max-height: 90vh; margin: auto; + width: 100%; } } diff --git a/src/utils/getImageUrl.ts b/src/utils/getImageUrl.ts index bc3c9073..e13872cc 100644 --- a/src/utils/getImageUrl.ts +++ b/src/utils/getImageUrl.ts @@ -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}` }