diff --git a/public/icons/check-subscribed-black.svg b/public/icons/check-subscribed-black.svg new file mode 100644 index 00000000..ba971661 --- /dev/null +++ b/public/icons/check-subscribed-black.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/close-white.svg b/public/icons/close-white.svg new file mode 100644 index 00000000..0593ee17 --- /dev/null +++ b/public/icons/close-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index ba3ba3cd..3cc4a2dc 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -9,9 +9,11 @@ "Add audio": "Add audio", "Add blockquote": "Add blockquote", "Add comment": "Comment", + "Here you can manage all your Discourse subscriptions": "Here you can manage all your Discourse subscriptions", "Add cover": "Add cover", "Add image": "Add image", "Add images": "Add images", + "Collections": "Collections", "Add intro": "Add intro", "Add link": "Add link", "Add rule": "Add rule", diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 6ba810df..930a5f30 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -75,6 +75,7 @@ "Confirm": "Подтвердить", "Cooperate": "Соучаствовать", "Copy": "Скопировать", + "Collections": "Коллекции", "Copy link": "Скопировать ссылку", "Corrections history": "История правок", "Create Chat": "Создать чат", @@ -208,6 +209,7 @@ "Move up": "Переместить вверх", "My feed": "Моя лента", "My subscriptions": "Подписки", + "Here you can manage all your Discourse subscriptions": "Здесь можно управлять всеми своими подписками на Дискурсе", "Name": "Имя", "Newsletter": "Рассылка", "New literary work": "Новое произведение", diff --git a/src/components/Author/AuthorCard/AuthorCard.tsx b/src/components/Author/AuthorCard/AuthorCard.tsx index c4547cf4..b63e73fb 100644 --- a/src/components/Author/AuthorCard/AuthorCard.tsx +++ b/src/components/Author/AuthorCard/AuthorCard.tsx @@ -17,8 +17,10 @@ import { Modal } from '../../Nav/Modal' import { showModal } from '../../../stores/ui' import { TopicCard } from '../../Topic/Card' import { getNumeralsDeclension } from '../../../utils/getNumeralsDeclension' +import { SubscriptionFilter } from '../../../pages/types' +import { isAuthor } from '../../../utils/isAuthor' +import { CheckButton } from '../../_shared/CheckButton' -type SubscriptionFilter = 'all' | 'users' | 'topics' type Props = { caption?: string hideWriteButton?: boolean @@ -40,10 +42,7 @@ type Props = { followers?: Author[] following?: Array showPublicationsCounter?: boolean -} - -function isAuthor(value: Author | Topic): value is Author { - return 'name' in value + minimizeSubscribeButton?: boolean } export const AuthorCard = (props: Props) => { @@ -276,12 +275,35 @@ export const AuthorCard = (props: Props) => { {(link) => } - - + + + + + + + {t('Follow')} + + + + } + > - } - > - + /> @@ -425,7 +432,7 @@ export const AuthorCard = (props: Props) => {
- {(subscription: Author | Topic) => + {(subscription) => isAuthor(subscription) ? ( { +export const ProfileSettingsNavigation = () => { const { t } = useLocalize() + const { page } = useRouter() return ( <>

{t('Settings')}

diff --git a/src/components/Nav/ProfileSettingsNavigation/index.ts b/src/components/Nav/ProfileSettingsNavigation/index.ts new file mode 100644 index 00000000..0ce2f11c --- /dev/null +++ b/src/components/Nav/ProfileSettingsNavigation/index.ts @@ -0,0 +1 @@ +export { ProfileSettingsNavigation } from './ProfileSettingsNavigation' diff --git a/src/components/Topic/Card.tsx b/src/components/Topic/Card.tsx index 8e398ecf..785cc04a 100644 --- a/src/components/Topic/Card.tsx +++ b/src/components/Topic/Card.tsx @@ -11,6 +11,7 @@ import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient' import { Icon } from '../_shared/Icon' import { useLocalize } from '../../context/localize' import { CardTopic } from '../Feed/CardTopic' +import { CheckButton } from '../_shared/CheckButton' interface TopicProps { topic: Topic @@ -24,6 +25,7 @@ interface TopicProps { showPublications?: boolean showDescription?: boolean isCardMode?: boolean + minimizeSubscribeButton?: boolean } export const TopicCard = (props: TopicProps) => { @@ -105,27 +107,34 @@ export const TopicCard = (props: TopicProps) => { > - + +
diff --git a/src/components/Views/AllAuthors.tsx b/src/components/Views/AllAuthors.tsx index b3711201..7f202e2e 100644 --- a/src/components/Views/AllAuthors.tsx +++ b/src/components/Views/AllAuthors.tsx @@ -11,6 +11,7 @@ import styles from '../../styles/AllTopics.module.scss' import { SearchField } from '../_shared/SearchField' import { scrollHandler } from '../../utils/scroll' import { useLocalize } from '../../context/localize' +import { dummyFilter } from '../../utils/dummyFilter' type AllAuthorsPageSearchParams = { by: '' | 'name' | 'shouts' | 'followers' @@ -69,26 +70,7 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => { const subscribed = (s) => Boolean(session()?.news?.authors && session()?.news?.authors?.includes(s || '')) const filteredAuthors = createMemo(() => { - let q = searchQuery().toLowerCase() - - if (q.length === 0) { - return sortedAuthors() - } - - if (lang() === 'ru') q = translit(q) - - return sortedAuthors().filter((author) => { - if (author.slug.split('-').some((w) => w.startsWith(q))) { - return true - } - - let name = author.name.toLowerCase() - if (lang() === 'ru') { - name = translit(name) - } - - return name.split(' ').some((word) => word.startsWith(q)) - }) + return dummyFilter(sortedAuthors(), searchQuery(), lang()) }) const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE) @@ -186,7 +168,7 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
{ const subscribed = (s) => Boolean(session()?.news?.topics && session()?.news?.topics?.includes(s || '')) const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE) - const [searchQuery, setSearchQuery] = createSignal('') - const filteredResults = createMemo(() => { - /* very stupid filter by string algorithm with no deps */ - let q = searchQuery().toLowerCase() - if (q.length === 0) { - return sortedTopics() - } - - if (lang() === 'ru') { - q = translit(q) - } - - return sortedTopics().filter((topic) => { - if (topic.slug.split('-').some((w) => w.startsWith(q))) { - return true - } - - let title = topic.title.toLowerCase() - if (lang() === 'ru') { - title = translit(title) - } - - return title.split(' ').some((word) => word.startsWith(q)) - }) + return dummyFilter(sortedTopics(), searchQuery(), lang()) }) const AllTopicsHead = () => ( @@ -180,7 +158,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => { {(topic) => ( <> { const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const [isBioExpanded, setIsBioExpanded] = createSignal(false) const [followers, setFollowers] = createSignal([]) - const [subscriptions, setSubscriptions] = createSignal>([]) - const [bioWrapper, setBioWrapper] = createSignal() + const [following, setFollowing] = createSignal>([]) const [showExpandBioControl, setShowExpandBioControl] = createSignal(false) const bioContainerRef: { current: HTMLDivElement } = { current: null } + const bioWrapperRef: { current: HTMLDivElement } = { current: null } const fetchSubscriptions = async (): Promise<{ authors: Author[]; topics: Topic[] }> => { try { const [getAuthors, getTopics] = await Promise.all([ @@ -62,7 +61,7 @@ export const AuthorView = (props: Props) => { const checkBioHeight = () => { if (bioContainerRef.current) { - setShowExpandBioControl(bioContainerRef.current.offsetHeight > bioWrapper().offsetHeight) + setShowExpandBioControl(bioContainerRef.current.offsetHeight > bioWrapperRef.current.offsetHeight) } } @@ -81,7 +80,7 @@ export const AuthorView = (props: Props) => { await loadMore() } const { authors, topics } = await fetchSubscriptions() - setSubscriptions([...authors, ...topics]) + setFollowing([...authors, ...topics]) }) const loadMore = async () => { @@ -131,7 +130,7 @@ export const AuthorView = (props: Props) => { author={author()} isAuthorPage={true} followers={followers()} - following={subscriptions()} + following={following()} />
@@ -170,7 +169,7 @@ export const AuthorView = (props: Props) => {
(bioWrapperRef.current = el)} class={styles.longBio} classList={{ [styles.longBioExpanded]: isBioExpanded() }} > diff --git a/src/components/_shared/CheckButton/CheckButton.module.scss b/src/components/_shared/CheckButton/CheckButton.module.scss new file mode 100644 index 00000000..dd1a491b --- /dev/null +++ b/src/components/_shared/CheckButton/CheckButton.module.scss @@ -0,0 +1,30 @@ +.CheckButton { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + min-width: 33px; + box-sizing: border-box; + padding: 0 8px; + background: var(--background-color); + color: var(--default-color); + border: 2px solid var(--default-color); + border-radius: 8px; + overflow: hidden; + align-self: center; + + .close { + display: none; + } + + &:hover { + background: var(--background-color-invert); + color: var(--default-color-invert); + .check { + display: none; + } + .close { + display: block; + } + } +} diff --git a/src/components/_shared/CheckButton/CheckButton.tsx b/src/components/_shared/CheckButton/CheckButton.tsx new file mode 100644 index 00000000..a8dcd401 --- /dev/null +++ b/src/components/_shared/CheckButton/CheckButton.tsx @@ -0,0 +1,38 @@ +import { clsx } from 'clsx' +import styles from './CheckButton.module.scss' +import { Icon } from '../Icon' +import { createSignal, Show } from 'solid-js' + +type Props = { + class?: string + checked: boolean + text: string + onClick: () => void +} + +// Signed - check mark icon +// On hover - cross icon +// If you clicked on the cross, you unsubscribed. Then the “Subscribe” button appears + +export const CheckButton = (props: Props) => { + const [clicked, setClicked] = createSignal(!props.checked) + const handleClick = () => { + props.onClick() + setClicked((prev) => !prev) + } + return ( + + ) +} diff --git a/src/components/_shared/CheckButton/index.ts b/src/components/_shared/CheckButton/index.ts new file mode 100644 index 00000000..2233633d --- /dev/null +++ b/src/components/_shared/CheckButton/index.ts @@ -0,0 +1 @@ +export { CheckButton } from './CheckButton' diff --git a/src/components/_shared/SearchField.module.scss b/src/components/_shared/SearchField/SearchField.module.scss similarity index 61% rename from src/components/_shared/SearchField.module.scss rename to src/components/_shared/SearchField/SearchField.module.scss index 87fc6c72..03811331 100644 --- a/src/components/_shared/SearchField.module.scss +++ b/src/components/_shared/SearchField/SearchField.module.scss @@ -1,6 +1,23 @@ .searchField { display: flex; justify-content: flex-end; + position: relative; + + &.bordered { + border: 2px solid var(--black-100); + padding: 10px 0 12px 10px; + + input { + width: 100%; + display: block; + box-sizing: border-box; + margin-right: 40px; + + &:focus { + box-shadow: unset; + } + } + } input { border: none; diff --git a/src/components/_shared/SearchField.tsx b/src/components/_shared/SearchField/SearchField.tsx similarity index 61% rename from src/components/_shared/SearchField.tsx rename to src/components/_shared/SearchField/SearchField.tsx index 8b1d91fe..5401b04c 100644 --- a/src/components/_shared/SearchField.tsx +++ b/src/components/_shared/SearchField/SearchField.tsx @@ -1,19 +1,20 @@ import styles from './SearchField.module.scss' -import { Icon } from './Icon' +import { Icon } from '../Icon' import { clsx } from 'clsx' -import { useLocalize } from '../../context/localize' +import { useLocalize } from '../../../context/localize' -type SearchFieldProps = { +type Props = { onChange: (value: string) => void class?: string + variant?: 'bordered' } -export const SearchField = (props: SearchFieldProps) => { +export const SearchField = (props: Props) => { const handleInputChange = (event) => props.onChange(event.target.value.trim()) const { t } = useLocalize() return ( -
+
@@ -24,7 +25,7 @@ export const SearchField = (props: SearchFieldProps) => { onInput={handleInputChange} placeholder={t('Search')} /> - +
) } diff --git a/src/components/_shared/SearchField/index.ts b/src/components/_shared/SearchField/index.ts new file mode 100644 index 00000000..5ef4d0da --- /dev/null +++ b/src/components/_shared/SearchField/index.ts @@ -0,0 +1 @@ +export { SearchField } from './SearchField' diff --git a/src/context/profile.tsx b/src/context/profile.tsx index 01ec6e70..0038b518 100644 --- a/src/context/profile.tsx +++ b/src/context/profile.tsx @@ -5,6 +5,12 @@ import { loadAuthor, useAuthorsStore } from '../stores/zine/authors' import { apiClient } from '../utils/apiClient' import type { ProfileInput } from '../graphql/types.gen' +const userpicUrl = (userpic: string) => { + if (userpic.includes('assets.discours.io')) { + return userpic.replace('100x', '500x500') + } + return userpic +} const useProfileForm = () => { const { session } = useSession() const currentSlug = createMemo(() => session()?.user?.slug) @@ -34,13 +40,12 @@ const useProfileForm = () => { if (!currentSlug()) return try { await loadAuthor({ slug: currentSlug() }) - setForm({ name: currentAuthor()?.name, slug: currentAuthor()?.slug, bio: currentAuthor()?.bio, about: currentAuthor()?.about, - userpic: currentAuthor()?.userpic.replace('100x', '500x500'), + userpic: userpicUrl(currentAuthor()?.userpic), links: currentAuthor()?.links }) } catch (error) { diff --git a/src/pages/profile/Settings.module.scss b/src/pages/profile/Settings.module.scss index 5282c107..a6ce9407 100644 --- a/src/pages/profile/Settings.module.scss +++ b/src/pages/profile/Settings.module.scss @@ -108,12 +108,12 @@ h5 { } .searchField { - display: block; + margin-bottom: 2rem; label:first-child { opacity: 0.5; position: absolute; - right: 1em; + right: 12px; transform: translateY(-50%); top: 50%; } diff --git a/src/pages/profile/profileSecurity.page.tsx b/src/pages/profile/profileSecurity.page.tsx index a32e13ce..2c95bbfb 100644 --- a/src/pages/profile/profileSecurity.page.tsx +++ b/src/pages/profile/profileSecurity.page.tsx @@ -2,7 +2,7 @@ import { PageLayout } from '../../components/_shared/PageLayout' import styles from './Settings.module.scss' import { Icon } from '../../components/_shared/Icon' import { clsx } from 'clsx' -import ProfileSettingsNavigation from '../../components/Discours/ProfileSettingsNavigation' +import { ProfileSettingsNavigation } from '../../components/Nav/ProfileSettingsNavigation' export const ProfileSecurityPage = () => { return ( diff --git a/src/pages/profile/profileSettings.page.tsx b/src/pages/profile/profileSettings.page.tsx index e9c67d1d..da392bda 100644 --- a/src/pages/profile/profileSettings.page.tsx +++ b/src/pages/profile/profileSettings.page.tsx @@ -1,6 +1,6 @@ import { PageLayout } from '../../components/_shared/PageLayout' import { Icon } from '../../components/_shared/Icon' -import ProfileSettingsNavigation from '../../components/Discours/ProfileSettingsNavigation' +import { ProfileSettingsNavigation } from '../../components/Nav/ProfileSettingsNavigation' import { For, createSignal, Show, onMount, onCleanup, createEffect } from 'solid-js' import deepEqual from 'fast-deep-equal' import { clsx } from 'clsx' diff --git a/src/pages/profile/profileSubscriptions.page.tsx b/src/pages/profile/profileSubscriptions.page.tsx index 2b21924c..8500788f 100644 --- a/src/pages/profile/profileSubscriptions.page.tsx +++ b/src/pages/profile/profileSubscriptions.page.tsx @@ -2,10 +2,61 @@ import { PageLayout } from '../../components/_shared/PageLayout' import styles from './Settings.module.scss' import stylesSettings from '../../styles/FeedSettings.module.scss' import { clsx } from 'clsx' -import ProfileSettingsNavigation from '../../components/Discours/ProfileSettingsNavigation' +import { ProfileSettingsNavigation } from '../../components/Nav/ProfileSettingsNavigation' import { SearchField } from '../../components/_shared/SearchField' +import { createEffect, createSignal, For, onMount, Show } from 'solid-js' +import { Author, Topic } from '../../graphql/types.gen' +import { apiClient } from '../../utils/apiClient' +import { useSession } from '../../context/session' +import { isAuthor } from '../../utils/isAuthor' +import { useLocalize } from '../../context/localize' +import { SubscriptionFilter } from '../types' +import { Loading } from '../../components/_shared/Loading' +import { TopicCard } from '../../components/Topic/Card' +import { AuthorCard } from '../../components/Author/AuthorCard' +import { dummyFilter } from '../../utils/dummyFilter' export const ProfileSubscriptionsPage = () => { + const { t, lang } = useLocalize() + const { user, isAuthenticated } = useSession() + const [following, setFollowing] = createSignal>([]) + const [filtered, setFiltered] = createSignal>([]) + const [subscriptionFilter, setSubscriptionFilter] = createSignal('all') + const [searchQuery, setSearchQuery] = createSignal('') + + const fetchSubscriptions = async () => { + try { + const [getAuthors, getTopics] = await Promise.all([ + apiClient.getAuthorFollowingUsers({ slug: user().slug }), + apiClient.getAuthorFollowingTopics({ slug: user().slug }) + ]) + setFollowing([...getAuthors, ...getTopics]) + setFiltered([...getAuthors, ...getTopics]) + } catch (error) { + console.error('[fetchSubscriptions] :', error) + throw error + } + } + + onMount(async () => { + if (isAuthenticated()) { + await fetchSubscriptions() + } + }) + + createEffect(() => { + if (following()) { + if (subscriptionFilter() === 'users') { + setFiltered(following().filter((s) => 'name' in s)) + } else if (subscriptionFilter() === 'topics') { + setFiltered(following().filter((s) => 'title' in s)) + } else { + setFiltered(following()) + } + } + setFiltered(dummyFilter(following(), searchQuery(), lang())) + }) + return (
@@ -19,112 +70,65 @@ export const ProfileSubscriptionsPage = () => {
-

Подписки

-

Здесь можно управлять всеми своими подписками на сайте.

- -
+

{t('My subscriptions')}

+

{t('Here you can manage all your Discourse subscriptions')}

+ }>
- console.log('nothing')} class={styles.searchField} /> + setSearchQuery(value)} + class={styles.searchField} + variant="bordered" + />
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
+ + {(followingItem) => ( +
+ {isAuthor(followingItem) ? ( + + ) : ( + + )} +
+ )} +
- -
-

- -

- +
diff --git a/src/pages/types.ts b/src/pages/types.ts index b8aac518..c66bc7f3 100644 --- a/src/pages/types.ts +++ b/src/pages/types.ts @@ -46,3 +46,5 @@ export type UploadedFile = { url: string originalFilename?: string } + +export type SubscriptionFilter = 'all' | 'users' | 'topics' diff --git a/src/styles/FeedSettings.module.scss b/src/styles/FeedSettings.module.scss index 49a1ae37..bff45ed7 100644 --- a/src/styles/FeedSettings.module.scss +++ b/src/styles/FeedSettings.module.scss @@ -1,5 +1,6 @@ .settingsList { display: table; + width: 100%; h2 { margin-top: 1em; @@ -45,16 +46,3 @@ } } } - -.settingsListRow { - display: table-row; -} - -.settingsListCell { - display: table-cell; - padding: 0 0.5em 1em 0; - - &:first-child { - padding-right: 2em; - } -} diff --git a/src/utils/dummyFilter.ts b/src/utils/dummyFilter.ts new file mode 100644 index 00000000..fe4d8f4b --- /dev/null +++ b/src/utils/dummyFilter.ts @@ -0,0 +1,36 @@ +import { translit } from './ru2en' +import { Author, Topic } from '../graphql/types.gen' + +type SearchData = Array + +const prepareQuery = (searchQuery, lang) => { + const q = searchQuery.toLowerCase() + if (q.length === 0) return '' + return lang === 'ru' ? translit(q) : q +} + +const stringMatches = (str, q, lang) => { + const preparedStr = lang === 'ru' ? translit(str.toLowerCase()) : str.toLowerCase() + return preparedStr.split(' ').some((word) => word.startsWith(q)) +} + +export const dummyFilter = (data: SearchData, searchQuery: string, lang: 'ru' | 'en'): SearchData => { + const q = prepareQuery(searchQuery, lang) + if (q.length === 0) return data + + return data.filter((item) => { + const slugMatches = item.slug && item.slug.split('-').some((w) => w.startsWith(q)) + if (slugMatches) return true + + if ('title' in item) { + return stringMatches(item.title, q, lang) + } + + if ('name' in item) { + return stringMatches(item.name, q, lang) || (item.bio && stringMatches(item.bio, q, lang)) + } + // If it does not match any of the 'slug', 'title', 'name' , 'bio' fields + // current element should not be included in the filtered array + return false + }) +} diff --git a/src/utils/isAuthor.ts b/src/utils/isAuthor.ts new file mode 100644 index 00000000..3d939b64 --- /dev/null +++ b/src/utils/isAuthor.ts @@ -0,0 +1,5 @@ +import { Author, Topic } from '../graphql/types.gen' + +export const isAuthor = (value: Author | Topic): value is Author => { + return 'name' in value +}