diff --git a/.drone.yml b/.drone.yml index f18f21e2..0d9eefa7 100644 --- a/.drone.yml +++ b/.drone.yml @@ -16,16 +16,18 @@ steps: NEXT_PUBLIC_API_HOST: https://pig.vault48.org/ NEXT_PUBLIC_REMOTE_CURRENT: https://pig.vault48.org/static/ NEXT_PUBLIC_PUBLIC_HOST: https://vault48.org/ + NEXT_PUBLIC_BOT_USERNAME: vault48bot settings: dockerfile: docker/nextjs/Dockerfile build_args_from_env: - NEXT_PUBLIC_API_HOST - NEXT_PUBLIC_REMOTE_CURRENT - NEXT_PUBLIC_PUBLIC_HOST + - NEXT_PUBLIC_BOT_USERNAME tag: - ${DRONE_BRANCH} custom_labels: - - "commit=${DRONE_COMMIT_SHA}" + - 'commit=${DRONE_COMMIT_SHA}' username: from_secret: global_docker_login password: @@ -43,16 +45,18 @@ steps: NEXT_PUBLIC_API_HOST: https://pig.staging.vault48.org/ NEXT_PUBLIC_REMOTE_CURRENT: https://pig.staging.vault48.org/static/ NEXT_PUBLIC_PUBLIC_HOST: https://staging.vault48.org/ + NEXT_PUBLIC_BOT_USERNAME: vault48bot settings: dockerfile: docker/nextjs/Dockerfile build_args_from_env: - NEXT_PUBLIC_API_HOST - NEXT_PUBLIC_REMOTE_CURRENT - NEXT_PUBLIC_PUBLIC_HOST + - NEXT_PUBLIC_BOT_USERNAME tag: - ${DRONE_BRANCH} custom_labels: - - "commit=${DRONE_COMMIT_SHA}" + - 'commit=${DRONE_COMMIT_SHA}' username: from_secret: global_docker_login password: diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..d2ae35e8 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn lint-staged diff --git a/docker/nextjs/Dockerfile b/docker/nextjs/Dockerfile index 01892cd6..3a010f69 100644 --- a/docker/nextjs/Dockerfile +++ b/docker/nextjs/Dockerfile @@ -7,10 +7,12 @@ COPY . . ARG NEXT_PUBLIC_API_HOST ARG NEXT_PUBLIC_REMOTE_CURRENT ARG NEXT_PUBLIC_PUBLIC_HOST +ARG NEXT_PUBLIC_BOT_USERNAME ENV NEXT_PUBLIC_API_HOST $NEXT_PUBLIC_API_HOST ENV NEXT_PUBLIC_REMOTE_CURRENT $NEXT_PUBLIC_REMOTE_CURRENT ENV NEXT_PUBLIC_PUBLIC_HOST $NEXT_PUBLIC_PUBLIC_HOST +ENV NEXT_PUBLIC_BOT_USERNAME $NEXT_PUBLIC_BOT_USERNAME RUN yarn next:build diff --git a/next.config.js b/next.config.js index 306a0017..b3b05794 100644 --- a/next.config.js +++ b/next.config.js @@ -2,7 +2,7 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); -const withTM = require('next-transpile-modules')(['ramda']); +const withTM = require('next-transpile-modules')(['ramda', '@v9v/ts-react-telegram-login']); module.exports = withBundleAnalyzer( withTM({ @@ -22,5 +22,19 @@ module.exports = withBundleAnalyzer( /** don't try to optimize fonts */ optimizeFonts: false, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '*.vault48.org', + pathname: '/**', + }, + { + protocol: 'https', + hostname: '*.ytimg.com', + pathname: '/**', + }, + ], + }, }) ); diff --git a/package.json b/package.json index e79a5ab7..848d90c6 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "@tippyjs/react": "^4.2.6", + "@v9v/ts-react-telegram-login": "^1.1.1", "autosize": "^4.0.2", "axios": "^0.21.2", "body-scroll-lock": "^2.6.4", @@ -39,7 +40,7 @@ "react-router-dom": "^5.1.2", "react-sticky-box": "^1.0.2", "sass": "^1.49.0", - "swiper": "^8.0.7", + "swiper": "^8.4.4", "swr": "^1.0.1", "throttle-debounce": "^2.1.0", "typescript": "^4.0.5", @@ -98,7 +99,10 @@ }, "lint-staged": { "./**/*.{js,jsx,ts,tsx}": [ - "next lint --fix" + "eslint --fix" ] + }, + "husky": { + "pre-push": "lint-staged" } } diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts index 06a46c75..0c1526c6 100644 --- a/src/api/auth/index.ts +++ b/src/api/auth/index.ts @@ -1,3 +1,5 @@ +import { TelegramUser } from '@v9v/ts-react-telegram-login'; + import { ApiAttachSocialRequest, ApiAttachSocialResult, @@ -98,3 +100,6 @@ export const apiLoginWithSocial = ({ password, }) .then(cleanResult); + +export const apiAttachTelegram = (data: TelegramUser) => + api.post(API.USER.ATTACH_TELEGRAM, data); diff --git a/src/api/notifications/settings.ts b/src/api/notifications/settings.ts new file mode 100644 index 00000000..232bbb94 --- /dev/null +++ b/src/api/notifications/settings.ts @@ -0,0 +1,35 @@ +import { API } from '~/constants/api'; +import { NotificationSettings } from '~/types/notifications'; +import { api, cleanResult } from '~/utils/api'; +import { + notificationSettingsFromRequest, + notificationSettingsToRequest, +} from '~/utils/notifications/notificationSettingsFromRequest'; + +import { + ApiGetNotificationSettingsResponse, + ApiGetNotificationsResponse, + ApiUpdateNotificationSettingsResponse, +} from './types'; + +export const apiGetNotificationSettings = (): Promise => + api + .get(API.NOTIFICATIONS.SETTINGS) + .then(cleanResult) + .then(notificationSettingsFromRequest); + +export const apiGetNotifications = () => + api + .get(API.NOTIFICATIONS.LIST) + .then(cleanResult); + +export const apiUpdateNotificationSettings = ( + settings: Partial, +) => + api + .post( + API.NOTIFICATIONS.SETTINGS, + notificationSettingsToRequest(settings), + ) + .then(cleanResult) + .then(notificationSettingsFromRequest); diff --git a/src/api/notifications/types.ts b/src/api/notifications/types.ts new file mode 100644 index 00000000..c17c575d --- /dev/null +++ b/src/api/notifications/types.ts @@ -0,0 +1,19 @@ +import { NotificationItem } from '~/types/notifications'; + +export interface ApiGetNotificationSettingsResponse { + enabled: boolean; + flow: boolean; + comments: boolean; + send_telegram: boolean; + show_indicator: boolean; + last_seen?: string | null; + last_date?: string | null; +} +export type ApiUpdateNotificationSettingsResponse = + ApiGetNotificationSettingsResponse; + +export type ApiUpdateNotificationSettingsRequest = + Partial; +export interface ApiGetNotificationsResponse { + items?: NotificationItem[]; +} diff --git a/src/components/auth/oauth/TelegramLoginForm/index.tsx b/src/components/auth/oauth/TelegramLoginForm/index.tsx new file mode 100644 index 00000000..0a4911af --- /dev/null +++ b/src/components/auth/oauth/TelegramLoginForm/index.tsx @@ -0,0 +1,46 @@ +import React, { FC } from 'react'; + +import TelegramLoginButton, { + TelegramUser, +} from '@v9v/ts-react-telegram-login'; + +import { LoaderCircle } from '~/components/input/LoaderCircle'; + +import styles from './styles.module.scss'; + +interface TelegramLoginFormProps { + botName: string; + loading?: boolean; + onSuccess?: (token: TelegramUser) => void; +} + +const TelegramLoginForm: FC = ({ + botName, + loading, + onSuccess, +}) => { + return ( +
+
+ {loading ? ( + + ) : ( +
+ После успешной авторизации аккаунт появится в настройках вашего + профиля +
+ )} +
+ +
+ +
+
+ ); +}; + +export { TelegramLoginForm }; diff --git a/src/components/auth/oauth/TelegramLoginForm/styles.module.scss b/src/components/auth/oauth/TelegramLoginForm/styles.module.scss new file mode 100644 index 00000000..12f263e5 --- /dev/null +++ b/src/components/auth/oauth/TelegramLoginForm/styles.module.scss @@ -0,0 +1,22 @@ +@import 'src/styles/variables'; + +.button { + flex: 0 0 48px; +} + +.container { + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: $gap; +} + +.text { + text-align: center; + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/components/comment/CommentContent/index.tsx b/src/components/comment/CommentContent/index.tsx index 51c9f96e..d36cde9d 100644 --- a/src/components/comment/CommentContent/index.tsx +++ b/src/components/comment/CommentContent/index.tsx @@ -1,18 +1,28 @@ -import React, { createElement, FC, Fragment, memo, ReactNode, useCallback, useMemo, useState } from 'react'; +import React, { + createElement, + FC, + Fragment, + memo, + ReactNode, + useCallback, + useMemo, + useState, +} from 'react'; import classnames from 'classnames'; -import classNames from 'classnames'; import { CommentForm } from '~/components/comment/CommentForm'; +import { Authorized } from '~/components/containers/Authorized'; import { Group } from '~/components/containers/Group'; import { AudioPlayer } from '~/components/media/AudioPlayer'; import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment'; import { UploadType } from '~/constants/uploads'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { IComment, IFile } from '~/types'; import { formatCommentText, getPrettyDate, getURL } from '~/utils/dom'; import { append, assocPath, path, reduce } from '~/utils/ramda'; +import { CommentImageGrid } from '../CommentImageGrid'; import { CommentMenu } from '../CommentMenu'; import styles from './styles.module.scss'; @@ -28,7 +38,15 @@ interface IProps { } const CommentContent: FC = memo( - ({ comment, canEdit, nodeId, saveComment, onDelete, onShowImageModal, prefix }) => { + ({ + comment, + canEdit, + nodeId, + saveComment, + onDelete, + onShowImageModal, + prefix, + }) => { const [isEditing, setIsEditing] = useState(false); const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]); @@ -38,20 +56,36 @@ const CommentContent: FC = memo( () => reduce( (group, file) => - file.type ? assocPath([file.type], append(file, group[file.type]), group) : group, + file.type + ? assocPath([file.type], append(file, group[file.type]), group) + : group, {} as Record, - comment.files + comment.files, ), - [comment] + [comment], ); const onLockClick = useCallback(() => { onDelete(comment.id, !comment.deleted_at); }, [comment, onDelete]); + const onImageClick = useCallback( + (file: IFile) => + onShowImageModal(groupped.image, groupped.image.indexOf(file)), + [onShowImageModal, groupped], + ); + const menu = useMemo( - () => canEdit && , - [canEdit, startEditing, onLockClick] + () => ( +
+ {canEdit && ( + + + + )} +
+ ), + [canEdit, startEditing, onLockClick], ); const blocks = useMemo( @@ -59,7 +93,7 @@ const CommentContent: FC = memo( !!comment.text.trim() ? formatCommentText(path(['user', 'username'], comment), comment.text) : [], - [comment] + [comment], ); if (isEditing) { @@ -76,6 +110,7 @@ const CommentContent: FC = memo( return (
{!!prefix &&
{prefix}
} + {comment.text.trim() && ( {menu} @@ -84,11 +119,16 @@ const CommentContent: FC = memo( {blocks.map( (block, key) => COMMENT_BLOCK_RENDERERS[block.type] && - createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key }) + createElement(COMMENT_BLOCK_RENDERERS[block.type], { + block, + key, + }), )} -
{getPrettyDate(comment.created_at)}
+
+ {getPrettyDate(comment.created_at)} +
)} @@ -96,38 +136,35 @@ const CommentContent: FC = memo(
{menu} -
1, - })} - > - {groupped.image.map((file, index) => ( -
onShowImageModal(groupped.image, index)}> - {file.name} -
- ))} -
+ -
{getPrettyDate(comment.created_at)}
+
+ {getPrettyDate(comment.created_at)} +
)} {groupped.audio && groupped.audio.length > 0 && ( - {groupped.audio.map(file => ( -
+ {groupped.audio.map((file) => ( +
{menu} -
{getPrettyDate(comment.created_at)}
+
+ {getPrettyDate(comment.created_at)} +
))} )}
); - } + }, ); export { CommentContent }; diff --git a/src/components/comment/CommentContent/styles.module.scss b/src/components/comment/CommentContent/styles.module.scss index 14954339..7b6fd4c2 100644 --- a/src/components/comment/CommentContent/styles.module.scss +++ b/src/components/comment/CommentContent/styles.module.scss @@ -117,35 +117,6 @@ touch-action: none; } -.images { - cursor: pointer; - - img { - max-height: 400px; - border-radius: $radius; - max-width: 100%; - } - - &.multiple { - img { - max-height: none; - } - - // Desktop devices - @include flexbin(25vh, $flexbin-space); - - // Tablet devices - @media (max-width: $flexbin-tablet-max) { - @include flexbin($flexbin-row-height-tablet, $flexbin-space-tablet); - } - - // Phone devices - @media (max-width: $flexbin-phone-max) { - @include flexbin($flexbin-row-height-phone, $flexbin-space-phone); - } - } -} - .audios { & > div { height: $comment_height; diff --git a/src/components/comment/CommentImageGrid/index.tsx b/src/components/comment/CommentImageGrid/index.tsx new file mode 100644 index 00000000..179f8ca8 --- /dev/null +++ b/src/components/comment/CommentImageGrid/index.tsx @@ -0,0 +1,49 @@ +import { FC } from 'react'; + +import classNames from 'classnames'; + +import { Hoverable } from '~/components/common/Hoverable'; +import { Icon } from '~/components/input/Icon'; +import { imagePresets } from '~/constants/urls'; +import { IFile } from '~/types'; +import { getURL } from '~/utils/dom'; +import { getFileSrcSet } from '~/utils/srcset'; + +import styles from './styles.module.scss'; + +interface CommentImageGridProps { + files: IFile[]; + onClick: (file: IFile) => void; +} + +const singleSrcSet = '(max-width: 1024px) 40vw, 20vw'; +const multipleSrcSet = '(max-width: 1024px) 50vw, 20vw'; + +const CommentImageGrid: FC = ({ files, onClick }) => { + return ( +
1, + })} + > + {files.map((file) => ( + onClick(file)} + className={styles.item} + icon={} + > + {file.name} 1 ? singleSrcSet : multipleSrcSet} + /> + + ))} +
+ ); +}; + +export { CommentImageGrid }; diff --git a/src/components/comment/CommentImageGrid/styles.module.scss b/src/components/comment/CommentImageGrid/styles.module.scss new file mode 100644 index 00000000..cfdc9d71 --- /dev/null +++ b/src/components/comment/CommentImageGrid/styles.module.scss @@ -0,0 +1,37 @@ +@import 'src/styles/variables'; +@import '~flexbin/flexbin'; + +.images { + cursor: pointer; + overflow: visible !important; + + &.multiple { + // Desktop devices + @include flexbin(25vh, $flexbin-space); + + // Tablet devices + @media (max-width: $flexbin-tablet-max) { + @include flexbin($flexbin-row-height-tablet, $flexbin-space-tablet); + } + + // Phone devices + @media (max-width: $flexbin-phone-max) { + @include flexbin($flexbin-row-height-phone, $flexbin-space-phone); + } + } +} + +.image { + max-height: 400px; + border-radius: $radius; + max-width: 100%; + + .multiple & { + max-height: 250px; + max-inline-size: 250px; + } +} + +.item { + border-radius: $radius; +} diff --git a/src/components/common/Avatar/index.tsx b/src/components/common/Avatar/index.tsx index 19d5a3f7..442a7dff 100644 --- a/src/components/common/Avatar/index.tsx +++ b/src/components/common/Avatar/index.tsx @@ -3,7 +3,7 @@ import React, { forwardRef } from 'react'; import classNames from 'classnames'; import { Square } from '~/components/common/Square'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { useColorGradientFromString } from '~/hooks/color/useColorGradientFromString'; import { getURLFromString } from '~/utils/dom'; import { DivProps } from '~/utils/types'; @@ -14,22 +14,37 @@ interface Props extends DivProps { url?: string; username?: string; size?: number; - preset?: typeof ImagePresets[keyof typeof ImagePresets]; + hasUpdates?: boolean; + preset?: typeof imagePresets[keyof typeof imagePresets]; } const Avatar = forwardRef( ( - { url, username, size, className, preset = ImagePresets.avatar, ...rest }, + { + url, + username, + size, + className, + preset = imagePresets.avatar, + hasUpdates, + ...rest + }, ref, ) => { return ( - + className={classNames(styles.container, { + [styles.has_dot]: hasUpdates, + })} + > + +
); }, ); diff --git a/src/components/common/Avatar/styles.module.scss b/src/components/common/Avatar/styles.module.scss index 4225c783..389a9772 100644 --- a/src/components/common/Avatar/styles.module.scss +++ b/src/components/common/Avatar/styles.module.scss @@ -1,5 +1,20 @@ @import 'src/styles/variables'; +.container { + &.has_dot::after { + content: ' '; + position: absolute; + bottom: 0; + right: 0; + width: 8px; + height: 8px; + border-radius: 8px; + background-color: $color_danger; + z-index: 1; + box-shadow: $content_bg 0 0 0 2px; + } +} + .avatar { @include outer_shadow; @@ -12,6 +27,7 @@ background-position: center; background-size: cover; cursor: pointer; + position: relative; img { object-fit: cover; diff --git a/src/components/common/Hoverable/index.tsx b/src/components/common/Hoverable/index.tsx new file mode 100644 index 00000000..c64c5f3e --- /dev/null +++ b/src/components/common/Hoverable/index.tsx @@ -0,0 +1,34 @@ +import React, { FC, ReactNode } from 'react'; + +import classNames from 'classnames'; + +import { DivProps } from '~/utils/types'; + +import styles from './styles.module.scss'; + +type HoverableEffect = 'rise' | 'shine'; + +interface HoverableProps extends DivProps { + icon?: ReactNode; + effect?: HoverableEffect; +} + +const Hoverable: FC = ({ + children, + className, + icon, + effect = 'rise', + ...rest +}) => ( +
+ {icon &&
{icon}
} + {children} +
+); + +export { Hoverable }; diff --git a/src/components/common/Hoverable/styles.module.scss b/src/components/common/Hoverable/styles.module.scss new file mode 100644 index 00000000..bb4d7850 --- /dev/null +++ b/src/components/common/Hoverable/styles.module.scss @@ -0,0 +1,71 @@ +@import 'src/styles/variables'; + +.hoverable { + position: relative; + cursor: pointer; + + &::after { + content: ' '; + position: absolute; + inset: 0; + border-radius: $radius; + opacity: 0; + transition: all 100ms; + touch-action: none; + pointer-events: none; + } + + &.with_icon::after { + background: linear-gradient(325deg, $color_primary 20px, transparent 100px); + } +} + +.hoverable.rise { + @media (hover: hover) { + &:hover { + z-index: 10; + transition: all 100ms; + transform: scale(1.025) translateY(-2%); + box-shadow: rgba(0, 0, 0, 0.5) 0 10px 10px 5px; + + &::after { + opacity: 1; + } + } + } +} + +.hoverable.shine { + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 150deg, + #{transparentize(yellow, 0.75)}, + transparent + ); + z-index: 1; + border-radius: $radius; + opacity: 0; + pointer-events: none; + touch-action: none; + transition: all 250ms; + } + + &:hover::before { + opacity: 1; + } +} + +.icon { + position: absolute; + bottom: 4px; + right: 4px; + z-index: 2; + opacity: 0; + + .hoverable:hover & { + opacity: 1; + } +} diff --git a/src/components/common/ImageLoadingWrapper/index.tsx b/src/components/common/ImageLoadingWrapper/index.tsx new file mode 100644 index 00000000..3941a41b --- /dev/null +++ b/src/components/common/ImageLoadingWrapper/index.tsx @@ -0,0 +1,52 @@ +import React, { + CSSProperties, + FC, + useCallback, + useMemo, + useReducer, + useState, +} from 'react'; + +import classNames from 'classnames'; + +import { LoaderCircle } from '~/components/input/LoaderCircle'; +import { DivProps } from '~/utils/types'; + +import styles from './styles.module.scss'; + +interface ImageLoadingWrapperProps extends Omit { + children: (props: { loading: boolean; onLoad: () => void }) => void; + preview?: string; +} + +const ImageLoadingWrapper: FC = ({ + className, + children, + preview, + color, + ...props +}) => { + const [loading, onLoad] = useReducer((v) => false, true); + + const style = useMemo( + () => ({ + backgroundImage: `url('${preview}')`, + backgroundColor: color || 'var(--color-primary)', + }), + [preview, color], + ); + + return ( +
+ {!!loading && !!preview && ( +
+
+ +
+ )} + {children({ loading, onLoad })} +
+ ); +}; + +export { ImageLoadingWrapper }; diff --git a/src/components/common/ImageLoadingWrapper/styles.module.scss b/src/components/common/ImageLoadingWrapper/styles.module.scss new file mode 100644 index 00000000..7b2ced09 --- /dev/null +++ b/src/components/common/ImageLoadingWrapper/styles.module.scss @@ -0,0 +1,26 @@ +@import 'src/styles/variables'; + +.wrapper { + position: relative; +} + +.preview { + position: absolute; + inset: 0; + pointer-events: none; + touch-action: none; + display: flex; + align-items: flex-end; + justify-content: flex-end; + padding: $gap; +} + +.thumbnail { + filter: blur(10px); + position: absolute; + inset: 0; + border-radius: $radius; + background-size: cover; + background-position: 50% 50%; + box-sizing: border-box; +} diff --git a/src/components/common/InlineUsername/index.tsx b/src/components/common/InlineUsername/index.tsx new file mode 100644 index 00000000..1bfb3124 --- /dev/null +++ b/src/components/common/InlineUsername/index.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react'; + +import { useColorFromString } from '~/hooks/color/useColorFromString'; + +import styles from './styles.module.scss'; + +interface InlineUsernameProps { + children: string; +} + +const InlineUsername: FC = ({ children }) => { + const backgroundColor = useColorFromString(children); + return ( + + ~{children} + + ); +}; + +export { InlineUsername }; diff --git a/src/components/common/InlineUsername/styles.module.scss b/src/components/common/InlineUsername/styles.module.scss new file mode 100644 index 00000000..552c701a --- /dev/null +++ b/src/components/common/InlineUsername/styles.module.scss @@ -0,0 +1,6 @@ +.username { + font-size: 0.9em; + padding: 0 2px; + text-transform: lowercase; + border-radius: 0.2em; +} diff --git a/src/components/common/LoadingProgress/styles.module.scss b/src/components/common/LoadingProgress/styles.module.scss index 61b5a8aa..fab3e022 100644 --- a/src/components/common/LoadingProgress/styles.module.scss +++ b/src/components/common/LoadingProgress/styles.module.scss @@ -29,7 +29,6 @@ top: $gap + 4px; left: 50%; font: $font_12_semibold; - background: red; z-index: 100; transform: translate(-50%, 0); padding: 2px 10px; diff --git a/src/components/containers/Authorized/index.tsx b/src/components/containers/Authorized/index.tsx index 739e1ec8..d38a9ea6 100644 --- a/src/components/containers/Authorized/index.tsx +++ b/src/components/containers/Authorized/index.tsx @@ -1,15 +1,20 @@ import React, { FC } from 'react'; +import { observer } from 'mobx-react-lite'; + import { useAuth } from '~/hooks/auth/useAuth'; -interface IProps {} +interface IProps { + // don't wait for user refetch, trust hydration + hydratedOnly?: boolean; +} -const Authorized: FC = ({ children }) => { - const { isUser } = useAuth(); +const Authorized: FC = observer(({ children, hydratedOnly }) => { + const { isUser, fetched } = useAuth(); - if (!isUser) return null; + if (!isUser || (!hydratedOnly && !fetched)) return null; return <>{children}; -}; +}); export { Authorized }; diff --git a/src/components/containers/Columns/index.tsx b/src/components/containers/Columns/index.tsx index 094beb83..b3a3759d 100644 --- a/src/components/containers/Columns/index.tsx +++ b/src/components/containers/Columns/index.tsx @@ -1,7 +1,9 @@ -import React, { FC } from 'react'; +import React, { FC, useEffect, useLayoutEffect, useRef, useState } from 'react'; import Masonry from 'react-masonry-css'; +import { useScrollEnd } from '~/hooks/dom/useScrollEnd'; + import styles from './styles.module.scss'; const defaultColumns = { @@ -11,12 +13,42 @@ const defaultColumns = { interface ColumnsProps { cols?: Record; + onScrollEnd?: () => void; + hasMore?: boolean; } -const Columns: FC = ({ children, cols = defaultColumns }) => ( - - {children} - -); +const Columns: FC = ({ + children, + cols = defaultColumns, + onScrollEnd, + hasMore, +}) => { + const ref = useRef(null); + const [columns, setColumns] = useState([]); + + useEffect(() => { + const childs = ref.current?.querySelectorAll(`.${styles.column}`); + + if (!childs) return; + + const timeout = setTimeout(() => setColumns([...childs]), 150); + + return () => clearTimeout(timeout); + }, [ref.current]); + + useScrollEnd(columns, onScrollEnd, { active: hasMore, threshold: 2 }); + + return ( +
+ + {children} + +
+ ); +}; export { Columns }; diff --git a/src/components/containers/Columns/styles.module.scss b/src/components/containers/Columns/styles.module.scss index 41c651d9..63663702 100644 --- a/src/components/containers/Columns/styles.module.scss +++ b/src/components/containers/Columns/styles.module.scss @@ -1,11 +1,12 @@ -@import "src/styles/variables"; -@import "src/styles/mixins"; +@import 'src/styles/variables'; +@import 'src/styles/mixins'; div.wrap { display: flex; width: 100%; margin-right: 0; padding: $gap $gap * 0.5; + align-items: flex-start; @include tablet { padding: 0 $gap * 0.5; diff --git a/src/components/containers/CoverBackdrop/index.tsx b/src/components/containers/CoverBackdrop/index.tsx index 33aaa823..f9bc38a6 100644 --- a/src/components/containers/CoverBackdrop/index.tsx +++ b/src/components/containers/CoverBackdrop/index.tsx @@ -2,7 +2,7 @@ import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { IUser } from '~/types/auth'; import { getURL } from '~/utils/dom'; @@ -19,14 +19,14 @@ const CoverBackdrop: FC = ({ cover }) => { const onLoad = useCallback(() => setIsLoaded(true), [setIsLoaded]); - const image = getURL(cover, ImagePresets.cover); + const image = getURL(cover, imagePresets.cover); useEffect(() => { if (!cover || !cover.url || !ref || !ref.current) return; ref.current.src = ''; setIsLoaded(false); - ref.current.src = getURL(cover, ImagePresets.cover); + ref.current.src = getURL(cover, imagePresets.cover); }, [cover]); if (!cover) return null; diff --git a/src/components/containers/PageCoverProvider/index.tsx b/src/components/containers/PageCoverProvider/index.tsx index 68c6c288..acdad00e 100644 --- a/src/components/containers/PageCoverProvider/index.tsx +++ b/src/components/containers/PageCoverProvider/index.tsx @@ -2,7 +2,7 @@ import React, { createContext, FC, useContext, useState } from 'react'; import { createPortal } from 'react-dom'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { IFile } from '~/types'; import { getURL } from '~/utils/dom'; @@ -27,9 +27,11 @@ const PageCoverProvider: FC = ({ children }) => { createPortal(
, - document.body + document.body, )} {children} diff --git a/src/components/editors/EditorUploadCoverButton/index.tsx b/src/components/editors/EditorUploadCoverButton/index.tsx index cb792f88..b6f5f579 100644 --- a/src/components/editors/EditorUploadCoverButton/index.tsx +++ b/src/components/editors/EditorUploadCoverButton/index.tsx @@ -2,7 +2,7 @@ import React, { ChangeEvent, FC, useCallback, useEffect } from 'react'; import { Icon } from '~/components/input/Icon'; import { UploadSubject, UploadTarget, UploadType } from '~/constants/uploads'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { useUploader } from '~/hooks/data/useUploader'; import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik'; import { IEditorComponentProps } from '~/types/node'; @@ -18,10 +18,12 @@ const EditorUploadCoverButton: FC = () => { const { uploadFile, files, pendingImages } = useUploader( UploadSubject.Editor, UploadTarget.Nodes, - values.cover ? [values.cover] : [] + values.cover ? [values.cover] : [], ); - const background = values.cover ? getURL(values.cover, ImagePresets['300']) : null; + const background = values.cover + ? getURL(values.cover, imagePresets['300']) + : null; const preview = pendingImages?.[0]?.thumbnail || ''; const onDropCover = useCallback(() => { @@ -31,13 +33,13 @@ const EditorUploadCoverButton: FC = () => { const onInputChange = useCallback( async (event: ChangeEvent) => { const files = Array.from(event.target.files || []) - .filter(file => getFileType(file) === UploadType.Image) + .filter((file) => getFileType(file) === UploadType.Image) .slice(0, 1); const result = await uploadFile(files[0]); setFieldValue('cover', result); }, - [uploadFile, setFieldValue] + [uploadFile, setFieldValue], ); useEffect(() => { diff --git a/src/components/flow/FlowCell/index.tsx b/src/components/flow/FlowCell/index.tsx index db3c3f9c..cf0308fe 100644 --- a/src/components/flow/FlowCell/index.tsx +++ b/src/components/flow/FlowCell/index.tsx @@ -119,7 +119,6 @@ const FlowCell: FC = ({ {image && ( diff --git a/src/components/flow/FlowCellImage/index.tsx b/src/components/flow/FlowCellImage/index.tsx index e05bfc48..58a8edfb 100644 --- a/src/components/flow/FlowCellImage/index.tsx +++ b/src/components/flow/FlowCellImage/index.tsx @@ -1,6 +1,7 @@ import React, { FC } from 'react'; import classNames from 'classnames'; +import Image from 'next/image'; import { IMGProps } from '~/utils/types'; @@ -10,9 +11,22 @@ interface Props extends IMGProps { height?: number; } -const FlowCellImage: FC = ({ className, children, ...rest }) => ( +const FlowCellImage: FC = ({ + className, + children, + src, + alt, + ...rest +}) => (
- + {alt} {children}
); diff --git a/src/components/flow/FlowCellImage/styles.module.scss b/src/components/flow/FlowCellImage/styles.module.scss index 442206b1..0bbdbccc 100644 --- a/src/components/flow/FlowCellImage/styles.module.scss +++ b/src/components/flow/FlowCellImage/styles.module.scss @@ -2,14 +2,4 @@ width: 100%; height: 100%; position: relative; - - img { - position: absolute; - top: 50%; - left: 50%; - width: 100%; - height: 100%; - transform: translate(-50%, -50%); - object-fit: cover; - } } diff --git a/src/components/flow/FlowGrid/index.tsx b/src/components/flow/FlowGrid/index.tsx index 3d451b54..5aac50b5 100644 --- a/src/components/flow/FlowGrid/index.tsx +++ b/src/components/flow/FlowGrid/index.tsx @@ -1,9 +1,11 @@ import React, { FC, Fragment } from 'react'; import classNames from 'classnames'; +import { observer } from 'mobx-react-lite'; import { FlowCell } from '~/components/flow/FlowCell'; import { flowDisplayToPreset, URLS } from '~/constants/urls'; +import { useAuth } from '~/hooks/auth/useAuth'; import { FlowDisplay, IFlowNode, INode } from '~/types'; import { IUser } from '~/types/auth'; import { getURLFromString } from '~/utils/dom'; @@ -17,28 +19,38 @@ interface Props { onChangeCellView: (id: INode['id'], flow: FlowDisplay) => void; } -export const FlowGrid: FC = ({ user, nodes, onChangeCellView }) => { - if (!nodes) { - return null; - } +export const FlowGrid: FC = observer( + ({ user, nodes, onChangeCellView }) => { + const { fetched, isUser } = useAuth(); - return ( - - {nodes.map(node => ( -
- -
- ))} -
- ); -}; + if (!nodes) { + return null; + } + + return ( + + {nodes.map((node) => ( +
+ +
+ ))} +
+ ); + }, +); diff --git a/src/components/flow/FlowRecentItem/index.tsx b/src/components/flow/FlowRecentItem/index.tsx index 9c01f980..02e18c8d 100644 --- a/src/components/flow/FlowRecentItem/index.tsx +++ b/src/components/flow/FlowRecentItem/index.tsx @@ -1,10 +1,9 @@ -import React, { FC, MouseEventHandler } from 'react'; +import { FC, MouseEventHandler } from 'react'; import classNames from 'classnames'; import { Anchor } from '~/components/common/Anchor'; -import { Icon } from '~/components/input/Icon'; -import { NodeRelatedItem } from '~/components/node/NodeRelatedItem'; +import { NodeThumbnail } from '~/components/node/NodeThumbnail'; import { URLS } from '~/constants/urls'; import { INode } from '~/types'; import { getPrettyDate } from '~/utils/dom'; @@ -31,7 +30,7 @@ const FlowRecentItem: FC = ({ node, has_new, onClick }) => { [styles.lab]: !node.is_promoted, })} > - +
diff --git a/src/components/flow/FlowSwiperHero/index.tsx b/src/components/flow/FlowSwiperHero/index.tsx index dfc67e6f..d6e62839 100644 --- a/src/components/flow/FlowSwiperHero/index.tsx +++ b/src/components/flow/FlowSwiperHero/index.tsx @@ -7,7 +7,7 @@ import SwiperClass from 'swiper/types/swiper-class'; import { Icon } from '~/components/input/Icon'; import { LoaderCircle } from '~/components/input/LoaderCircle'; -import { ImagePresets, URLS } from '~/constants/urls'; +import { imagePresets, URLS } from '~/constants/urls'; import { useWindowSize } from '~/hooks/dom/useWindowSize'; import { useNavigation } from '~/hooks/navigation/useNavigation'; import { IFlowNode } from '~/types'; @@ -29,8 +29,11 @@ const autoplay = { }; const lazy = { - loadPrevNextAmount: 3, - checkInView: false, + enabled: true, + loadPrevNextAmount: 2, + loadOnTransitionStart: true, + loadPrevNext: true, + checkInView: true, }; export const FlowSwiperHero: FC = ({ heroes }) => { @@ -42,7 +45,7 @@ export const FlowSwiperHero: FC = ({ heroes }) => { >(undefined); const [currentIndex, setCurrentIndex] = useState(heroes.length); const preset = useMemo( - () => (isTablet ? ImagePresets.cover : ImagePresets.small_hero), + () => (isTablet ? imagePresets.cover : imagePresets.small_hero), [isTablet], ); @@ -130,13 +133,14 @@ export const FlowSwiperHero: FC = ({ heroes }) => { onClick={onClick} followFinger shortSwipes={false} + watchSlidesProgress > {heroes - .filter(node => node.thumbnail) - .map(node => ( + .filter((node) => node.thumbnail) + .map((node) => ( diff --git a/src/components/input/ArcProgress/styles.module.scss b/src/components/input/ArcProgress/styles.module.scss index e5ba5472..e45a511e 100644 --- a/src/components/input/ArcProgress/styles.module.scss +++ b/src/components/input/ArcProgress/styles.module.scss @@ -3,8 +3,4 @@ .icon { fill: $color_danger; stroke: none; - - //path { - // transition: d 0.5s; - //} } diff --git a/src/components/input/InputRow/index.tsx b/src/components/input/InputRow/index.tsx new file mode 100644 index 00000000..9fb25231 --- /dev/null +++ b/src/components/input/InputRow/index.tsx @@ -0,0 +1,19 @@ +import React, { FC, ReactNode } from 'react'; + +import classNames from 'classnames'; + +import styles from './styles.module.scss'; + +interface InputRowProps { + className?: string; + input?: ReactNode; +} + +const InputRow: FC = ({ children, input, className }) => ( +
+
{children}
+ {!!input &&
{input}
} +
+); + +export { InputRow }; diff --git a/src/components/input/InputRow/styles.module.scss b/src/components/input/InputRow/styles.module.scss new file mode 100644 index 00000000..8f341d7f --- /dev/null +++ b/src/components/input/InputRow/styles.module.scss @@ -0,0 +1,9 @@ +@import 'src/styles/variables'; + +.row { + display: grid; + grid-template-columns: 1fr auto; + row-gap: $gap; + align-items: center; + font: $font_14_medium; +} diff --git a/src/components/input/LoaderScreen/index.tsx b/src/components/input/LoaderScreen/index.tsx new file mode 100644 index 00000000..0cc3c1da --- /dev/null +++ b/src/components/input/LoaderScreen/index.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react'; + +import classNames from 'classnames'; + +import { LoaderCircle } from '../LoaderCircle'; + +import styles from './styles.module.scss'; + +interface LoaderScreenProps { + className?: string; + align?: 'top' | 'middle'; +} + +const LoaderScreen: FC = ({ + className, + align = 'middle', +}) => ( +
+ +
+); + +export { LoaderScreen }; diff --git a/src/components/input/LoaderScreen/styles.module.scss b/src/components/input/LoaderScreen/styles.module.scss new file mode 100644 index 00000000..09e8cce1 --- /dev/null +++ b/src/components/input/LoaderScreen/styles.module.scss @@ -0,0 +1,14 @@ +@import 'src/styles/variables'; + +.screen { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: $gap; + + &.align-top { + align-items: flex-start; + } +} diff --git a/src/components/input/Toggle/styles.module.scss b/src/components/input/Toggle/styles.module.scss index 52db0036..253933a6 100644 --- a/src/components/input/Toggle/styles.module.scss +++ b/src/components/input/Toggle/styles.module.scss @@ -38,6 +38,10 @@ transition: transform 0.25s, color 0.25s, background-color; } + &:disabled { + opacity: 0.5; + } + &.active { &::after { transform: translate(24px, 0); diff --git a/src/components/lab/LabImage/index.tsx b/src/components/lab/LabImage/index.tsx index b132691d..8bc085a4 100644 --- a/src/components/lab/LabImage/index.tsx +++ b/src/components/lab/LabImage/index.tsx @@ -1,13 +1,16 @@ import React, { FC } from 'react'; +import Image from 'next/future/image'; import SwiperCore, { A11y, Navigation, Pagination } from 'swiper'; import { ImagePreloader } from '~/components/media/ImagePreloader'; import { Placeholder } from '~/components/placeholders/Placeholder'; import { INodeComponentProps } from '~/constants/node'; +import { imagePresets } from '~/constants/urls'; import { useGotoNode } from '~/hooks/node/useGotoNode'; import { useNodeImages } from '~/hooks/node/useNodeImages'; import { normalizeBrightColor } from '~/utils/color'; +import { getURL } from '~/utils/dom'; import styles from './styles.module.scss'; @@ -19,7 +22,7 @@ const LabImage: FC = ({ node, isLoading }) => { const images = useNodeImages(node); const onClick = useGotoNode(node.id); - if (!images?.length && !isLoading) { + if (!images?.length) { return null; } @@ -28,9 +31,12 @@ const LabImage: FC = ({ node, isLoading }) => { return (
- diff --git a/src/components/lab/LabImage/styles.module.scss b/src/components/lab/LabImage/styles.module.scss index bf0e220c..a8aecef7 100644 --- a/src/components/lab/LabImage/styles.module.scss +++ b/src/components/lab/LabImage/styles.module.scss @@ -51,6 +51,8 @@ max-height: calc(100vh - 70px - 70px); max-width: 100%; transition: box-shadow 1s; + max-inline-size: 100%; + block-size: auto; @include tablet { padding-bottom: 0; diff --git a/src/components/main/UserButton/index.tsx b/src/components/main/UserButton/index.tsx index dd06a27b..85c7bf2d 100644 --- a/src/components/main/UserButton/index.tsx +++ b/src/components/main/UserButton/index.tsx @@ -2,8 +2,7 @@ import { FC } from 'react'; import { Avatar } from '~/components/common/Avatar'; import { Group } from '~/components/containers/Group'; -import { Icon } from '~/components/input/Icon'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { IFile } from '~/types'; import { getURL } from '~/utils/dom'; @@ -12,15 +11,21 @@ import styles from './styles.module.scss'; interface IProps { username: string; photo?: IFile; + hasUpdates?: boolean; + onClick?: () => void; } -const UserButton: FC = ({ username, photo, onClick }) => { +const UserButton: FC = ({ username, photo, hasUpdates, onClick }) => { return ( ); diff --git a/src/components/media/ImagePreloader/index.tsx b/src/components/media/ImagePreloader/index.tsx index e650a8aa..f1b00ea3 100644 --- a/src/components/media/ImagePreloader/index.tsx +++ b/src/components/media/ImagePreloader/index.tsx @@ -1,4 +1,10 @@ -import React, { FC, MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import React, { + FC, + MouseEventHandler, + useCallback, + useMemo, + useState, +} from 'react'; import classNames from 'classnames'; @@ -6,7 +12,7 @@ import { ImageWithSSRLoad } from '~/components/common/ImageWithSSRLoad'; import { Icon } from '~/components/input/Icon'; import { LoaderCircle } from '~/components/input/LoaderCircle'; import { DEFAULT_DOMINANT_COLOR } from '~/constants/node'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { useResizeHandler } from '~/hooks/dom/useResizeHandler'; import { IFile } from '~/types'; import { getURL } from '~/utils/dom'; @@ -24,7 +30,13 @@ interface IProps { const DEFAULT_WIDTH = 1920; const DEFAULT_HEIGHT = 1020; -const ImagePreloader: FC = ({ file, color, onLoad, onClick, className }) => { +const ImagePreloader: FC = ({ + file, + color, + onLoad, + onClick, + className, +}) => { const [maxHeight, setMaxHeight] = useState(0); const [loaded, setLoaded] = useState(false); const [hasError, setHasError] = useState(false); @@ -47,8 +59,11 @@ const ImagePreloader: FC = ({ file, color, onLoad, onClick, className }) }, [setHasError]); const [width, height] = useMemo( - () => [file?.metadata?.width || DEFAULT_WIDTH, file?.metadata?.height || DEFAULT_HEIGHT], - [file] + () => [ + file?.metadata?.width || DEFAULT_WIDTH, + file?.metadata?.height || DEFAULT_HEIGHT, + ], + [file], ); useResizeHandler(onResize); @@ -74,11 +89,18 @@ const ImagePreloader: FC = ({ file, color, onLoad, onClick, className }) - + {!hasError && ( = ({ file, color, onLoad, onClick, className }) = ({ file, color, onLoad, onClick, className }) onError={onError} /> - {!loaded && !hasError && } + {!loaded && !hasError && ( + + )} {hasError && (
diff --git a/src/components/menu/HorizontalMenu/index.tsx b/src/components/menu/HorizontalMenu/index.tsx index 51daf0d2..146f1ebc 100644 --- a/src/components/menu/HorizontalMenu/index.tsx +++ b/src/components/menu/HorizontalMenu/index.tsx @@ -14,6 +14,7 @@ interface HorizontalMenuItemProps { icon?: string; color?: 'green' | 'orange' | 'yellow'; active?: boolean; + stretchy?: boolean; onClick?: () => void; } @@ -31,6 +32,7 @@ HorizontalMenu.Item = ({ children, isLoading, active, + stretchy, onClick, }: PropsWithChildren) => { if (isLoading) { @@ -44,7 +46,11 @@ HorizontalMenu.Item = ({ return (
{!!icon && } diff --git a/src/components/menu/HorizontalMenu/styles.module.scss b/src/components/menu/HorizontalMenu/styles.module.scss index e4ae9935..54d3c298 100644 --- a/src/components/menu/HorizontalMenu/styles.module.scss +++ b/src/components/menu/HorizontalMenu/styles.module.scss @@ -57,6 +57,11 @@ background: $warning_gradient; } } + + &.stretchy { + flex: 1; + justify-content: center; + } } .text { diff --git a/src/components/menu/SeparatedMenu/index.tsx b/src/components/menu/SeparatedMenu/index.tsx index 19a4c9e3..c030220b 100644 --- a/src/components/menu/SeparatedMenu/index.tsx +++ b/src/components/menu/SeparatedMenu/index.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; import styles from './styles.module.scss'; -interface SeparatedMenuProps { +export interface SeparatedMenuProps { className?: string; } @@ -14,7 +14,7 @@ const SeparatedMenu: FC = ({ children, className }) => { return []; } - return (Array.isArray(children) ? children : [children]).filter(it => it); + return (Array.isArray(children) ? children : [children]).filter((it) => it); }, [children]); return ( diff --git a/src/components/menu/VerticalMenu/index.tsx b/src/components/menu/VerticalMenu/index.tsx index 0b84065e..23c20e41 100644 --- a/src/components/menu/VerticalMenu/index.tsx +++ b/src/components/menu/VerticalMenu/index.tsx @@ -1,8 +1,8 @@ -import React, { PropsWithChildren } from 'react'; +import { PropsWithChildren } from 'react'; import classNames from 'classnames'; -import { Card } from '~/components/containers/Card'; +import { Anchor } from '~/components/common/Anchor'; import { DivProps, LinkProps } from '~/utils/types'; import styles from './styles.module.scss'; @@ -11,7 +11,9 @@ interface VerticalMenuProps extends DivProps { appearance?: 'inset' | 'flat' | 'default'; } -interface VerticalMenuItemProps extends Omit {} +interface VerticalMenuItemProps extends Omit { + hasUpdates?: boolean; +} function VerticalMenu({ children, @@ -28,8 +30,13 @@ function VerticalMenu({ ); } -VerticalMenu.Item = ({ ...props }: VerticalMenuItemProps) => ( - +VerticalMenu.Item = ({ hasUpdates, ...props }: VerticalMenuItemProps) => ( + ); export { VerticalMenu }; diff --git a/src/components/menu/VerticalMenu/styles.module.scss b/src/components/menu/VerticalMenu/styles.module.scss index 1c3e5d1d..81b66ffa 100644 --- a/src/components/menu/VerticalMenu/styles.module.scss +++ b/src/components/menu/VerticalMenu/styles.module.scss @@ -33,6 +33,19 @@ a.item { cursor: pointer; background-color: transparent; transition: background-color 0.25s; + position: relative; + + &.has_dot::after { + content: ' '; + position: absolute; + top: 50%; + right: 10px; + width: 8px; + height: 8px; + background: $color_danger; + border-radius: 8px; + transform: translate(0, -50%); + } &:hover { background-color: $content_bg_success; diff --git a/src/components/node/NodeAudioImageBlock/index.tsx b/src/components/node/NodeAudioImageBlock/index.tsx index 28726f0e..2563ae73 100644 --- a/src/components/node/NodeAudioImageBlock/index.tsx +++ b/src/components/node/NodeAudioImageBlock/index.tsx @@ -1,7 +1,7 @@ import React, { FC } from 'react'; import { INodeComponentProps } from '~/constants/node'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { useNodeImages } from '~/hooks/node/useNodeImages'; import { getURL } from '~/utils/dom'; import { path } from '~/utils/ramda'; @@ -19,7 +19,12 @@ const NodeAudioImageBlock: FC = ({ node }) => {
); diff --git a/src/components/node/NodeImageSwiperBlock/index.tsx b/src/components/node/NodeImageSwiperBlock/index.tsx index 12f93888..50a9c2db 100644 --- a/src/components/node/NodeImageSwiperBlock/index.tsx +++ b/src/components/node/NodeImageSwiperBlock/index.tsx @@ -1,20 +1,30 @@ -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import classNames from 'classnames'; import { observer } from 'mobx-react-lite'; -import SwiperCore, { Keyboard, Navigation, Pagination, SwiperOptions } from 'swiper'; +import SwiperCore, { + Keyboard, + Lazy, + Navigation, + Pagination, + SwiperOptions, +} from 'swiper'; import { Swiper, SwiperSlide } from 'swiper/react'; import SwiperClass from 'swiper/types/swiper-class'; -import { ImagePreloader } from '~/components/media/ImagePreloader'; +import { ImageLoadingWrapper } from '~/components/common/ImageLoadingWrapper/index'; import { INodeComponentProps } from '~/constants/node'; +import { imagePresets } from '~/constants/urls'; import { useModal } from '~/hooks/modal/useModal'; import { useImageModal } from '~/hooks/navigation/useImageModal'; import { useNodeImages } from '~/hooks/node/useNodeImages'; import { normalizeBrightColor } from '~/utils/color'; +import { getURL } from '~/utils/dom'; +import { getFileSrcSet } from '~/utils/srcset'; import styles from './styles.module.scss'; -SwiperCore.use([Navigation, Pagination, Keyboard]); +SwiperCore.use([Navigation, Pagination, Keyboard, Lazy]); interface IProps extends INodeComponentProps {} @@ -26,8 +36,18 @@ const breakpoints: SwiperOptions['breakpoints'] = { const pagination = { type: 'fraction' as const }; +const lazy = { + enabled: true, + loadPrevNextAmount: 1, + loadOnTransitionStart: true, + loadPrevNext: true, + checkInView: true, +}; + const NodeImageSwiperBlock: FC = observer(({ node }) => { - const [controlledSwiper, setControlledSwiper] = useState(undefined); + const [controlledSwiper, setControlledSwiper] = useState< + SwiperClass | undefined + >(undefined); const showPhotoSwiper = useImageModal(); const { isOpened: isModalActive } = useModal(); @@ -38,7 +58,7 @@ const NodeImageSwiperBlock: FC = observer(({ node }) => { enabled: !isModalActive, onlyInViewport: true, }), - [isModalActive] + [isModalActive], ); const updateSwiper = useCallback(() => { @@ -53,14 +73,17 @@ const NodeImageSwiperBlock: FC = observer(({ node }) => { const onOpenPhotoSwipe = useCallback( (index: number) => { - if (index !== controlledSwiper?.activeIndex && controlledSwiper?.slideTo) { + if ( + index !== controlledSwiper?.activeIndex && + controlledSwiper?.slideTo + ) { controlledSwiper.slideTo(index, 300); return; } showPhotoSwiper(images, index); }, - [images, controlledSwiper, showPhotoSwiper] + [images, controlledSwiper, showPhotoSwiper], ); useEffect(() => { @@ -80,18 +103,6 @@ const NodeImageSwiperBlock: FC = observer(({ node }) => { return null; } - if (images.length === 1) { - return ( -
- onOpenPhotoSwipe(0)} - className={styles.image} - /> -
- ); - } - return (
= observer(({ node }) => { autoHeight zoom navigation + watchSlidesProgress + lazy={lazy} > - {images.map((file, i) => ( + {images.map((file, index) => ( - onOpenPhotoSwipe(i)} - className={styles.image} - color={normalizeBrightColor(file?.metadata?.dominant_color)} - /> + + {({ loading, onLoad }) => ( + onOpenPhotoSwipe(index)} + className={classNames(styles.image, 'swiper-lazy', { + [styles.loading]: loading, + })} + color={normalizeBrightColor(file?.metadata?.dominant_color)} + alt="" + sizes="(max-width: 560px) 100vw, 50vh" + /> + )} + ))} diff --git a/src/components/node/NodeImageSwiperBlock/styles.module.scss b/src/components/node/NodeImageSwiperBlock/styles.module.scss index 46d4d405..4e59a912 100644 --- a/src/components/node/NodeImageSwiperBlock/styles.module.scss +++ b/src/components/node/NodeImageSwiperBlock/styles.module.scss @@ -95,12 +95,12 @@ width: auto; max-width: 100vw; opacity: 1; - //transform: translate(0, 10px); transform: scale(0.99); filter: brightness(50%) saturate(0.5); transition: opacity 0.5s, filter 0.5s, transform 0.5s; padding-bottom: $gap * 1.5; padding-top: $gap; + position: relative; &:global(.swiper-slide-active) { opacity: 1; @@ -117,12 +117,16 @@ } .image { - max-height: calc(100vh - 70px - 70px); - max-width: 100%; + max-inline-size: calc(100vh - 150px); + writing-mode: vertical-rl; + block-size: auto; border-radius: $radius; transition: box-shadow 1s; box-shadow: transparentize(black, 0.7) 0 3px 5px; - opacity: 0; + + &.loading { + opacity: 0; + } :global(.swiper-slide-active) & { box-shadow: transparentize(black, 0.9) 0 10px 5px 4px, @@ -134,7 +138,9 @@ max-height: 100vh; border-radius: 0; } -} -.loader { + @media (orientation: portrait) { + max-inline-size: 100vw; + writing-mode: horizontal-tb; + } } diff --git a/src/components/node/NodeNoComments/index.tsx b/src/components/node/NodeNoComments/index.tsx index 3ae20562..11db8302 100644 --- a/src/components/node/NodeNoComments/index.tsx +++ b/src/components/node/NodeNoComments/index.tsx @@ -9,20 +9,25 @@ import { t } from '~/utils/trans'; import styles from './styles.module.scss'; interface IProps { - is_loading?: boolean; + loading?: boolean; count?: number; } -const NodeNoComments: FC = ({ is_loading = false, count = 3 }) => { +const NodeNoComments: FC = ({ loading = false, count = 3 }) => { const items = useMemo( - () => [...new Array(count)].map((_, i) =>
), - [count] + () => + [...new Array(count)].map((_, i) => ( +
+ )), + [count], ); return ( - + {items} - {!is_loading &&
{t(ERRORS.NO_COMMENTS)}
} + {!loading && ( +
{t(ERRORS.NO_COMMENTS)}
+ )}
); }; diff --git a/src/components/node/NodeNoComments/styles.module.scss b/src/components/node/NodeNoComments/styles.module.scss index cdae439c..3b350569 100644 --- a/src/components/node/NodeNoComments/styles.module.scss +++ b/src/components/node/NodeNoComments/styles.module.scss @@ -1,5 +1,14 @@ @import 'src/styles/variables'; +@keyframes fade { + from { + opacity: 1; + } + to { + opacity: 0.3; + } +} + .wrap { user-select: none; overflow: hidden; @@ -17,7 +26,7 @@ bottom: 0; } - &:global(.is_loading) { + &.loading { opacity: 1; .card { diff --git a/src/components/node/NodeRelated/index.tsx b/src/components/node/NodeRelated/index.tsx index fc324707..b5b7ede1 100644 --- a/src/components/node/NodeRelated/index.tsx +++ b/src/components/node/NodeRelated/index.tsx @@ -1,13 +1,13 @@ import React, { FC, ReactElement } from 'react'; +import { Hoverable } from '~/components/common/Hoverable'; import { SubTitle } from '~/components/common/SubTitle'; import { Group } from '~/components/containers/Group'; -import { NodeRelatedItem } from '~/components/node/NodeRelatedItem'; +import { NodeThumbnail } from '~/components/node/NodeThumbnail'; import { INode } from '~/types'; import styles from './styles.module.scss'; - interface IProps { title: ReactElement | string; items: Partial[]; @@ -19,8 +19,10 @@ const NodeRelated: FC = ({ title, items }) => { {title}
- {items.map(item => ( - + {items.map((item) => ( + + + ))}
diff --git a/src/components/node/NodeRelated/placeholder.tsx b/src/components/node/NodeRelated/placeholder.tsx index b9a8d011..b63433a8 100644 --- a/src/components/node/NodeRelated/placeholder.tsx +++ b/src/components/node/NodeRelated/placeholder.tsx @@ -3,7 +3,7 @@ import React, { FC, memo } from 'react'; import classNames from 'classnames'; import { Group } from '~/components/containers/Group'; -import cell_style from '~/components/node/NodeRelatedItem/styles.module.scss'; +import cell_style from '~/components/node/NodeThumbnail/styles.module.scss'; import { Placeholder } from '~/components/placeholders/Placeholder'; import { range } from '~/utils/ramda'; @@ -23,7 +23,7 @@ const NodeRelatedPlaceholder: FC = memo(() => {
- {range(0, 6).map(el => ( + {range(0, 6).map((el) => (
))}
diff --git a/src/components/node/NodeRelated/styles.module.scss b/src/components/node/NodeRelated/styles.module.scss index ee44bbea..1d4de34d 100644 --- a/src/components/node/NodeRelated/styles.module.scss +++ b/src/components/node/NodeRelated/styles.module.scss @@ -1,5 +1,14 @@ @import 'src/styles/variables'; +@keyframes fade { + from { + opacity: 1; + } + to { + opacity: 0.2; + } +} + .wrap { border-radius: $panel_radius; padding: $gap 0; @@ -39,6 +48,11 @@ .grid { div { background: $placeholder_bg; + animation: fade 0.5s infinite alternate; } } } + +.item { + border-radius: $radius; +} diff --git a/src/components/node/NodeRelatedItem/index.tsx b/src/components/node/NodeThumbnail/index.tsx similarity index 85% rename from src/components/node/NodeRelatedItem/index.tsx rename to src/components/node/NodeThumbnail/index.tsx index be7186f1..1dd757a3 100644 --- a/src/components/node/NodeRelatedItem/index.tsx +++ b/src/components/node/NodeThumbnail/index.tsx @@ -1,20 +1,24 @@ -import React, { FC, memo, useEffect, useMemo, useRef, useState } from 'react'; +import { FC, memo, useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import { ImageWithSSRLoad } from '~/components/common/ImageWithSSRLoad'; import { Square } from '~/components/common/Square'; import { Icon } from '~/components/input/Icon'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { useColorGradientFromString } from '~/hooks/color/useColorGradientFromString'; import { useGotoNode } from '~/hooks/node/useGotoNode'; -import { INode } from '~/types'; import { getURL, getURLFromString } from '~/utils/dom'; import styles from './styles.module.scss'; -type IProps = { - item: Partial; +type NodeThumbnailProps = { + item: { + thumbnail?: string; + title?: string; + is_promoted?: boolean; + id?: number; + }; }; type CellSize = 'small' | 'medium' | 'large'; @@ -33,7 +37,7 @@ const getTitleLetters = (title?: string): string => { : words[0].substr(0, 2).toUpperCase(); }; -const NodeRelatedItem: FC = memo(({ item }) => { +const NodeThumbnail: FC = memo(({ item }) => { const onClick = useGotoNode(item.id); const [is_loaded, setIsLoaded] = useState(false); const [width, setWidth] = useState(0); @@ -42,7 +46,7 @@ const NodeRelatedItem: FC = memo(({ item }) => { const thumb = useMemo( () => item.thumbnail - ? getURL({ url: item.thumbnail }, ImagePresets.avatar) + ? getURL({ url: item.thumbnail }, imagePresets.avatar) : '', [item], ); @@ -68,7 +72,7 @@ const NodeRelatedItem: FC = memo(({ item }) => { }, [width]); const image = useMemo( - () => getURL({ url: item.thumbnail }, ImagePresets.avatar), + () => getURL({ url: item.thumbnail }, imagePresets.avatar), [item.thumbnail], ); @@ -118,4 +122,4 @@ const NodeRelatedItem: FC = memo(({ item }) => { ); }); -export { NodeRelatedItem }; +export { NodeThumbnail }; diff --git a/src/components/node/NodeRelatedItem/styles.module.scss b/src/components/node/NodeThumbnail/styles.module.scss similarity index 100% rename from src/components/node/NodeRelatedItem/styles.module.scss rename to src/components/node/NodeThumbnail/styles.module.scss diff --git a/src/components/node/NodeTitle/index.tsx b/src/components/node/NodeTitle/index.tsx index d33a4bab..99ae85ae 100644 --- a/src/components/node/NodeTitle/index.tsx +++ b/src/components/node/NodeTitle/index.tsx @@ -2,6 +2,7 @@ import React, { memo, VFC } from 'react'; import classNames from 'classnames'; +import { Authorized } from '~/components/containers/Authorized'; import { Icon } from '~/components/input/Icon'; import { SeparatedMenu } from '~/components/menu/SeparatedMenu'; import { NodeEditMenu } from '~/components/node/NodeEditMenu'; @@ -76,37 +77,39 @@ const NodeTitle: VFC = memo( )}
- - {canEdit && ( - - )} + + + {canEdit && ( + + )} - {canLike && ( -
- {isLiked ? ( - - ) : ( - - )} + {canLike && ( +
+ {isLiked ? ( + + ) : ( + + )} - {!!likeCount && likeCount > 0 && ( -
{likeCount}
- )} -
- )} - + {!!likeCount && likeCount > 0 && ( +
{likeCount}
+ )} +
+ )} +
+
); diff --git a/src/components/notifications/NotificationBadge/index.tsx b/src/components/notifications/NotificationBadge/index.tsx new file mode 100644 index 00000000..20c32a29 --- /dev/null +++ b/src/components/notifications/NotificationBadge/index.tsx @@ -0,0 +1,77 @@ +import React, { FC } from 'react'; + +import { Anchor } from '~/components/common/Anchor'; +import { InlineUsername } from '~/components/common/InlineUsername'; +import { Square } from '~/components/common/Square'; +import { Card } from '~/components/containers/Card'; +import { FlowRecentItem } from '~/components/flow/FlowRecentItem'; +import { NotificationItem, NotificationType } from '~/types/notifications'; +import { formatText, getPrettyDate, getURLFromString } from '~/utils/dom'; + +import styles from './styles.module.scss'; + +interface NotificationBadgeProps { + item: NotificationItem; +} + +const getTitle = (item: NotificationItem) => { + if (!item.user.username) { + return ''; + } + + switch (item.type) { + case NotificationType.Comment: + return ( + + {item.user.username} пишет: + + ); + case NotificationType.Node: + return ( + + Новый пост от {item.user.username}: + + ); + } +}; + +const getContent = (item: NotificationItem) => { + switch (item.type) { + case NotificationType.Comment: + return ( +
+ ); + case NotificationType.Node: + return ( +
+ ); + } +}; + +const getIcon = (item: NotificationItem) => { + return ; +}; + +const NotificationBadge: FC = ({ item }) => ( + +
+
{getIcon(item)}
+ +
+ {getTitle(item)} +
{getContent(item)}
+
{getPrettyDate(item.created_at)}
+
+
+
+); + +export { NotificationBadge }; diff --git a/src/components/notifications/NotificationBadge/styles.module.scss b/src/components/notifications/NotificationBadge/styles.module.scss new file mode 100644 index 00000000..8468237e --- /dev/null +++ b/src/components/notifications/NotificationBadge/styles.module.scss @@ -0,0 +1,33 @@ +@import 'src/styles/variables'; + +.link { + text-decoration: none; + color: inherit; +} + +.message { + font: $font_14_regular; + line-height: 1.3em; + padding: $gap/2 $gap/2 $gap/4 $gap/2; + min-height: calc(1.3em * 3 + $gap); + display: grid; + grid-template-columns: 40px auto; + column-gap: $gap; +} + +.text { + @include clamp(2, 14px); + text-overflow: ellipsis; +} + +.title { + font: $font_14_semibold; + margin-bottom: $gap / 2; +} + +.time { + font: $font_10_regular; + text-align: right; + margin-top: 2px; + color: $gray_75; +} diff --git a/src/components/notifications/NotificationBubble/index.tsx b/src/components/notifications/NotificationBubble/index.tsx deleted file mode 100644 index 0ef0d796..00000000 --- a/src/components/notifications/NotificationBubble/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React, { createElement, FC } from 'react'; - -import { Icon } from '~/components/input/Icon'; -import { useRandomPhrase } from '~/constants/phrases'; -import { INotification, NOTIFICATION_TYPES } from '~/types'; - -import { NotificationMessage } from '../NotificationMessage'; - -import styles from './styles.module.scss'; - -interface IProps { - notifications: INotification[]; - onClick: (notification: INotification) => void; -} - -const NOTIFICATION_RENDERERS = { - [NOTIFICATION_TYPES.message]: NotificationMessage, -}; - -const NotificationBubble: FC = ({ notifications, onClick }) => { - const placeholder = useRandomPhrase('NOTHING_HERE'); - - return ( -
-
- {notifications.length === 0 && ( -
- -
{placeholder}
-
- )} - {notifications.length > 0 && - notifications - .filter(notification => notification.type && NOTIFICATION_RENDERERS[notification.type]) - .map(notification => - createElement(NOTIFICATION_RENDERERS[notification.type], { - notification, - onClick, - key: notification.content.id, - }) - )} -
-
- ); -}; - -export { NotificationBubble }; diff --git a/src/components/notifications/NotificationBubble/styles.module.scss b/src/components/notifications/NotificationBubble/styles.module.scss deleted file mode 100644 index d351ef4d..00000000 --- a/src/components/notifications/NotificationBubble/styles.module.scss +++ /dev/null @@ -1,106 +0,0 @@ -@import 'src/styles/variables'; - -$notification_color: $content_bg_dark; - -@keyframes appear { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -.wrap { - position: absolute; - background: $notification_color; - top: 42px; - left: 50%; - transform: translate(-50%, 0); - border-radius: $radius; - animation: appear 0.25s forwards; - z-index: 2; - - &::before { - content: ' '; - width: 0; - height: 0; - border-style: solid; - border-width: 0 0 16px 16px; - border-color: transparent transparent $notification_color transparent; - position: absolute; - left: 50%; - top: -16px; - transform: translate(-20px, 0); - } -} - -.list { - width: 300px; - max-width: 100vw; - min-width: 0; - max-height: 400px; - overflow: auto; -} - -.item { - display: flex; - align-items: stretch; - justify-content: stretch; - flex-direction: column; - padding: $gap; - min-width: 0; - cursor: pointer; - - svg { - fill: white; - margin-right: $gap; - } -} - -.item_head { - display: flex; - align-items: center; - justify-content: flex-start; - flex-direction: row; -} - -.item_title { - flex: 1; - white-space: nowrap; - font: $font_14_semibold; - overflow: hidden; - text-overflow: ellipsis; - // text-transform: none; -} - -.item_text { - font: $font_14_regular; - max-height: 2.4em; - padding-left: 30px; - overflow: hidden; -} - -.placeholder { - height: 200px; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - text-transform: uppercase; - font: $font_16_semibold; - box-sizing: border-box; - padding: 80px; - text-align: center; - line-height: 1.6em; - - svg { - width: 120px; - height: 120px; - opacity: 0.05; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } -} diff --git a/src/components/notifications/NotificationComment/index.tsx b/src/components/notifications/NotificationComment/index.tsx new file mode 100644 index 00000000..0310cc85 --- /dev/null +++ b/src/components/notifications/NotificationComment/index.tsx @@ -0,0 +1,54 @@ +import { FC } from 'react'; + +import { Anchor } from '~/components/common/Anchor'; +import { Avatar } from '~/components/common/Avatar'; +import { InlineUsername } from '~/components/common/InlineUsername'; +import { Square } from '~/components/common/Square'; +import { NotificationItem } from '~/types/notifications'; +import { formatText, getPrettyDate, getURLFromString } from '~/utils/dom'; + +import styles from './styles.module.scss'; + +interface NotificationCommentProps { + item: NotificationItem; +} + +const NotificationComment: FC = ({ item }) => ( + +
+
+ +
+ +
+ + + {item.user.username} + + - + +
{item.title}
+
+ +
+
+
+
+
+ +); + +export { NotificationComment }; diff --git a/src/components/notifications/NotificationComment/styles.module.scss b/src/components/notifications/NotificationComment/styles.module.scss new file mode 100644 index 00000000..a0f60d6b --- /dev/null +++ b/src/components/notifications/NotificationComment/styles.module.scss @@ -0,0 +1,72 @@ +@import 'src/styles/variables'; + +.link { + text-decoration: none; + color: inherit; +} + +.message { + font: $font_14_regular; + line-height: 1.3em; + min-height: calc(1.3em * 3 + $gap); + display: grid; + grid-template-columns: 32px auto; + column-gap: 0; +} + +.content { + background: $content_bg; + padding: 5px; + border-radius: 0 $radius $radius $radius; + position: relative; + min-width: 0; + + &:before { + content: ' '; + position: absolute; + top: 8px; + right: 100%; + @include arrow_left(8px, $content_bg); + } +} + +.text { + @include clamp(2, 14px); + text-overflow: ellipsis; +} + +.title { + font: $font_14_medium; + margin-bottom: $gap / 2; + display: flex; + flex-direction: row; + align-items: center; + + & > * { + padding-right: 5px; + } +} + +.item_title { + flex: 1; + padding-left: 5px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.item_image { + flex: 0 0 16px; + border-radius: 2px; +} + +.time { + font: $font_10_regular; + text-align: right; + margin-top: 2px; + color: $gray_75; +} + +div.circle { + border-radius: 4px 0 0 4px; +} diff --git a/src/components/notifications/NotificationMessage/index.tsx b/src/components/notifications/NotificationMessage/index.tsx deleted file mode 100644 index 760d9390..00000000 --- a/src/components/notifications/NotificationMessage/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { FC, useCallback } from 'react'; - -import { Icon } from '~/components/input/Icon'; -import styles from '~/components/notifications/NotificationBubble/styles.module.scss'; -import { IMessageNotification, INotification } from '~/types'; - -interface IProps { - notification: IMessageNotification; - onClick: (notification: INotification) => void; -} - -const NotificationMessage: FC = ({ - notification, - notification: { - content: { text, from }, - }, - onClick, -}) => { - const onMouseDown = useCallback(() => onClick(notification), [onClick, notification]); - - return ( -
-
- -
Сообщение от ~{from?.username}:
-
-
{text}
-
- ); -}; - -export { NotificationMessage }; diff --git a/src/components/notifications/NotificationNode/index.tsx b/src/components/notifications/NotificationNode/index.tsx new file mode 100644 index 00000000..ce7deb0d --- /dev/null +++ b/src/components/notifications/NotificationNode/index.tsx @@ -0,0 +1,39 @@ +import { FC, useMemo } from 'react'; + +import { NodeThumbnail } from '~/components/node/NodeThumbnail'; +import { NotificationItem } from '~/types/notifications'; +import { getPrettyDate } from '~/utils/dom'; + +import styles from './styles.module.scss'; + +interface NotificationNodeProps { + item: NotificationItem; +} + +const NotificationNode: FC = ({ item }) => { + const thumbnail = useMemo( + () => ({ + title: item.title, + thumbnail: item.thumbnail, + is_promoted: true, + }), + [item], + ); + + return ( +
+
+ +
+ +
+
{item.title || '...'}
+
+ ~{item.user.username}, {getPrettyDate(item.created_at)} +
+
+
+ ); +}; + +export { NotificationNode }; diff --git a/src/components/notifications/NotificationNode/styles.module.scss b/src/components/notifications/NotificationNode/styles.module.scss new file mode 100644 index 00000000..89672976 --- /dev/null +++ b/src/components/notifications/NotificationNode/styles.module.scss @@ -0,0 +1,36 @@ +@import 'src/styles/variables'; + +.card { + background-color: $content_bg; + display: flex; + flex-direction: row; + border-radius: $radius; + justify-content: center; + align-items: center; + padding: $gap/2; +} + +.text { + flex: 1; + min-width: 0; + padding-left: $gap; + margin-top: -0.2em; +} + +.title { + font-size: 1.2em; + font-weight: bold; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + text-transform: capitalize; +} + +.user { + font-size: 0.7em; + color: $gray_50; +} + +.image { + flex: 0 0 48px; +} diff --git a/src/components/notifications/NotificationSettingsForm/index.tsx b/src/components/notifications/NotificationSettingsForm/index.tsx new file mode 100644 index 00000000..c9387ad4 --- /dev/null +++ b/src/components/notifications/NotificationSettingsForm/index.tsx @@ -0,0 +1,86 @@ +import React, { FC, useCallback } from 'react'; + +import { Card } from '~/components/containers/Card'; +import { Group } from '~/components/containers/Group'; +import { Zone } from '~/components/containers/Zone'; +import { Button } from '~/components/input/Button'; +import { InputRow } from '~/components/input/InputRow'; +import { Toggle } from '~/components/input/Toggle'; +import { useNotificationSettingsForm } from '~/hooks/notifications/useNotificationSettingsForm'; +import { NotificationSettings } from '~/types/notifications'; + +import styles from './styles.module.scss'; + +interface NotificationSettingsFormProps { + value: NotificationSettings; + onSubmit: (val: Partial) => Promise; + telegramConnected: boolean; + onConnectTelegram: () => void; +} + +const NotificationSettingsForm: FC = ({ + value, + onSubmit, + telegramConnected, + onConnectTelegram, +}) => { + const { setFieldValue, values } = useNotificationSettingsForm( + value, + onSubmit, + ); + + const toggle = useCallback( + (key: keyof NotificationSettings, disabled?: boolean) => ( + setFieldValue(key, val)} + value={values[key]} + disabled={disabled} + /> + ), + [setFieldValue, values], + ); + + const telegramInput = telegramConnected ? ( + toggle('sendTelegram', !values.enabled) + ) : ( + + ); + + return ( + + + Получать уведомления + + +
+ + + + + Новые посты + + + + Комментарии + + + + +
+ + + + + На иконке профиля + + + Телеграм + + + + ); +}; + +export { NotificationSettingsForm }; diff --git a/src/components/notifications/NotificationSettingsForm/styles.module.scss b/src/components/notifications/NotificationSettingsForm/styles.module.scss new file mode 100644 index 00000000..4aa76416 --- /dev/null +++ b/src/components/notifications/NotificationSettingsForm/styles.module.scss @@ -0,0 +1,7 @@ +@import 'src/styles/variables'; + +.grid { + display: grid; + grid-auto-flow: row; + row-gap: $gap; +} diff --git a/src/components/profile/ProfileAvatar/index.tsx b/src/components/profile/ProfileAvatar/index.tsx index 4168d385..7cdd652c 100644 --- a/src/components/profile/ProfileAvatar/index.tsx +++ b/src/components/profile/ProfileAvatar/index.tsx @@ -2,7 +2,7 @@ import React, { ChangeEvent, FC, useCallback } from 'react'; import { Avatar } from '~/components/common/Avatar'; import { Button } from '~/components/input/Button'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { IFile } from '~/types'; import { getURL } from '~/utils/dom'; diff --git a/src/components/profile/ProfileSidebarNotes/styles.module.scss b/src/components/profile/ProfileSidebarNotes/styles.module.scss deleted file mode 100644 index 078e0081..00000000 --- a/src/components/profile/ProfileSidebarNotes/styles.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -@import "src/styles/variables"; - -.scroller { - flex: 1; - overflow: auto; - padding: $gap; -} diff --git a/src/components/profile/ProfileSidebarNotifications/index.tsx b/src/components/profile/ProfileSidebarNotifications/index.tsx new file mode 100644 index 00000000..7a700117 --- /dev/null +++ b/src/components/profile/ProfileSidebarNotifications/index.tsx @@ -0,0 +1,64 @@ +import { useState, VFC } from 'react'; + +import { Group } from '~/components/containers/Group'; +import { Button } from '~/components/input/Button'; +import { Icon } from '~/components/input/Icon'; +import { HorizontalMenu } from '~/components/menu/HorizontalMenu'; +import { useStackContext } from '~/components/sidebar/SidebarStack'; +import { SidebarStackCard } from '~/components/sidebar/SidebarStackCard'; +import { NotificationList } from '~/containers/notifications/NotificationList'; +import { NotificationSettings } from '~/containers/notifications/NotificationSettings'; +import { useNotificationSettings } from '~/hooks/notifications/useNotificationSettings'; + +import styles from './styles.module.scss'; + +interface ProfileSidebarNotificationsProps {} + +enum Tabs { + List, + Settings, +} + +const ProfileSidebarNotifications: VFC< + ProfileSidebarNotificationsProps +> = () => { + const { closeAllTabs } = useStackContext(); + const [tab, setTab] = useState(Tabs.List); + const { loading } = useNotificationSettings(); + + return ( + +
+ + + setTab(Tabs.List)} + stretchy + > + Уведомления + + + setTab(Tabs.Settings)} + stretchy + > + Настройки + + + +
+
+ ); +}; + +export { ProfileSidebarNotifications }; diff --git a/src/components/profile/ProfileSidebarNotifications/styles.module.scss b/src/components/profile/ProfileSidebarNotifications/styles.module.scss new file mode 100644 index 00000000..59f37f49 --- /dev/null +++ b/src/components/profile/ProfileSidebarNotifications/styles.module.scss @@ -0,0 +1,31 @@ +@import 'src/styles/variables'; + +.grid { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + z-index: 4; + height: 100%; +} + +.head { + @include row_shadow; + + width: 100%; + padding: $gap; + flex: 0; +} + +.tabs { + flex: 1; +} + +.list { + @include row_shadow; + + overflow-y: auto; + flex: 1 1; + overflow: auto; + width: 100%; +} diff --git a/src/components/sortable/SortableImageGrid/index.tsx b/src/components/sortable/SortableImageGrid/index.tsx index ecc9e7c7..086fd268 100644 --- a/src/components/sortable/SortableImageGrid/index.tsx +++ b/src/components/sortable/SortableImageGrid/index.tsx @@ -1,7 +1,9 @@ import React, { FC, useMemo } from 'react'; +import { observer } from 'mobx-react-lite'; + import { ImageUpload } from '~/components/upload/ImageUpload'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { UploadStatus } from '~/store/uploader/UploaderStore'; import { IFile } from '~/types'; import { getURL } from '~/utils/dom'; @@ -18,18 +20,31 @@ interface SortableImageGridProps { className?: string; size?: number; } -const renderItem = ({ item, onDelete }: { item: IFile; onDelete: (fileId: number) => void }) => ( - +const renderItem = observer( + ({ item, onDelete }: { item: IFile; onDelete: (fileId: number) => void }) => ( + + ), ); -const renderLocked = ({ - locked, - onDelete, -}: { - locked: UploadStatus; - onDelete: (fileId: number) => void; -}) => ( - +const renderLocked = observer( + ({ + locked, + onDelete, + }: { + locked: UploadStatus; + onDelete: (fileId: number) => void; + }) => ( + + ), ); const SortableImageGrid: FC = ({ @@ -46,8 +61,8 @@ const SortableImageGrid: FC = ({ it.id} - getLockedID={it => it.id} + getID={(it) => it.id} + getLockedID={(it) => it.id} renderItem={renderItem} renderItemProps={props} renderLocked={renderLocked} diff --git a/src/constants/api.ts b/src/constants/api.ts index 6cb36bf4..b3318a30 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -24,6 +24,7 @@ export const API = { DROP_SOCIAL: (provider, id) => `/oauth/${provider}/${id}`, ATTACH_SOCIAL: `/oauth`, LOGIN_WITH_SOCIAL: `/oauth`, + ATTACH_TELEGRAM: '/oauth/telegram/attach', }, NODES: { SAVE: '/nodes/', @@ -62,4 +63,8 @@ export const API = { STATS: '/nodes/lab/stats', UPDATES: '/nodes/lab/updates', }, + NOTIFICATIONS: { + LIST: '/notifications/', + SETTINGS: '/notifications/settings', + }, }; diff --git a/src/constants/auth/socials.ts b/src/constants/auth/socials.ts index 3d0532f5..f9d24fd6 100644 --- a/src/constants/auth/socials.ts +++ b/src/constants/auth/socials.ts @@ -3,4 +3,5 @@ import { OAuthProvider } from '~/types/auth'; export const SOCIAL_ICONS: Record = { vkontakte: 'vk', google: 'google', + telegram: 'telegram', }; diff --git a/src/constants/modal/index.ts b/src/constants/modal/index.ts index 5717475e..344fb577 100644 --- a/src/constants/modal/index.ts +++ b/src/constants/modal/index.ts @@ -6,6 +6,7 @@ import { LoginSocialRegisterDialog } from '~/containers/dialogs/LoginSocialRegis import { PhotoSwipe } from '~/containers/dialogs/PhotoSwipe'; import { RestorePasswordDialog } from '~/containers/dialogs/RestorePasswordDialog'; import { RestoreRequestDialog } from '~/containers/dialogs/RestoreRequestDialog'; +import { TelegramAttachDialog } from '~/containers/dialogs/TelegramAttachDialog'; import { TestDialog } from '~/containers/dialogs/TestDialog'; export enum Dialog { @@ -18,6 +19,7 @@ export enum Dialog { Photoswipe = 'Photoswipe', CreateNode = 'CreateNode', EditNode = 'EditNode', + TelegramAttach = 'TelegramAttach', } export const DIALOG_CONTENT = { @@ -30,4 +32,5 @@ export const DIALOG_CONTENT = { [Dialog.Photoswipe]: PhotoSwipe, [Dialog.CreateNode]: EditorCreateDialog, [Dialog.EditNode]: EditorEditDialog, + [Dialog.TelegramAttach]: TelegramAttachDialog, } as const; diff --git a/src/constants/urls.ts b/src/constants/urls.ts index 5f1fc6f7..9f3a8b7b 100644 --- a/src/constants/urls.ts +++ b/src/constants/urls.ts @@ -1,49 +1,61 @@ -import { FlowDisplayVariant, INode } from "~/types"; +import { FlowDisplayVariant, INode } from '~/types'; export const URLS = { - BASE: "/", - LAB: "/lab", - BORIS: "/boris", + BASE: '/', + LAB: '/lab', + BORIS: '/boris', AUTH: { - LOGIN: "/auth/login", + LOGIN: '/auth/login', }, EXAMPLES: { - EDITOR: "/examples/edit", - IMAGE: "/examples/image", + EDITOR: '/examples/edit', + IMAGE: '/examples/image', }, ERRORS: { - NOT_FOUND: "/lost", - BACKEND_DOWN: "/oopsie", + NOT_FOUND: '/lost', + BACKEND_DOWN: '/oopsie', }, - NODE_URL: (id: INode["id"] | string) => `/post${id}`, + NODE_URL: (id: INode['id'] | string) => `/post${id}`, PROFILE_PAGE: (username: string) => `/profile/${username}`, SETTINGS: { - BASE: "/settings", - NOTES: "/settings/notes", - TRASH: "/settings/trash", + BASE: '/settings', + NOTES: '/settings/notes', + TRASH: '/settings/trash', }, - NOTES: "/notes/", + NOTES: '/notes/', NOTE: (id: number) => `/notes/${id}`, }; -export const ImagePresets = { - "1600": "1600", - "600": "600", - "300": "300", - cover: "cover", - small_hero: "small_hero", - avatar: "avatar", - flow_square: "flow_square", - flow_vertical: "flow_vertical", - flow_horizontal: "flow_horizontal", +export const imagePresets = { + '1600': '1600', + '900': '900', + '1200': '1200', + '600': '600', + '300': '300', + cover: 'cover', + small_hero: 'small_hero', + avatar: 'avatar', + flow_square: 'flow_square', + flow_vertical: 'flow_vertical', + flow_horizontal: 'flow_horizontal', } as const; +export type ImagePreset = typeof imagePresets[keyof typeof imagePresets]; + +export const imageSrcSets: Partial> = { + [imagePresets[1600]]: 1600, + [imagePresets[900]]: 900, + [imagePresets[1200]]: 1200, + [imagePresets[600]]: 600, + [imagePresets[300]]: 300, +}; + export const flowDisplayToPreset: Record< FlowDisplayVariant, - typeof ImagePresets[keyof typeof ImagePresets] + typeof imagePresets[keyof typeof imagePresets] > = { - single: "flow_square", - quadro: "flow_square", - vertical: "flow_vertical", - horizontal: "flow_horizontal", + single: 'flow_square', + quadro: 'flow_square', + vertical: 'flow_vertical', + horizontal: 'flow_horizontal', }; diff --git a/src/containers/boris/BorisComments/index.tsx b/src/containers/boris/BorisComments/index.tsx index b0052db2..7f17df1f 100644 --- a/src/containers/boris/BorisComments/index.tsx +++ b/src/containers/boris/BorisComments/index.tsx @@ -17,27 +17,27 @@ const BorisComments: FC = () => { const user = useUserContext(); const { isUser } = useAuth(); - const { - isLoading, - comments, - onSaveComment, - } = useCommentContext(); + const { isLoading, comments, onSaveComment } = useCommentContext(); const { node } = useNodeContext(); return ( - - {(isUser || isSSR) && ( - - )} + + {(isUser || isSSR) && ( + + )} - {isLoading || !comments?.length ? ( - - ) : ( - - )} + {isLoading || !comments?.length ? ( + + ) : ( + + )} -
- +
+ ); }; diff --git a/src/containers/dialogs/PhotoSwipe/index.tsx b/src/containers/dialogs/PhotoSwipe/index.tsx index f64395b0..a0437ec3 100644 --- a/src/containers/dialogs/PhotoSwipe/index.tsx +++ b/src/containers/dialogs/PhotoSwipe/index.tsx @@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite'; import PhotoSwipeUI_Default from 'photoswipe/dist/photoswipe-ui-default.js'; import PhotoSwipeJs from 'photoswipe/dist/photoswipe.js'; -import { ImagePresets } from '~/constants/urls'; +import { imagePresets } from '~/constants/urls'; import { useWindowSize } from '~/hooks/dom/useWindowSize'; import { useModal } from '~/hooks/modal/useModal'; import { IFile } from '~/types'; @@ -25,35 +25,47 @@ const PhotoSwipe: VFC = observer(({ index, items }) => { const { isTablet } = useWindowSize(); useEffect(() => { - new Promise(async resolve => { + new Promise(async (resolve) => { const images = await Promise.all( items.map( - image => - new Promise(resolveImage => { + (file) => + new Promise((resolve) => { + const src = getURL( + file, + isTablet ? imagePresets[900] : imagePresets[1600], + ); + + if (file.metadata?.width && file.metadata.height) { + resolve({ + src, + w: file.metadata.width, + h: file.metadata.height, + }); + + return; + } + const img = new Image(); img.onload = () => { - resolveImage({ - src: getURL( - image, - isTablet ? ImagePresets[900] : ImagePresets[1600], - ), + resolve({ + src, h: img.naturalHeight, w: img.naturalWidth, }); }; img.onerror = () => { - resolveImage({}); + resolve({}); }; - img.src = getURL(image, ImagePresets[1600]); + img.src = getURL(file, imagePresets[1600]); }), ), ); resolve(images); - }).then(images => { + }).then((images) => { const ps = new PhotoSwipeJs(ref.current, PhotoSwipeUI_Default, images, { index: index || 0, closeOnScroll: false, diff --git a/src/containers/dialogs/TelegramAttachDialog/index.tsx b/src/containers/dialogs/TelegramAttachDialog/index.tsx new file mode 100644 index 00000000..2a284db3 --- /dev/null +++ b/src/containers/dialogs/TelegramAttachDialog/index.tsx @@ -0,0 +1,49 @@ +import React, { FC, useCallback, useMemo } from 'react'; + +import { TelegramUser } from '@v9v/ts-react-telegram-login'; + +import { Padder } from '~/components/containers/Padder'; +import { Button } from '~/components/input/Button'; +import { useTelegramAccount } from '~/hooks/auth/useTelegramAccount'; +import { DialogComponentProps } from '~/types/modal'; + +import { TelegramLoginForm } from '../../../components/auth/oauth/TelegramLoginForm/index'; +import { BetterScrollDialog } from '../../../components/dialogs/BetterScrollDialog'; + +interface TelegramAttachDialogProps extends DialogComponentProps {} + +const botName = process.env.NEXT_PUBLIC_BOT_USERNAME; + +const TelegramAttachDialog: FC = ({ + onRequestClose, +}) => { + const { attach } = useTelegramAccount(); + + const onAttach = useCallback( + (data: TelegramUser) => attach(data, onRequestClose), + [onRequestClose], + ); + + const buttons = useMemo( + () => ( + + + + ), + [onRequestClose], + ); + + if (!botName) { + onRequestClose(); + return null; + } + + return ( + + + + ); +}; +export { TelegramAttachDialog }; diff --git a/src/containers/lab/LabGrid/index.tsx b/src/containers/lab/LabGrid/index.tsx index a8b9c1a3..792a00e9 100644 --- a/src/containers/lab/LabGrid/index.tsx +++ b/src/containers/lab/LabGrid/index.tsx @@ -1,5 +1,6 @@ import { FC, memo } from 'react'; +import { Hoverable } from '~/components/common/Hoverable'; import { Columns } from '~/components/containers/Columns'; import { InfiniteScroll } from '~/components/containers/InfiniteScroll'; import { LabNoResults } from '~/components/lab/LabNoResults'; @@ -11,27 +12,27 @@ import styles from './styles.module.scss'; interface IProps {} const LabGrid: FC = memo(() => { - const { nodes, hasMore, loadMore, search, setSearch } = useLabContext(); + const { nodes, hasMore, loadMore, search, setSearch, isLoading } = + useLabContext(); if (search && !nodes.length) { return setSearch('')} />; } return ( - -
- - {nodes.map((node) => ( +
+ + {nodes.map((node) => ( + - ))} - -
- + + ))} +
+
); }); diff --git a/src/containers/main/Header/index.tsx b/src/containers/main/Header/index.tsx index 893737d3..10b43801 100644 --- a/src/containers/main/Header/index.tsx +++ b/src/containers/main/Header/index.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; import isBefore from 'date-fns/isBefore'; @@ -9,17 +9,16 @@ import { Authorized } from '~/components/containers/Authorized'; import { Filler } from '~/components/containers/Filler'; import { Button } from '~/components/input/Button'; import { Logo } from '~/components/main/Logo'; -import { UserButton } from '~/components/main/UserButton'; import { Dialog } from '~/constants/modal'; -import { SidebarName } from '~/constants/sidebar'; import { URLS } from '~/constants/urls'; import { useAuth } from '~/hooks/auth/useAuth'; import { useScrollTop } from '~/hooks/dom/useScrollTop'; import { useFlow } from '~/hooks/flow/useFlow'; -import { useGetLabStats } from '~/hooks/lab/useGetLabStats'; import { useModal } from '~/hooks/modal/useModal'; import { useUpdates } from '~/hooks/updates/useUpdates'; -import { useSidebar } from '~/utils/providers/SidebarProvider'; +import { useNotifications } from '~/utils/providers/NotificationProvider'; + +import { UserButtonWithNotifications } from '../UserButtonWithNotifications'; import styles from './styles.module.scss'; @@ -28,14 +27,10 @@ export interface HeaderProps {} const Header: FC = observer(() => { const [isScrolled, setIsScrolled] = useState(false); const { showModal } = useModal(); - const { isUser, user } = useAuth(); + const { isUser, user, fetched } = useAuth(); const { hasFlowUpdates, hasLabUpdates } = useFlow(); const { borisCommentedAt } = useUpdates(); - const { open } = useSidebar(); - - const openProfileSidebar = useCallback(() => { - open(SidebarName.Settings, {}); - }, [open]); + const { indicatorEnabled } = useNotifications(); const onLogin = useCallback(() => showModal(Dialog.Login, {}), [showModal]); @@ -44,10 +39,11 @@ const Header: FC = observer(() => { const hasBorisUpdates = useMemo( () => isUser && + !indicatorEnabled && borisCommentedAt && - (!user.last_seen_boris || + ((fetched && !user.last_seen_boris) || isBefore(new Date(user.last_seen_boris), new Date(borisCommentedAt))), - [borisCommentedAt, isUser, user.last_seen_boris], + [borisCommentedAt, isUser, user.last_seen_boris, fetched], ); // Needed for SSR @@ -66,11 +62,15 @@ const Header: FC = observer(() => { -
- +
diff --git a/src/containers/node/NodeComments/index.tsx b/src/containers/node/NodeComments/index.tsx index 27252081..6fbcf1fd 100644 --- a/src/containers/node/NodeComments/index.tsx +++ b/src/containers/node/NodeComments/index.tsx @@ -34,15 +34,21 @@ const NodeComments: FC = memo(({ order }) => { const groupped: ICommentGroup[] = useGrouppedComments( comments, order, - lastSeenCurrent ?? undefined + lastSeenCurrent ?? undefined, ); const more = useMemo( () => - hasMore &&
- -
, - [hasMore, onLoadMoreComments, isLoadingMore] + hasMore && + !isLoading && ( +
+ +
+ ), + [hasMore, onLoadMoreComments, isLoadingMore, isLoading], ); if (!node?.id) { @@ -53,7 +59,7 @@ const NodeComments: FC = memo(({ order }) => {
{order === 'DESC' && more} - {groupped.map(group => ( + {groupped.map((group) => ( = () => { + const { isLoading, items } = useNotificationsList(); + const { enabled, toggleEnabled } = useNotifications(); + const { markAsRead } = useNotifications(); + + useEffect(() => { + return () => markAsRead(); + }, []); + + const renderItem = useCallback((item: NotificationItem) => { + switch (item.type) { + case NotificationType.Comment: + return ; + case NotificationType.Node: + return ; + default: + return null; + } + }, []); + + if (isLoading) { + return ; + } + + return ( +
+ {!enabled && ( +
+ + Включить + + } + > + Уведомления выключены + +
+ )} + +
+
+ {items?.map((item) => ( +
+ {renderItem(item)} +
+ ))} +
+
+
+ ); +}; + +export { NotificationList }; diff --git a/src/containers/notifications/NotificationList/styles.module.scss b/src/containers/notifications/NotificationList/styles.module.scss new file mode 100644 index 00000000..3c391f7b --- /dev/null +++ b/src/containers/notifications/NotificationList/styles.module.scss @@ -0,0 +1,57 @@ +@import 'src/styles/variables'; + +.grid { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + z-index: 4; + height: 100%; +} + +.head { + @include row_shadow; + + width: 100%; + padding: $gap; +} + +.list { + @include row_shadow; + + overflow-y: auto; + flex: 1 1; + overflow: auto; + width: 100%; + position: relative; + + &.inactive { + opacity: 0.3; + overflow: hidden; + + &::after { + content: ' '; + inset: 0; + position: absolute; + background: linear-gradient(transparent, $content_bg_backdrop); + pointer-events: none; + touch-action: none; + } + } +} + +.items { + display: grid; + grid-auto-flow: row; +} + +.item { + @include row_shadow; + padding: $gap / 2 $gap $gap / 2 $gap / 2; + transition: background-color 0.25s; + min-width: 0; + + &:hover { + background-color: $content_bg_lighter; + } +} diff --git a/src/containers/notifications/NotificationSettings/index.tsx b/src/containers/notifications/NotificationSettings/index.tsx new file mode 100644 index 00000000..24ac8ce5 --- /dev/null +++ b/src/containers/notifications/NotificationSettings/index.tsx @@ -0,0 +1,30 @@ +import { FC } from 'react'; + +import { Padder } from '~/components/containers/Padder'; +import { NotificationSettingsForm } from '~/components/notifications/NotificationSettingsForm'; +import { useOAuth } from '~/hooks/auth/useOAuth'; +import { useNotificationSettings } from '~/hooks/notifications/useNotificationSettings'; + +interface NotificationSettingsProps {} + +const NotificationSettings: FC = () => { + const { settings, update } = useNotificationSettings(); + const { hasTelegram, showTelegramModal } = useOAuth(); + + if (!settings) { + return <>{null}; + } + + return ( + + + + ); +}; + +export { NotificationSettings }; diff --git a/src/containers/profile/ProfileAccounts/index.tsx b/src/containers/profile/ProfileAccounts/index.tsx index 994dc27d..d57e7ad1 100644 --- a/src/containers/profile/ProfileAccounts/index.tsx +++ b/src/containers/profile/ProfileAccounts/index.tsx @@ -1,30 +1,40 @@ -import React, { FC, Fragment } from 'react'; +import React, { FC, Fragment, useCallback, useMemo } from 'react'; +import { Superpower } from '~/components/boris/Superpower'; import { Group } from '~/components/containers/Group'; import { Button } from '~/components/input/Button'; import { Icon } from '~/components/input/Icon'; import { Placeholder } from '~/components/placeholders/Placeholder'; import { SOCIAL_ICONS } from '~/constants/auth/socials'; +import { Dialog } from '~/constants/modal'; import { useOAuth } from '~/hooks/auth/useOAuth'; +import { useModal } from '~/hooks/modal/useModal'; import styles from './styles.module.scss'; type ProfileAccountsProps = {}; const ProfileAccounts: FC = () => { - const { isLoading, accounts, dropAccount, openOauthWindow } = useOAuth(); + const { + isLoading, + accounts, + dropAccount, + openOauthWindow, + hasTelegram, + showTelegramModal, + } = useOAuth(); return (

- Ты можешь входить в Убежище, используя аккаунты на других сайтах вместо ввода логина и - пароля. + Ты можешь входить в Убежище, используя аккаунты на других сайтах + вместо ввода логина и пароля.

- Мы честно украдём и будем хранить твои имя, фото и адрес на этом сайте, но никому о них не - расскажем. + Мы честно украдём и будем хранить твои имя, фото и адрес на этом + сайте, но никому о них не расскажем.

@@ -42,11 +52,13 @@ const ProfileAccounts: FC = () => { {!isLoading && accounts.length > 0 && (
{!isLoading && - accounts.map(it => ( + accounts.map((it) => (
@@ -56,7 +68,11 @@ const ProfileAccounts: FC = () => {
{it.name || it.id}
- dropAccount(it.provider, it.id)} /> + dropAccount(it.provider, it.id)} + />
))} @@ -64,6 +80,19 @@ const ProfileAccounts: FC = () => { )} + + + +