1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-24 20:36:40 +07:00

Merge branch 'master' into dependabot/npm_and_yarn/loader-utils-2.0.4

This commit is contained in:
muerwre 2023-03-16 16:58:17 +06:00 committed by GitHub
commit cddab9ceaa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
128 changed files with 2823 additions and 711 deletions

View file

@ -16,16 +16,18 @@ steps:
NEXT_PUBLIC_API_HOST: https://pig.vault48.org/ NEXT_PUBLIC_API_HOST: https://pig.vault48.org/
NEXT_PUBLIC_REMOTE_CURRENT: https://pig.vault48.org/static/ NEXT_PUBLIC_REMOTE_CURRENT: https://pig.vault48.org/static/
NEXT_PUBLIC_PUBLIC_HOST: https://vault48.org/ NEXT_PUBLIC_PUBLIC_HOST: https://vault48.org/
NEXT_PUBLIC_BOT_USERNAME: vault48bot
settings: settings:
dockerfile: docker/nextjs/Dockerfile dockerfile: docker/nextjs/Dockerfile
build_args_from_env: build_args_from_env:
- NEXT_PUBLIC_API_HOST - NEXT_PUBLIC_API_HOST
- NEXT_PUBLIC_REMOTE_CURRENT - NEXT_PUBLIC_REMOTE_CURRENT
- NEXT_PUBLIC_PUBLIC_HOST - NEXT_PUBLIC_PUBLIC_HOST
- NEXT_PUBLIC_BOT_USERNAME
tag: tag:
- ${DRONE_BRANCH} - ${DRONE_BRANCH}
custom_labels: custom_labels:
- "commit=${DRONE_COMMIT_SHA}" - 'commit=${DRONE_COMMIT_SHA}'
username: username:
from_secret: global_docker_login from_secret: global_docker_login
password: password:
@ -43,16 +45,18 @@ steps:
NEXT_PUBLIC_API_HOST: https://pig.staging.vault48.org/ NEXT_PUBLIC_API_HOST: https://pig.staging.vault48.org/
NEXT_PUBLIC_REMOTE_CURRENT: https://pig.staging.vault48.org/static/ NEXT_PUBLIC_REMOTE_CURRENT: https://pig.staging.vault48.org/static/
NEXT_PUBLIC_PUBLIC_HOST: https://staging.vault48.org/ NEXT_PUBLIC_PUBLIC_HOST: https://staging.vault48.org/
NEXT_PUBLIC_BOT_USERNAME: vault48bot
settings: settings:
dockerfile: docker/nextjs/Dockerfile dockerfile: docker/nextjs/Dockerfile
build_args_from_env: build_args_from_env:
- NEXT_PUBLIC_API_HOST - NEXT_PUBLIC_API_HOST
- NEXT_PUBLIC_REMOTE_CURRENT - NEXT_PUBLIC_REMOTE_CURRENT
- NEXT_PUBLIC_PUBLIC_HOST - NEXT_PUBLIC_PUBLIC_HOST
- NEXT_PUBLIC_BOT_USERNAME
tag: tag:
- ${DRONE_BRANCH} - ${DRONE_BRANCH}
custom_labels: custom_labels:
- "commit=${DRONE_COMMIT_SHA}" - 'commit=${DRONE_COMMIT_SHA}'
username: username:
from_secret: global_docker_login from_secret: global_docker_login
password: password:

4
.husky/pre-commit Executable file
View file

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint-staged

View file

@ -7,10 +7,12 @@ COPY . .
ARG NEXT_PUBLIC_API_HOST ARG NEXT_PUBLIC_API_HOST
ARG NEXT_PUBLIC_REMOTE_CURRENT ARG NEXT_PUBLIC_REMOTE_CURRENT
ARG NEXT_PUBLIC_PUBLIC_HOST ARG NEXT_PUBLIC_PUBLIC_HOST
ARG NEXT_PUBLIC_BOT_USERNAME
ENV NEXT_PUBLIC_API_HOST $NEXT_PUBLIC_API_HOST ENV NEXT_PUBLIC_API_HOST $NEXT_PUBLIC_API_HOST
ENV NEXT_PUBLIC_REMOTE_CURRENT $NEXT_PUBLIC_REMOTE_CURRENT ENV NEXT_PUBLIC_REMOTE_CURRENT $NEXT_PUBLIC_REMOTE_CURRENT
ENV NEXT_PUBLIC_PUBLIC_HOST $NEXT_PUBLIC_PUBLIC_HOST ENV NEXT_PUBLIC_PUBLIC_HOST $NEXT_PUBLIC_PUBLIC_HOST
ENV NEXT_PUBLIC_BOT_USERNAME $NEXT_PUBLIC_BOT_USERNAME
RUN yarn next:build RUN yarn next:build

View file

@ -2,7 +2,7 @@
const withBundleAnalyzer = require('@next/bundle-analyzer')({ const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true', 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( module.exports = withBundleAnalyzer(
withTM({ withTM({
@ -22,5 +22,19 @@ module.exports = withBundleAnalyzer(
/** don't try to optimize fonts */ /** don't try to optimize fonts */
optimizeFonts: false, optimizeFonts: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.vault48.org',
pathname: '/**',
},
{
protocol: 'https',
hostname: '*.ytimg.com',
pathname: '/**',
},
],
},
}) })
); );

View file

@ -10,6 +10,7 @@
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10", "@testing-library/user-event": "^12.1.10",
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"@v9v/ts-react-telegram-login": "^1.1.1",
"autosize": "^4.0.2", "autosize": "^4.0.2",
"axios": "^0.21.2", "axios": "^0.21.2",
"body-scroll-lock": "^2.6.4", "body-scroll-lock": "^2.6.4",
@ -39,7 +40,7 @@
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-sticky-box": "^1.0.2", "react-sticky-box": "^1.0.2",
"sass": "^1.49.0", "sass": "^1.49.0",
"swiper": "^8.0.7", "swiper": "^8.4.4",
"swr": "^1.0.1", "swr": "^1.0.1",
"throttle-debounce": "^2.1.0", "throttle-debounce": "^2.1.0",
"typescript": "^4.0.5", "typescript": "^4.0.5",
@ -98,7 +99,10 @@
}, },
"lint-staged": { "lint-staged": {
"./**/*.{js,jsx,ts,tsx}": [ "./**/*.{js,jsx,ts,tsx}": [
"next lint --fix" "eslint --fix"
] ]
},
"husky": {
"pre-push": "lint-staged"
} }
} }

View file

@ -1,3 +1,5 @@
import { TelegramUser } from '@v9v/ts-react-telegram-login';
import { import {
ApiAttachSocialRequest, ApiAttachSocialRequest,
ApiAttachSocialResult, ApiAttachSocialResult,
@ -98,3 +100,6 @@ export const apiLoginWithSocial = ({
password, password,
}) })
.then(cleanResult); .then(cleanResult);
export const apiAttachTelegram = (data: TelegramUser) =>
api.post(API.USER.ATTACH_TELEGRAM, data);

View file

@ -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<NotificationSettings> =>
api
.get<ApiGetNotificationSettingsResponse>(API.NOTIFICATIONS.SETTINGS)
.then(cleanResult)
.then(notificationSettingsFromRequest);
export const apiGetNotifications = () =>
api
.get<ApiGetNotificationsResponse>(API.NOTIFICATIONS.LIST)
.then(cleanResult);
export const apiUpdateNotificationSettings = (
settings: Partial<NotificationSettings>,
) =>
api
.post<ApiUpdateNotificationSettingsResponse>(
API.NOTIFICATIONS.SETTINGS,
notificationSettingsToRequest(settings),
)
.then(cleanResult)
.then(notificationSettingsFromRequest);

View file

@ -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<ApiGetNotificationSettingsResponse>;
export interface ApiGetNotificationsResponse {
items?: NotificationItem[];
}

View file

@ -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<TelegramLoginFormProps> = ({
botName,
loading,
onSuccess,
}) => {
return (
<div className={styles.container}>
<div className={styles.text}>
{loading ? (
<LoaderCircle />
) : (
<div>
После успешной авторизации аккаунт появится в настройках вашего
профиля
</div>
)}
</div>
<div className={styles.button}>
<TelegramLoginButton
dataOnAuth={onSuccess}
botName={botName}
requestAccess
/>
</div>
</div>
);
};
export { TelegramLoginForm };

View file

@ -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;
}

View file

@ -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 classNames from 'classnames';
import { CommentForm } from '~/components/comment/CommentForm'; import { CommentForm } from '~/components/comment/CommentForm';
import { Authorized } from '~/components/containers/Authorized';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
import { AudioPlayer } from '~/components/media/AudioPlayer'; import { AudioPlayer } from '~/components/media/AudioPlayer';
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment'; import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
import { UploadType } from '~/constants/uploads'; import { UploadType } from '~/constants/uploads';
import { ImagePresets } from '~/constants/urls'; import { imagePresets } from '~/constants/urls';
import { IComment, IFile } from '~/types'; import { IComment, IFile } from '~/types';
import { formatCommentText, getPrettyDate, getURL } from '~/utils/dom'; import { formatCommentText, getPrettyDate, getURL } from '~/utils/dom';
import { append, assocPath, path, reduce } from '~/utils/ramda'; import { append, assocPath, path, reduce } from '~/utils/ramda';
import { CommentImageGrid } from '../CommentImageGrid';
import { CommentMenu } from '../CommentMenu'; import { CommentMenu } from '../CommentMenu';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
@ -28,7 +38,15 @@ interface IProps {
} }
const CommentContent: FC<IProps> = memo( const CommentContent: FC<IProps> = memo(
({ comment, canEdit, nodeId, saveComment, onDelete, onShowImageModal, prefix }) => { ({
comment,
canEdit,
nodeId,
saveComment,
onDelete,
onShowImageModal,
prefix,
}) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]); const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]);
@ -38,20 +56,36 @@ const CommentContent: FC<IProps> = memo(
() => () =>
reduce( reduce(
(group, file) => (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<UploadType, IFile[]>, {} as Record<UploadType, IFile[]>,
comment.files comment.files,
), ),
[comment] [comment],
); );
const onLockClick = useCallback(() => { const onLockClick = useCallback(() => {
onDelete(comment.id, !comment.deleted_at); onDelete(comment.id, !comment.deleted_at);
}, [comment, onDelete]); }, [comment, onDelete]);
const onImageClick = useCallback(
(file: IFile) =>
onShowImageModal(groupped.image, groupped.image.indexOf(file)),
[onShowImageModal, groupped],
);
const menu = useMemo( const menu = useMemo(
() => canEdit && <CommentMenu onDelete={onLockClick} onEdit={startEditing} />, () => (
[canEdit, startEditing, onLockClick] <div>
{canEdit && (
<Authorized>
<CommentMenu onDelete={onLockClick} onEdit={startEditing} />
</Authorized>
)}
</div>
),
[canEdit, startEditing, onLockClick],
); );
const blocks = useMemo( const blocks = useMemo(
@ -59,7 +93,7 @@ const CommentContent: FC<IProps> = memo(
!!comment.text.trim() !!comment.text.trim()
? formatCommentText(path(['user', 'username'], comment), comment.text) ? formatCommentText(path(['user', 'username'], comment), comment.text)
: [], : [],
[comment] [comment],
); );
if (isEditing) { if (isEditing) {
@ -76,6 +110,7 @@ const CommentContent: FC<IProps> = memo(
return ( return (
<div className={styles.wrap}> <div className={styles.wrap}>
{!!prefix && <div className={styles.prefix}>{prefix}</div>} {!!prefix && <div className={styles.prefix}>{prefix}</div>}
{comment.text.trim() && ( {comment.text.trim() && (
<Group className={classnames(styles.block, styles.block_text)}> <Group className={classnames(styles.block, styles.block_text)}>
{menu} {menu}
@ -84,11 +119,16 @@ const CommentContent: FC<IProps> = memo(
{blocks.map( {blocks.map(
(block, key) => (block, key) =>
COMMENT_BLOCK_RENDERERS[block.type] && COMMENT_BLOCK_RENDERERS[block.type] &&
createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key }) createElement(COMMENT_BLOCK_RENDERERS[block.type], {
block,
key,
}),
)} )}
</Group> </Group>
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div> <div className={styles.date}>
{getPrettyDate(comment.created_at)}
</div>
</Group> </Group>
)} )}
@ -96,38 +136,35 @@ const CommentContent: FC<IProps> = memo(
<div className={classnames(styles.block, styles.block_image)}> <div className={classnames(styles.block, styles.block_image)}>
{menu} {menu}
<div <CommentImageGrid files={groupped.image} onClick={onImageClick} />
className={classNames(styles.images, {
[styles.multiple]: groupped.image.length > 1,
})}
>
{groupped.image.map((file, index) => (
<div key={file.id} onClick={() => onShowImageModal(groupped.image, index)}>
<img src={getURL(file, ImagePresets['600'])} alt={file.name} />
</div>
))}
</div>
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div> <div className={styles.date}>
{getPrettyDate(comment.created_at)}
</div>
</div> </div>
)} )}
{groupped.audio && groupped.audio.length > 0 && ( {groupped.audio && groupped.audio.length > 0 && (
<Fragment> <Fragment>
{groupped.audio.map(file => ( {groupped.audio.map((file) => (
<div className={classnames(styles.block, styles.block_audio)} key={file.id}> <div
className={classnames(styles.block, styles.block_audio)}
key={file.id}
>
{menu} {menu}
<AudioPlayer file={file} /> <AudioPlayer file={file} />
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div> <div className={styles.date}>
{getPrettyDate(comment.created_at)}
</div>
</div> </div>
))} ))}
</Fragment> </Fragment>
)} )}
</div> </div>
); );
} },
); );
export { CommentContent }; export { CommentContent };

View file

@ -117,35 +117,6 @@
touch-action: none; 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 { .audios {
& > div { & > div {
height: $comment_height; height: $comment_height;

View file

@ -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<CommentImageGridProps> = ({ files, onClick }) => {
return (
<div
className={classNames(styles.images, {
[styles.multiple]: files.length > 1,
})}
>
{files.map((file) => (
<Hoverable
key={file.id}
onClick={() => onClick(file)}
className={styles.item}
icon={<Icon icon="zoom" size={30} />}
>
<img
srcSet={getFileSrcSet(file)}
src={getURL(file, imagePresets['300'])}
alt={file.name}
className={styles.image}
sizes={files.length > 1 ? singleSrcSet : multipleSrcSet}
/>
</Hoverable>
))}
</div>
);
};
export { CommentImageGrid };

View file

@ -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;
}

View file

@ -3,7 +3,7 @@ import React, { forwardRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Square } from '~/components/common/Square'; import { Square } from '~/components/common/Square';
import { ImagePresets } from '~/constants/urls'; import { imagePresets } from '~/constants/urls';
import { useColorGradientFromString } from '~/hooks/color/useColorGradientFromString'; import { useColorGradientFromString } from '~/hooks/color/useColorGradientFromString';
import { getURLFromString } from '~/utils/dom'; import { getURLFromString } from '~/utils/dom';
import { DivProps } from '~/utils/types'; import { DivProps } from '~/utils/types';
@ -14,22 +14,37 @@ interface Props extends DivProps {
url?: string; url?: string;
username?: string; username?: string;
size?: number; size?: number;
preset?: typeof ImagePresets[keyof typeof ImagePresets]; hasUpdates?: boolean;
preset?: typeof imagePresets[keyof typeof imagePresets];
} }
const Avatar = forwardRef<HTMLDivElement, Props>( const Avatar = forwardRef<HTMLDivElement, Props>(
( (
{ url, username, size, className, preset = ImagePresets.avatar, ...rest }, {
url,
username,
size,
className,
preset = imagePresets.avatar,
hasUpdates,
...rest
},
ref, ref,
) => { ) => {
return ( return (
<Square <div
{...rest} {...rest}
className={classNames(styles.container, {
[styles.has_dot]: hasUpdates,
})}
>
<Square
image={getURLFromString(url, preset) || '/images/john_doe.svg'} image={getURLFromString(url, preset) || '/images/john_doe.svg'}
className={classNames(styles.avatar, className)} className={classNames(styles.avatar, className)}
size={size} size={size}
ref={ref} ref={ref}
/> />
</div>
); );
}, },
); );

View file

@ -1,5 +1,20 @@
@import 'src/styles/variables'; @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 { .avatar {
@include outer_shadow; @include outer_shadow;
@ -12,6 +27,7 @@
background-position: center; background-position: center;
background-size: cover; background-size: cover;
cursor: pointer; cursor: pointer;
position: relative;
img { img {
object-fit: cover; object-fit: cover;

View file

@ -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<HoverableProps> = ({
children,
className,
icon,
effect = 'rise',
...rest
}) => (
<div
{...rest}
className={classNames(styles.hoverable, styles[effect], className, {
[styles.with_icon]: !!icon,
})}
>
{icon && <div className={styles.icon}>{icon}</div>}
{children}
</div>
);
export { Hoverable };

View file

@ -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;
}
}

View file

@ -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<DivProps, 'children'> {
children: (props: { loading: boolean; onLoad: () => void }) => void;
preview?: string;
}
const ImageLoadingWrapper: FC<ImageLoadingWrapperProps> = ({
className,
children,
preview,
color,
...props
}) => {
const [loading, onLoad] = useReducer((v) => false, true);
const style = useMemo<CSSProperties>(
() => ({
backgroundImage: `url('${preview}')`,
backgroundColor: color || 'var(--color-primary)',
}),
[preview, color],
);
return (
<div className={classNames(styles.wrapper, className)} {...props}>
{!!loading && !!preview && (
<div className={styles.preview}>
<div className={styles.thumbnail} style={style} />
<LoaderCircle size={32} />
</div>
)}
{children({ loading, onLoad })}
</div>
);
};
export { ImageLoadingWrapper };

View file

@ -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;
}

View file

@ -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<InlineUsernameProps> = ({ children }) => {
const backgroundColor = useColorFromString(children);
return (
<span style={{ backgroundColor }} className={styles.username}>
~{children}
</span>
);
};
export { InlineUsername };

View file

@ -0,0 +1,6 @@
.username {
font-size: 0.9em;
padding: 0 2px;
text-transform: lowercase;
border-radius: 0.2em;
}

View file

@ -29,7 +29,6 @@
top: $gap + 4px; top: $gap + 4px;
left: 50%; left: 50%;
font: $font_12_semibold; font: $font_12_semibold;
background: red;
z-index: 100; z-index: 100;
transform: translate(-50%, 0); transform: translate(-50%, 0);
padding: 2px 10px; padding: 2px 10px;

View file

@ -1,15 +1,20 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { observer } from 'mobx-react-lite';
import { useAuth } from '~/hooks/auth/useAuth'; import { useAuth } from '~/hooks/auth/useAuth';
interface IProps {} interface IProps {
// don't wait for user refetch, trust hydration
hydratedOnly?: boolean;
}
const Authorized: FC<IProps> = ({ children }) => { const Authorized: FC<IProps> = observer(({ children, hydratedOnly }) => {
const { isUser } = useAuth(); const { isUser, fetched } = useAuth();
if (!isUser) return null; if (!isUser || (!hydratedOnly && !fetched)) return null;
return <>{children}</>; return <>{children}</>;
}; });
export { Authorized }; export { Authorized };

View file

@ -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 Masonry from 'react-masonry-css';
import { useScrollEnd } from '~/hooks/dom/useScrollEnd';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
const defaultColumns = { const defaultColumns = {
@ -11,12 +13,42 @@ const defaultColumns = {
interface ColumnsProps { interface ColumnsProps {
cols?: Record<number, number>; cols?: Record<number, number>;
onScrollEnd?: () => void;
hasMore?: boolean;
} }
const Columns: FC<ColumnsProps> = ({ children, cols = defaultColumns }) => ( const Columns: FC<ColumnsProps> = ({
<Masonry className={styles.wrap} breakpointCols={cols} columnClassName={styles.column}> children,
cols = defaultColumns,
onScrollEnd,
hasMore,
}) => {
const ref = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState<Element[]>([]);
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 (
<div ref={ref}>
<Masonry
className={styles.wrap}
breakpointCols={cols}
columnClassName={styles.column}
>
{children} {children}
</Masonry> </Masonry>
</div>
); );
};
export { Columns }; export { Columns };

View file

@ -1,11 +1,12 @@
@import "src/styles/variables"; @import 'src/styles/variables';
@import "src/styles/mixins"; @import 'src/styles/mixins';
div.wrap { div.wrap {
display: flex; display: flex;
width: 100%; width: 100%;
margin-right: 0; margin-right: 0;
padding: $gap $gap * 0.5; padding: $gap $gap * 0.5;
align-items: flex-start;
@include tablet { @include tablet {
padding: 0 $gap * 0.5; padding: 0 $gap * 0.5;

View file

@ -2,7 +2,7 @@ import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { ImagePresets } from '~/constants/urls'; import { imagePresets } from '~/constants/urls';
import { IUser } from '~/types/auth'; import { IUser } from '~/types/auth';
import { getURL } from '~/utils/dom'; import { getURL } from '~/utils/dom';
@ -19,14 +19,14 @@ const CoverBackdrop: FC<IProps> = ({ cover }) => {
const onLoad = useCallback(() => setIsLoaded(true), [setIsLoaded]); const onLoad = useCallback(() => setIsLoaded(true), [setIsLoaded]);
const image = getURL(cover, ImagePresets.cover); const image = getURL(cover, imagePresets.cover);
useEffect(() => { useEffect(() => {
if (!cover || !cover.url || !ref || !ref.current) return; if (!cover || !cover.url || !ref || !ref.current) return;
ref.current.src = ''; ref.current.src = '';
setIsLoaded(false); setIsLoaded(false);
ref.current.src = getURL(cover, ImagePresets.cover); ref.current.src = getURL(cover, imagePresets.cover);
}, [cover]); }, [cover]);
if (!cover) return null; if (!cover) return null;

View file

@ -2,7 +2,7 @@ import React, { createContext, FC, useContext, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ImagePresets } from '~/constants/urls'; import { imagePresets } from '~/constants/urls';
import { IFile } from '~/types'; import { IFile } from '~/types';
import { getURL } from '~/utils/dom'; import { getURL } from '~/utils/dom';
@ -27,9 +27,11 @@ const PageCoverProvider: FC = ({ children }) => {
createPortal( createPortal(
<div <div
className={styles.wrap} className={styles.wrap}
style={{ backgroundImage: `url("${getURL(cover, ImagePresets.cover)}")` }} style={{
backgroundImage: `url("${getURL(cover, imagePresets.cover)}")`,
}}
/>, />,
document.body document.body,
)} )}
{children} {children}

View file

@ -2,7 +2,7 @@ import React, { ChangeEvent, FC, useCallback, useEffect } from 'react';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { UploadSubject, UploadTarget, UploadType } from '~/constants/uploads'; import { UploadSubject, UploadTarget, UploadType } from '~/constants/uploads';
import { ImagePresets } from '~/constants/urls'; import { imagePresets } from '~/constants/urls';
import { useUploader } from '~/hooks/data/useUploader'; import { useUploader } from '~/hooks/data/useUploader';
import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik'; import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik';
import { IEditorComponentProps } from '~/types/node'; import { IEditorComponentProps } from '~/types/node';
@ -18,10 +18,12 @@ const EditorUploadCoverButton: FC<IProps> = () => {
const { uploadFile, files, pendingImages } = useUploader( const { uploadFile, files, pendingImages } = useUploader(
UploadSubject.Editor, UploadSubject.Editor,
UploadTarget.Nodes, 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 preview = pendingImages?.[0]?.thumbnail || '';
const onDropCover = useCallback(() => { const onDropCover = useCallback(() => {
@ -31,13 +33,13 @@ const EditorUploadCoverButton: FC<IProps> = () => {
const onInputChange = useCallback( const onInputChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => { async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []) const files = Array.from(event.target.files || [])
.filter(file => getFileType(file) === UploadType.Image) .filter((file) => getFileType(file) === UploadType.Image)
.slice(0, 1); .slice(0, 1);
const result = await uploadFile(files[0]); const result = await uploadFile(files[0]);
setFieldValue('cover', result); setFieldValue('cover', result);
}, },
[uploadFile, setFieldValue] [uploadFile, setFieldValue],
); );
useEffect(() => { useEffect(() => {

View file

@ -119,7 +119,6 @@ const FlowCell: FC<Props> = ({
{image && ( {image && (
<FlowCellImage <FlowCellImage
src={image} src={image}
height={400}
className={styles.thumb} className={styles.thumb}
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
/> />

View file

@ -1,6 +1,7 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import Image from 'next/image';
import { IMGProps } from '~/utils/types'; import { IMGProps } from '~/utils/types';
@ -10,9 +11,22 @@ interface Props extends IMGProps {
height?: number; height?: number;
} }
const FlowCellImage: FC<Props> = ({ className, children, ...rest }) => ( const FlowCellImage: FC<Props> = ({
className,
children,
src,
alt,
...rest
}) => (
<div className={classNames(styles.wrapper, className)}> <div className={classNames(styles.wrapper, className)}>
<img {...rest} src={rest.src} alt="" /> <Image
{...rest}
src={src!}
alt={alt}
placeholder="empty"
layout="fill"
objectFit="cover"
/>
{children} {children}
</div> </div>
); );

View file

@ -2,14 +2,4 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative; position: relative;
img {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
object-fit: cover;
}
} }

View file

@ -1,9 +1,11 @@
import React, { FC, Fragment } from 'react'; import React, { FC, Fragment } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { observer } from 'mobx-react-lite';
import { FlowCell } from '~/components/flow/FlowCell'; import { FlowCell } from '~/components/flow/FlowCell';
import { flowDisplayToPreset, URLS } from '~/constants/urls'; import { flowDisplayToPreset, URLS } from '~/constants/urls';
import { useAuth } from '~/hooks/auth/useAuth';
import { FlowDisplay, IFlowNode, INode } from '~/types'; import { FlowDisplay, IFlowNode, INode } from '~/types';
import { IUser } from '~/types/auth'; import { IUser } from '~/types/auth';
import { getURLFromString } from '~/utils/dom'; import { getURLFromString } from '~/utils/dom';
@ -17,28 +19,38 @@ interface Props {
onChangeCellView: (id: INode['id'], flow: FlowDisplay) => void; onChangeCellView: (id: INode['id'], flow: FlowDisplay) => void;
} }
export const FlowGrid: FC<Props> = ({ user, nodes, onChangeCellView }) => { export const FlowGrid: FC<Props> = observer(
({ user, nodes, onChangeCellView }) => {
const { fetched, isUser } = useAuth();
if (!nodes) { if (!nodes) {
return null; return null;
} }
return ( return (
<Fragment> <Fragment>
{nodes.map(node => ( {nodes.map((node) => (
<div className={classNames(styles.cell, styles[node.flow.display])} key={node.id}> <div
className={classNames(styles.cell, styles[node.flow.display])}
key={node.id}
>
<FlowCell <FlowCell
id={node.id} id={node.id}
color={node.flow.dominant_color} color={node.flow.dominant_color}
to={URLS.NODE_URL(node.id)} to={URLS.NODE_URL(node.id)}
image={getURLFromString(node.thumbnail, flowDisplayToPreset[node.flow.display])} image={getURLFromString(
node.thumbnail,
flowDisplayToPreset[node.flow.display],
)}
flow={node.flow} flow={node.flow}
text={node.description} text={node.description}
title={node.title} title={node.title}
canEdit={canEditNode(node, user)} canEdit={fetched && isUser && canEditNode(node, user)}
onChangeCellView={onChangeCellView} onChangeCellView={onChangeCellView}
/> />
</div> </div>
))} ))}
</Fragment> </Fragment>
); );
}; },
);

View file

@ -1,10 +1,9 @@
import React, { FC, MouseEventHandler } from 'react'; import { FC, MouseEventHandler } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Anchor } from '~/components/common/Anchor'; import { Anchor } from '~/components/common/Anchor';
import { Icon } from '~/components/input/Icon'; import { NodeThumbnail } from '~/components/node/NodeThumbnail';
import { NodeRelatedItem } from '~/components/node/NodeRelatedItem';
import { URLS } from '~/constants/urls'; import { URLS } from '~/constants/urls';
import { INode } from '~/types'; import { INode } from '~/types';
import { getPrettyDate } from '~/utils/dom'; import { getPrettyDate } from '~/utils/dom';
@ -31,7 +30,7 @@ const FlowRecentItem: FC<IProps> = ({ node, has_new, onClick }) => {
[styles.lab]: !node.is_promoted, [styles.lab]: !node.is_promoted,
})} })}
> >
<NodeRelatedItem item={node} /> <NodeThumbnail item={node} />
</div> </div>
<div className={styles.info}> <div className={styles.info}>

View file

@ -7,7 +7,7 @@ import SwiperClass from 'swiper/types/swiper-class';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { LoaderCircle } from '~/components/input/LoaderCircle'; 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 { useWindowSize } from '~/hooks/dom/useWindowSize';
import { useNavigation } from '~/hooks/navigation/useNavigation'; import { useNavigation } from '~/hooks/navigation/useNavigation';
import { IFlowNode } from '~/types'; import { IFlowNode } from '~/types';
@ -29,8 +29,11 @@ const autoplay = {
}; };
const lazy = { const lazy = {
loadPrevNextAmount: 3, enabled: true,
checkInView: false, loadPrevNextAmount: 2,
loadOnTransitionStart: true,
loadPrevNext: true,
checkInView: true,
}; };
export const FlowSwiperHero: FC<Props> = ({ heroes }) => { export const FlowSwiperHero: FC<Props> = ({ heroes }) => {
@ -42,7 +45,7 @@ export const FlowSwiperHero: FC<Props> = ({ heroes }) => {
>(undefined); >(undefined);
const [currentIndex, setCurrentIndex] = useState(heroes.length); const [currentIndex, setCurrentIndex] = useState(heroes.length);
const preset = useMemo( const preset = useMemo(
() => (isTablet ? ImagePresets.cover : ImagePresets.small_hero), () => (isTablet ? imagePresets.cover : imagePresets.small_hero),
[isTablet], [isTablet],
); );
@ -130,13 +133,14 @@ export const FlowSwiperHero: FC<Props> = ({ heroes }) => {
onClick={onClick} onClick={onClick}
followFinger followFinger
shortSwipes={false} shortSwipes={false}
watchSlidesProgress
> >
{heroes {heroes
.filter(node => node.thumbnail) .filter((node) => node.thumbnail)
.map(node => ( .map((node) => (
<SwiperSlide key={node.id}> <SwiperSlide key={node.id}>
<img <img
src={getURLFromString(node.thumbnail!, preset)} data-src={getURLFromString(node.thumbnail!, preset)}
alt="" alt=""
className={classNames(styles.preview, 'swiper-lazy')} className={classNames(styles.preview, 'swiper-lazy')}
/> />

View file

@ -3,8 +3,4 @@
.icon { .icon {
fill: $color_danger; fill: $color_danger;
stroke: none; stroke: none;
//path {
// transition: d 0.5s;
//}
} }

View file

@ -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<InputRowProps> = ({ children, input, className }) => (
<div className={classNames(styles.row, className)}>
<div>{children}</div>
{!!input && <div>{input}</div>}
</div>
);
export { InputRow };

View file

@ -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;
}

View file

@ -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<LoaderScreenProps> = ({
className,
align = 'middle',
}) => (
<div
className={classNames(styles.screen, styles[`align-${align}`], className)}
>
<LoaderCircle size={32} />
</div>
);
export { LoaderScreen };

View file

@ -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;
}
}

View file

@ -38,6 +38,10 @@
transition: transform 0.25s, color 0.25s, background-color; transition: transform 0.25s, color 0.25s, background-color;
} }
&:disabled {
opacity: 0.5;
}
&.active { &.active {
&::after { &::after {
transform: translate(24px, 0); transform: translate(24px, 0);

View file

@ -1,13 +1,16 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import Image from 'next/future/image';
import SwiperCore, { A11y, Navigation, Pagination } from 'swiper'; import SwiperCore, { A11y, Navigation, Pagination } from 'swiper';
import { ImagePreloader } from '~/components/media/ImagePreloader'; import { ImagePreloader } from '~/components/media/ImagePreloader';
import { Placeholder } from '~/components/placeholders/Placeholder'; import { Placeholder } from '~/components/placeholders/Placeholder';
import { INodeComponentProps } from '~/constants/node'; import { INodeComponentProps } from '~/constants/node';
import { imagePresets } from '~/constants/urls';
import { useGotoNode } from '~/hooks/node/useGotoNode'; import { useGotoNode } from '~/hooks/node/useGotoNode';
import { useNodeImages } from '~/hooks/node/useNodeImages'; import { useNodeImages } from '~/hooks/node/useNodeImages';
import { normalizeBrightColor } from '~/utils/color'; import { normalizeBrightColor } from '~/utils/color';
import { getURL } from '~/utils/dom';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
@ -19,7 +22,7 @@ const LabImage: FC<IProps> = ({ node, isLoading }) => {
const images = useNodeImages(node); const images = useNodeImages(node);
const onClick = useGotoNode(node.id); const onClick = useGotoNode(node.id);
if (!images?.length && !isLoading) { if (!images?.length) {
return null; return null;
} }
@ -28,9 +31,12 @@ const LabImage: FC<IProps> = ({ node, isLoading }) => {
return ( return (
<Placeholder active={isLoading} width="100%" height={400}> <Placeholder active={isLoading} width="100%" height={400}>
<div className={styles.wrapper}> <div className={styles.wrapper}>
<ImagePreloader <Image
file={file} src={getURL(file, imagePresets[600])}
width={file.metadata?.width}
height={file.metadata?.height}
onClick={onClick} onClick={onClick}
alt=""
className={styles.image} className={styles.image}
color={normalizeBrightColor(file?.metadata?.dominant_color)} color={normalizeBrightColor(file?.metadata?.dominant_color)}
/> />

View file

@ -51,6 +51,8 @@
max-height: calc(100vh - 70px - 70px); max-height: calc(100vh - 70px - 70px);
max-width: 100%; max-width: 100%;
transition: box-shadow 1s; transition: box-shadow 1s;
max-inline-size: 100%;
block-size: auto;
@include tablet { @include tablet {
padding-bottom: 0; padding-bottom: 0;

View file

@ -2,8 +2,7 @@ import { FC } from 'react';
import { Avatar } from '~/components/common/Avatar'; import { Avatar } from '~/components/common/Avatar';
import { Group } from '~/components/containers/Group'; 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 { IFile } from '~/types';
import { getURL } from '~/utils/dom'; import { getURL } from '~/utils/dom';
@ -12,15 +11,21 @@ import styles from './styles.module.scss';
interface IProps { interface IProps {
username: string; username: string;
photo?: IFile; photo?: IFile;
hasUpdates?: boolean;
onClick?: () => void; onClick?: () => void;
} }
const UserButton: FC<IProps> = ({ username, photo, onClick }) => { const UserButton: FC<IProps> = ({ username, photo, hasUpdates, onClick }) => {
return ( return (
<button className={styles.wrap} onClick={onClick}> <button className={styles.wrap} onClick={onClick}>
<Group horizontal className={styles.user_button}> <Group horizontal className={styles.user_button}>
<div className={styles.username}>{username}</div> <div className={styles.username}>{username}</div>
<Avatar url={getURL(photo, ImagePresets.avatar)} size={32} /> <Avatar
url={getURL(photo, imagePresets.avatar)}
size={32}
hasUpdates={hasUpdates}
/>
</Group> </Group>
</button> </button>
); );

View file

@ -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'; import classNames from 'classnames';
@ -6,7 +12,7 @@ import { ImageWithSSRLoad } from '~/components/common/ImageWithSSRLoad';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { LoaderCircle } from '~/components/input/LoaderCircle'; import { LoaderCircle } from '~/components/input/LoaderCircle';
import { DEFAULT_DOMINANT_COLOR } from '~/constants/node'; import { DEFAULT_DOMINANT_COLOR } from '~/constants/node';
import { ImagePresets } from '~/constants/urls'; import { imagePresets } from '~/constants/urls';
import { useResizeHandler } from '~/hooks/dom/useResizeHandler'; import { useResizeHandler } from '~/hooks/dom/useResizeHandler';
import { IFile } from '~/types'; import { IFile } from '~/types';
import { getURL } from '~/utils/dom'; import { getURL } from '~/utils/dom';
@ -24,7 +30,13 @@ interface IProps {
const DEFAULT_WIDTH = 1920; const DEFAULT_WIDTH = 1920;
const DEFAULT_HEIGHT = 1020; const DEFAULT_HEIGHT = 1020;
const ImagePreloader: FC<IProps> = ({ file, color, onLoad, onClick, className }) => { const ImagePreloader: FC<IProps> = ({
file,
color,
onLoad,
onClick,
className,
}) => {
const [maxHeight, setMaxHeight] = useState(0); const [maxHeight, setMaxHeight] = useState(0);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
@ -47,8 +59,11 @@ const ImagePreloader: FC<IProps> = ({ file, color, onLoad, onClick, className })
}, [setHasError]); }, [setHasError]);
const [width, height] = useMemo( 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); useResizeHandler(onResize);
@ -74,11 +89,18 @@ const ImagePreloader: FC<IProps> = ({ file, color, onLoad, onClick, className })
</defs> </defs>
<g filter="url(#f1)"> <g filter="url(#f1)">
<rect fill={fill} width="100%" height="100%" stroke="none" rx="8" ry="8" /> <rect
fill={fill}
width="100%"
height="100%"
stroke="none"
rx="8"
ry="8"
/>
{!hasError && ( {!hasError && (
<image <image
xlinkHref={getURL(file, ImagePresets['300'])} xlinkHref={getURL(file, imagePresets['300'])}
width="100%" width="100%"
height="100%" height="100%"
onLoad={onLoad} onLoad={onLoad}
@ -88,8 +110,12 @@ const ImagePreloader: FC<IProps> = ({ file, color, onLoad, onClick, className })
</svg> </svg>
<ImageWithSSRLoad <ImageWithSSRLoad
className={classNames(styles.image, { [styles.is_loaded]: loaded }, className)} className={classNames(
src={getURL(file, ImagePresets['1600'])} styles.image,
{ [styles.is_loaded]: loaded },
className,
)}
src={getURL(file, imagePresets['1600'])}
alt="" alt=""
key={file.id} key={file.id}
onLoad={onImageLoad} onLoad={onImageLoad}
@ -98,7 +124,9 @@ const ImagePreloader: FC<IProps> = ({ file, color, onLoad, onClick, className })
onError={onError} onError={onError}
/> />
{!loaded && !hasError && <LoaderCircle className={styles.icon} size={64} />} {!loaded && !hasError && (
<LoaderCircle className={styles.icon} size={64} />
)}
{hasError && ( {hasError && (
<div className={styles.error}> <div className={styles.error}>

View file

@ -14,6 +14,7 @@ interface HorizontalMenuItemProps {
icon?: string; icon?: string;
color?: 'green' | 'orange' | 'yellow'; color?: 'green' | 'orange' | 'yellow';
active?: boolean; active?: boolean;
stretchy?: boolean;
onClick?: () => void; onClick?: () => void;
} }
@ -31,6 +32,7 @@ HorizontalMenu.Item = ({
children, children,
isLoading, isLoading,
active, active,
stretchy,
onClick, onClick,
}: PropsWithChildren<HorizontalMenuItemProps>) => { }: PropsWithChildren<HorizontalMenuItemProps>) => {
if (isLoading) { if (isLoading) {
@ -44,7 +46,11 @@ HorizontalMenu.Item = ({
return ( return (
<div <div
className={classNames(styles.item, { [styles.active]: active }, styles[color])} className={classNames(
styles.item,
{ [styles.active]: active, [styles.stretchy]: stretchy },
styles[color],
)}
onClick={onClick} onClick={onClick}
> >
{!!icon && <Icon icon={icon} size={24} />} {!!icon && <Icon icon={icon} size={24} />}

View file

@ -57,6 +57,11 @@
background: $warning_gradient; background: $warning_gradient;
} }
} }
&.stretchy {
flex: 1;
justify-content: center;
}
} }
.text { .text {

View file

@ -4,7 +4,7 @@ import classNames from 'classnames';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
interface SeparatedMenuProps { export interface SeparatedMenuProps {
className?: string; className?: string;
} }
@ -14,7 +14,7 @@ const SeparatedMenu: FC<SeparatedMenuProps> = ({ children, className }) => {
return []; return [];
} }
return (Array.isArray(children) ? children : [children]).filter(it => it); return (Array.isArray(children) ? children : [children]).filter((it) => it);
}, [children]); }, [children]);
return ( return (

View file

@ -1,8 +1,8 @@
import React, { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Card } from '~/components/containers/Card'; import { Anchor } from '~/components/common/Anchor';
import { DivProps, LinkProps } from '~/utils/types'; import { DivProps, LinkProps } from '~/utils/types';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
@ -11,7 +11,9 @@ interface VerticalMenuProps extends DivProps {
appearance?: 'inset' | 'flat' | 'default'; appearance?: 'inset' | 'flat' | 'default';
} }
interface VerticalMenuItemProps extends Omit<LinkProps, 'href'> {} interface VerticalMenuItemProps extends Omit<LinkProps, 'href'> {
hasUpdates?: boolean;
}
function VerticalMenu({ function VerticalMenu({
children, children,
@ -28,8 +30,13 @@ function VerticalMenu({
); );
} }
VerticalMenu.Item = ({ ...props }: VerticalMenuItemProps) => ( VerticalMenu.Item = ({ hasUpdates, ...props }: VerticalMenuItemProps) => (
<a {...props} className={classNames(styles.item, props.className)} /> <a
{...props}
className={classNames(styles.item, props.className, {
[styles.has_dot]: hasUpdates,
})}
/>
); );
export { VerticalMenu }; export { VerticalMenu };

View file

@ -33,6 +33,19 @@ a.item {
cursor: pointer; cursor: pointer;
background-color: transparent; background-color: transparent;
transition: background-color 0.25s; 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 { &:hover {
background-color: $content_bg_success; background-color: $content_bg_success;

View file

@ -1,7 +1,7 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { INodeComponentProps } from '~/constants/node'; import { INodeComponentProps } from '~/constants/node';
import { ImagePresets } from '~/constants/urls'; import { imagePresets } from '~/constants/urls';
import { useNodeImages } from '~/hooks/node/useNodeImages'; import { useNodeImages } from '~/hooks/node/useNodeImages';
import { getURL } from '~/utils/dom'; import { getURL } from '~/utils/dom';
import { path } from '~/utils/ramda'; import { path } from '~/utils/ramda';
@ -19,7 +19,12 @@ const NodeAudioImageBlock: FC<IProps> = ({ node }) => {
<div className={styles.wrap}> <div className={styles.wrap}>
<div <div
className={styles.slide} className={styles.slide}
style={{ backgroundImage: `url("${getURL(path([0], images), ImagePresets.small_hero)}")` }} style={{
backgroundImage: `url("${getURL(
path([0], images),
imagePresets.small_hero,
)}")`,
}}
/> />
</div> </div>
); );

View file

@ -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 { 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 { Swiper, SwiperSlide } from 'swiper/react';
import SwiperClass from 'swiper/types/swiper-class'; 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 { INodeComponentProps } from '~/constants/node';
import { imagePresets } from '~/constants/urls';
import { useModal } from '~/hooks/modal/useModal'; import { useModal } from '~/hooks/modal/useModal';
import { useImageModal } from '~/hooks/navigation/useImageModal'; import { useImageModal } from '~/hooks/navigation/useImageModal';
import { useNodeImages } from '~/hooks/node/useNodeImages'; import { useNodeImages } from '~/hooks/node/useNodeImages';
import { normalizeBrightColor } from '~/utils/color'; import { normalizeBrightColor } from '~/utils/color';
import { getURL } from '~/utils/dom';
import { getFileSrcSet } from '~/utils/srcset';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
SwiperCore.use([Navigation, Pagination, Keyboard]); SwiperCore.use([Navigation, Pagination, Keyboard, Lazy]);
interface IProps extends INodeComponentProps {} interface IProps extends INodeComponentProps {}
@ -26,8 +36,18 @@ const breakpoints: SwiperOptions['breakpoints'] = {
const pagination = { type: 'fraction' as const }; const pagination = { type: 'fraction' as const };
const lazy = {
enabled: true,
loadPrevNextAmount: 1,
loadOnTransitionStart: true,
loadPrevNext: true,
checkInView: true,
};
const NodeImageSwiperBlock: FC<IProps> = observer(({ node }) => { const NodeImageSwiperBlock: FC<IProps> = observer(({ node }) => {
const [controlledSwiper, setControlledSwiper] = useState<SwiperClass | undefined>(undefined); const [controlledSwiper, setControlledSwiper] = useState<
SwiperClass | undefined
>(undefined);
const showPhotoSwiper = useImageModal(); const showPhotoSwiper = useImageModal();
const { isOpened: isModalActive } = useModal(); const { isOpened: isModalActive } = useModal();
@ -38,7 +58,7 @@ const NodeImageSwiperBlock: FC<IProps> = observer(({ node }) => {
enabled: !isModalActive, enabled: !isModalActive,
onlyInViewport: true, onlyInViewport: true,
}), }),
[isModalActive] [isModalActive],
); );
const updateSwiper = useCallback(() => { const updateSwiper = useCallback(() => {
@ -53,14 +73,17 @@ const NodeImageSwiperBlock: FC<IProps> = observer(({ node }) => {
const onOpenPhotoSwipe = useCallback( const onOpenPhotoSwipe = useCallback(
(index: number) => { (index: number) => {
if (index !== controlledSwiper?.activeIndex && controlledSwiper?.slideTo) { if (
index !== controlledSwiper?.activeIndex &&
controlledSwiper?.slideTo
) {
controlledSwiper.slideTo(index, 300); controlledSwiper.slideTo(index, 300);
return; return;
} }
showPhotoSwiper(images, index); showPhotoSwiper(images, index);
}, },
[images, controlledSwiper, showPhotoSwiper] [images, controlledSwiper, showPhotoSwiper],
); );
useEffect(() => { useEffect(() => {
@ -80,18 +103,6 @@ const NodeImageSwiperBlock: FC<IProps> = observer(({ node }) => {
return null; return null;
} }
if (images.length === 1) {
return (
<div className={styles.single}>
<ImagePreloader
file={images[0]}
onClick={() => onOpenPhotoSwipe(0)}
className={styles.image}
/>
</div>
);
}
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<Swiper <Swiper
@ -113,16 +124,31 @@ const NodeImageSwiperBlock: FC<IProps> = observer(({ node }) => {
autoHeight autoHeight
zoom zoom
navigation navigation
watchSlidesProgress
lazy={lazy}
> >
{images.map((file, i) => ( {images.map((file, index) => (
<SwiperSlide className={styles.slide} key={file.id}> <SwiperSlide className={styles.slide} key={file.id}>
<ImagePreloader <ImageLoadingWrapper
file={file} preview={getURL(file, imagePresets['300'])}
onLoad={updateSwiper} color={file.metadata?.dominant_color}
onClick={() => onOpenPhotoSwipe(i)} >
className={styles.image} {({ loading, onLoad }) => (
<img
data-srcset={getFileSrcSet(file)}
width={file.metadata?.width}
height={file.metadata?.height}
onLoad={onLoad}
onClick={() => onOpenPhotoSwipe(index)}
className={classNames(styles.image, 'swiper-lazy', {
[styles.loading]: loading,
})}
color={normalizeBrightColor(file?.metadata?.dominant_color)} color={normalizeBrightColor(file?.metadata?.dominant_color)}
alt=""
sizes="(max-width: 560px) 100vw, 50vh"
/> />
)}
</ImageLoadingWrapper>
</SwiperSlide> </SwiperSlide>
))} ))}
</Swiper> </Swiper>

View file

@ -95,12 +95,12 @@
width: auto; width: auto;
max-width: 100vw; max-width: 100vw;
opacity: 1; opacity: 1;
//transform: translate(0, 10px);
transform: scale(0.99); transform: scale(0.99);
filter: brightness(50%) saturate(0.5); filter: brightness(50%) saturate(0.5);
transition: opacity 0.5s, filter 0.5s, transform 0.5s; transition: opacity 0.5s, filter 0.5s, transform 0.5s;
padding-bottom: $gap * 1.5; padding-bottom: $gap * 1.5;
padding-top: $gap; padding-top: $gap;
position: relative;
&:global(.swiper-slide-active) { &:global(.swiper-slide-active) {
opacity: 1; opacity: 1;
@ -117,12 +117,16 @@
} }
.image { .image {
max-height: calc(100vh - 70px - 70px); max-inline-size: calc(100vh - 150px);
max-width: 100%; writing-mode: vertical-rl;
block-size: auto;
border-radius: $radius; border-radius: $radius;
transition: box-shadow 1s; transition: box-shadow 1s;
box-shadow: transparentize(black, 0.7) 0 3px 5px; box-shadow: transparentize(black, 0.7) 0 3px 5px;
&.loading {
opacity: 0; opacity: 0;
}
:global(.swiper-slide-active) & { :global(.swiper-slide-active) & {
box-shadow: transparentize(black, 0.9) 0 10px 5px 4px, box-shadow: transparentize(black, 0.9) 0 10px 5px 4px,
@ -134,7 +138,9 @@
max-height: 100vh; max-height: 100vh;
border-radius: 0; border-radius: 0;
} }
}
.loader { @media (orientation: portrait) {
max-inline-size: 100vw;
writing-mode: horizontal-tb;
}
} }

View file

@ -9,20 +9,25 @@ import { t } from '~/utils/trans';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
interface IProps { interface IProps {
is_loading?: boolean; loading?: boolean;
count?: number; count?: number;
} }
const NodeNoComments: FC<IProps> = ({ is_loading = false, count = 3 }) => { const NodeNoComments: FC<IProps> = ({ loading = false, count = 3 }) => {
const items = useMemo( const items = useMemo(
() => [...new Array(count)].map((_, i) => <div className={styles.card} key={i} />), () =>
[count] [...new Array(count)].map((_, i) => (
<div className={styles.card} key={i} />
)),
[count],
); );
return ( return (
<Group className={classNames(styles.wrap, { is_loading })}> <Group className={classNames(styles.wrap, { [styles.loading]: loading })}>
{items} {items}
{!is_loading && <div className={styles.nothing}>{t(ERRORS.NO_COMMENTS)}</div>} {!loading && (
<div className={styles.nothing}>{t(ERRORS.NO_COMMENTS)}</div>
)}
</Group> </Group>
); );
}; };

View file

@ -1,5 +1,14 @@
@import 'src/styles/variables'; @import 'src/styles/variables';
@keyframes fade {
from {
opacity: 1;
}
to {
opacity: 0.3;
}
}
.wrap { .wrap {
user-select: none; user-select: none;
overflow: hidden; overflow: hidden;
@ -17,7 +26,7 @@
bottom: 0; bottom: 0;
} }
&:global(.is_loading) { &.loading {
opacity: 1; opacity: 1;
.card { .card {

View file

@ -1,13 +1,13 @@
import React, { FC, ReactElement } from 'react'; import React, { FC, ReactElement } from 'react';
import { Hoverable } from '~/components/common/Hoverable';
import { SubTitle } from '~/components/common/SubTitle'; import { SubTitle } from '~/components/common/SubTitle';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
import { NodeRelatedItem } from '~/components/node/NodeRelatedItem'; import { NodeThumbnail } from '~/components/node/NodeThumbnail';
import { INode } from '~/types'; import { INode } from '~/types';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
interface IProps { interface IProps {
title: ReactElement | string; title: ReactElement | string;
items: Partial<INode>[]; items: Partial<INode>[];
@ -19,8 +19,10 @@ const NodeRelated: FC<IProps> = ({ title, items }) => {
<SubTitle className={styles.title}>{title}</SubTitle> <SubTitle className={styles.title}>{title}</SubTitle>
<div className={styles.grid}> <div className={styles.grid}>
{items.map(item => ( {items.map((item) => (
<NodeRelatedItem item={item} key={item.id} /> <Hoverable key={item.id} className={styles.item}>
<NodeThumbnail item={item} />
</Hoverable>
))} ))}
</div> </div>
</Group> </Group>

View file

@ -3,7 +3,7 @@ import React, { FC, memo } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Group } from '~/components/containers/Group'; 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 { Placeholder } from '~/components/placeholders/Placeholder';
import { range } from '~/utils/ramda'; import { range } from '~/utils/ramda';
@ -23,7 +23,7 @@ const NodeRelatedPlaceholder: FC<IProps> = memo(() => {
</div> </div>
<div className={styles.grid}> <div className={styles.grid}>
{range(0, 6).map(el => ( {range(0, 6).map((el) => (
<div className={cell_style.item} key={el} /> <div className={cell_style.item} key={el} />
))} ))}
</div> </div>

View file

@ -1,5 +1,14 @@
@import 'src/styles/variables'; @import 'src/styles/variables';
@keyframes fade {
from {
opacity: 1;
}
to {
opacity: 0.2;
}
}
.wrap { .wrap {
border-radius: $panel_radius; border-radius: $panel_radius;
padding: $gap 0; padding: $gap 0;
@ -39,6 +48,11 @@
.grid { .grid {
div { div {
background: $placeholder_bg; background: $placeholder_bg;
animation: fade 0.5s infinite alternate;
} }
} }
} }
.item {
border-radius: $radius;
}

View file

@ -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 classNames from 'classnames';
import { ImageWithSSRLoad } from '~/components/common/ImageWithSSRLoad'; import { ImageWithSSRLoad } from '~/components/common/ImageWithSSRLoad';
import { Square } from '~/components/common/Square'; import { Square } from '~/components/common/Square';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { ImagePresets } from '~/constants/urls'; import { imagePresets } from '~/constants/urls';
import { useColorGradientFromString } from '~/hooks/color/useColorGradientFromString'; import { useColorGradientFromString } from '~/hooks/color/useColorGradientFromString';
import { useGotoNode } from '~/hooks/node/useGotoNode'; import { useGotoNode } from '~/hooks/node/useGotoNode';
import { INode } from '~/types';
import { getURL, getURLFromString } from '~/utils/dom'; import { getURL, getURLFromString } from '~/utils/dom';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
type IProps = { type NodeThumbnailProps = {
item: Partial<INode>; item: {
thumbnail?: string;
title?: string;
is_promoted?: boolean;
id?: number;
};
}; };
type CellSize = 'small' | 'medium' | 'large'; type CellSize = 'small' | 'medium' | 'large';
@ -33,7 +37,7 @@ const getTitleLetters = (title?: string): string => {
: words[0].substr(0, 2).toUpperCase(); : words[0].substr(0, 2).toUpperCase();
}; };
const NodeRelatedItem: FC<IProps> = memo(({ item }) => { const NodeThumbnail: FC<NodeThumbnailProps> = memo(({ item }) => {
const onClick = useGotoNode(item.id); const onClick = useGotoNode(item.id);
const [is_loaded, setIsLoaded] = useState(false); const [is_loaded, setIsLoaded] = useState(false);
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
@ -42,7 +46,7 @@ const NodeRelatedItem: FC<IProps> = memo(({ item }) => {
const thumb = useMemo( const thumb = useMemo(
() => () =>
item.thumbnail item.thumbnail
? getURL({ url: item.thumbnail }, ImagePresets.avatar) ? getURL({ url: item.thumbnail }, imagePresets.avatar)
: '', : '',
[item], [item],
); );
@ -68,7 +72,7 @@ const NodeRelatedItem: FC<IProps> = memo(({ item }) => {
}, [width]); }, [width]);
const image = useMemo( const image = useMemo(
() => getURL({ url: item.thumbnail }, ImagePresets.avatar), () => getURL({ url: item.thumbnail }, imagePresets.avatar),
[item.thumbnail], [item.thumbnail],
); );
@ -118,4 +122,4 @@ const NodeRelatedItem: FC<IProps> = memo(({ item }) => {
); );
}); });
export { NodeRelatedItem }; export { NodeThumbnail };

View file

@ -2,6 +2,7 @@ import React, { memo, VFC } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Authorized } from '~/components/containers/Authorized';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { SeparatedMenu } from '~/components/menu/SeparatedMenu'; import { SeparatedMenu } from '~/components/menu/SeparatedMenu';
import { NodeEditMenu } from '~/components/node/NodeEditMenu'; import { NodeEditMenu } from '~/components/node/NodeEditMenu';
@ -76,6 +77,7 @@ const NodeTitle: VFC<IProps> = memo(
)} )}
</div> </div>
<Authorized>
<SeparatedMenu className={styles.buttons}> <SeparatedMenu className={styles.buttons}>
{canEdit && ( {canEdit && (
<NodeEditMenu <NodeEditMenu
@ -107,6 +109,7 @@ const NodeTitle: VFC<IProps> = memo(
</div> </div>
)} )}
</SeparatedMenu> </SeparatedMenu>
</Authorized>
</div> </div>
</div> </div>
); );

View file

@ -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 (
<span>
<InlineUsername>{item.user.username}</InlineUsername> пишет:
</span>
);
case NotificationType.Node:
return (
<span>
Новый пост от <InlineUsername>{item.user.username}</InlineUsername>:
</span>
);
}
};
const getContent = (item: NotificationItem) => {
switch (item.type) {
case NotificationType.Comment:
return (
<div
dangerouslySetInnerHTML={{
__html: formatText(item.text),
}}
/>
);
case NotificationType.Node:
return (
<div
dangerouslySetInnerHTML={{
__html: formatText(item.text),
}}
/>
);
}
};
const getIcon = (item: NotificationItem) => {
return <Square image={getURLFromString(item.thumbnail, 'avatar')} />;
};
const NotificationBadge: FC<NotificationBadgeProps> = ({ item }) => (
<Anchor href={item.url} className={styles.link}>
<div className={styles.message}>
<div className={styles.icon}>{getIcon(item)}</div>
<div>
<b className={styles.title}>{getTitle(item)}</b>
<div className={styles.text}>{getContent(item)}</div>
<div className={styles.time}>{getPrettyDate(item.created_at)}</div>
</div>
</div>
</Anchor>
);
export { NotificationBadge };

View file

@ -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;
}

View file

@ -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<IProps> = ({ notifications, onClick }) => {
const placeholder = useRandomPhrase('NOTHING_HERE');
return (
<div className={styles.wrap}>
<div className={styles.list}>
{notifications.length === 0 && (
<div className={styles.placeholder}>
<Icon icon="bell_ring" />
<div>{placeholder}</div>
</div>
)}
{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,
})
)}
</div>
</div>
);
};
export { NotificationBubble };

View file

@ -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%);
}
}

View file

@ -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<NotificationCommentProps> = ({ item }) => (
<Anchor href={item.url} className={styles.link}>
<div className={styles.message}>
<div className={styles.icon}>
<Avatar
size={32}
url={item.user?.photo}
username={item.user?.username}
className={styles.circle}
/>
</div>
<div className={styles.content}>
<b className={styles.title}>
<span>
<InlineUsername>{item.user.username}</InlineUsername>
</span>
<span>-</span>
<Square
className={styles.item_image}
size={16}
image={getURLFromString(item.thumbnail, 'avatar')}
/>
<div className={styles.item_title}>{item.title}</div>
</b>
<div className={styles.text}>
<div
dangerouslySetInnerHTML={{
__html: formatText(item.text),
}}
/>
</div>
</div>
</div>
</Anchor>
);
export { NotificationComment };

View file

@ -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;
}

View file

@ -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<IProps> = ({
notification,
notification: {
content: { text, from },
},
onClick,
}) => {
const onMouseDown = useCallback(() => onClick(notification), [onClick, notification]);
return (
<div className={styles.item} onMouseDown={onMouseDown}>
<div className={styles.item_head}>
<Icon icon="message" />
<div className={styles.item_title}>Сообщение от ~{from?.username}:</div>
</div>
<div className={styles.item_text}>{text}</div>
</div>
);
};
export { NotificationMessage };

View file

@ -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<NotificationNodeProps> = ({ item }) => {
const thumbnail = useMemo(
() => ({
title: item.title,
thumbnail: item.thumbnail,
is_promoted: true,
}),
[item],
);
return (
<div className={styles.card}>
<div className={styles.image}>
<NodeThumbnail item={thumbnail} />
</div>
<div className={styles.text}>
<div className={styles.title}>{item.title || '...'}</div>
<div className={styles.user}>
~{item.user.username}, {getPrettyDate(item.created_at)}
</div>
</div>
</div>
);
};
export { NotificationNode };

View file

@ -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;
}

View file

@ -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<NotificationSettings>) => Promise<unknown>;
telegramConnected: boolean;
onConnectTelegram: () => void;
}
const NotificationSettingsForm: FC<NotificationSettingsFormProps> = ({
value,
onSubmit,
telegramConnected,
onConnectTelegram,
}) => {
const { setFieldValue, values } = useNotificationSettingsForm(
value,
onSubmit,
);
const toggle = useCallback(
(key: keyof NotificationSettings, disabled?: boolean) => (
<Toggle
handler={(val) => setFieldValue(key, val)}
value={values[key]}
disabled={disabled}
/>
),
[setFieldValue, values],
);
const telegramInput = telegramConnected ? (
toggle('sendTelegram', !values.enabled)
) : (
<Button size="micro" onClick={onConnectTelegram}>
Подключить
</Button>
);
return (
<Group>
<Card>
<InputRow input={toggle('enabled')}>Получать уведомления</InputRow>
</Card>
<div />
<Zone title="Типы уведомлений">
<Group>
<InputRow input={toggle('flow', !values.enabled)}>
Новые посты
</InputRow>
<InputRow input={toggle('comments', !values.enabled)}>
Комментарии
</InputRow>
</Group>
</Zone>
<div />
<Zone title="Способы доставки">
<Group>
<InputRow input={toggle('showIndicator', !values.enabled)}>
На иконке профиля
</InputRow>
<InputRow input={telegramInput}>Телеграм</InputRow>
</Group>
</Zone>
</Group>
);
};
export { NotificationSettingsForm };

View file

@ -0,0 +1,7 @@
@import 'src/styles/variables';
.grid {
display: grid;
grid-auto-flow: row;
row-gap: $gap;
}

View file

@ -2,7 +2,7 @@ import React, { ChangeEvent, FC, useCallback } from 'react';
import { Avatar } from '~/components/common/Avatar'; import { Avatar } from '~/components/common/Avatar';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
import { ImagePresets } from '~/constants/urls'; import { imagePresets } from '~/constants/urls';
import { IFile } from '~/types'; import { IFile } from '~/types';
import { getURL } from '~/utils/dom'; import { getURL } from '~/utils/dom';

View file

@ -1,7 +0,0 @@
@import "src/styles/variables";
.scroller {
flex: 1;
overflow: auto;
padding: $gap;
}

View file

@ -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 (
<SidebarStackCard width={400} onBackPress={closeAllTabs}>
<div className={styles.grid}>
<Group className={styles.head} horizontal>
<HorizontalMenu className={styles.tabs}>
<HorizontalMenu.Item
active={tab === Tabs.List}
isLoading={loading}
onClick={() => setTab(Tabs.List)}
stretchy
>
Уведомления
</HorizontalMenu.Item>
<HorizontalMenu.Item
active={tab === Tabs.Settings}
isLoading={loading}
onClick={() => setTab(Tabs.Settings)}
stretchy
>
Настройки
</HorizontalMenu.Item>
</HorizontalMenu>
<Button iconLeft="right" color="link" onClick={closeAllTabs} />
</Group>
<div className={styles.list}>
{tab === Tabs.List ? <NotificationList /> : <NotificationSettings />}
</div>
</div>
</SidebarStackCard>
);
};
export { ProfileSidebarNotifications };

View file

@ -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%;
}

View file

@ -1,7 +1,9 @@
import React, { FC, useMemo } from 'react'; import React, { FC, useMemo } from 'react';
import { observer } from 'mobx-react-lite';
import { ImageUpload } from '~/components/upload/ImageUpload'; import { ImageUpload } from '~/components/upload/ImageUpload';
import { ImagePresets } from '~/constants/urls'; import { imagePresets } from '~/constants/urls';
import { UploadStatus } from '~/store/uploader/UploaderStore'; import { UploadStatus } from '~/store/uploader/UploaderStore';
import { IFile } from '~/types'; import { IFile } from '~/types';
import { getURL } from '~/utils/dom'; import { getURL } from '~/utils/dom';
@ -18,18 +20,31 @@ interface SortableImageGridProps {
className?: string; className?: string;
size?: number; size?: number;
} }
const renderItem = ({ item, onDelete }: { item: IFile; onDelete: (fileId: number) => void }) => ( const renderItem = observer(
<ImageUpload id={item.id} thumb={getURL(item, ImagePresets.cover)} onDrop={onDelete} /> ({ item, onDelete }: { item: IFile; onDelete: (fileId: number) => void }) => (
<ImageUpload
id={item.id}
thumb={getURL(item, imagePresets.cover)}
onDrop={onDelete}
/>
),
); );
const renderLocked = ({ const renderLocked = observer(
({
locked, locked,
onDelete, onDelete,
}: { }: {
locked: UploadStatus; locked: UploadStatus;
onDelete: (fileId: number) => void; onDelete: (fileId: number) => void;
}) => ( }) => (
<ImageUpload thumb={locked.thumbnail} onDrop={onDelete} progress={locked.progress} uploading /> <ImageUpload
thumb={locked.thumbnail}
onDrop={onDelete}
progress={locked.progress}
uploading
/>
),
); );
const SortableImageGrid: FC<SortableImageGridProps> = ({ const SortableImageGrid: FC<SortableImageGridProps> = ({
@ -46,8 +61,8 @@ const SortableImageGrid: FC<SortableImageGridProps> = ({
<SortableGrid <SortableGrid
items={items} items={items}
locked={locked} locked={locked}
getID={it => it.id} getID={(it) => it.id}
getLockedID={it => it.id} getLockedID={(it) => it.id}
renderItem={renderItem} renderItem={renderItem}
renderItemProps={props} renderItemProps={props}
renderLocked={renderLocked} renderLocked={renderLocked}

View file

@ -24,6 +24,7 @@ export const API = {
DROP_SOCIAL: (provider, id) => `/oauth/${provider}/${id}`, DROP_SOCIAL: (provider, id) => `/oauth/${provider}/${id}`,
ATTACH_SOCIAL: `/oauth`, ATTACH_SOCIAL: `/oauth`,
LOGIN_WITH_SOCIAL: `/oauth`, LOGIN_WITH_SOCIAL: `/oauth`,
ATTACH_TELEGRAM: '/oauth/telegram/attach',
}, },
NODES: { NODES: {
SAVE: '/nodes/', SAVE: '/nodes/',
@ -62,4 +63,8 @@ export const API = {
STATS: '/nodes/lab/stats', STATS: '/nodes/lab/stats',
UPDATES: '/nodes/lab/updates', UPDATES: '/nodes/lab/updates',
}, },
NOTIFICATIONS: {
LIST: '/notifications/',
SETTINGS: '/notifications/settings',
},
}; };

View file

@ -3,4 +3,5 @@ import { OAuthProvider } from '~/types/auth';
export const SOCIAL_ICONS: Record<OAuthProvider, string> = { export const SOCIAL_ICONS: Record<OAuthProvider, string> = {
vkontakte: 'vk', vkontakte: 'vk',
google: 'google', google: 'google',
telegram: 'telegram',
}; };

View file

@ -6,6 +6,7 @@ import { LoginSocialRegisterDialog } from '~/containers/dialogs/LoginSocialRegis
import { PhotoSwipe } from '~/containers/dialogs/PhotoSwipe'; import { PhotoSwipe } from '~/containers/dialogs/PhotoSwipe';
import { RestorePasswordDialog } from '~/containers/dialogs/RestorePasswordDialog'; import { RestorePasswordDialog } from '~/containers/dialogs/RestorePasswordDialog';
import { RestoreRequestDialog } from '~/containers/dialogs/RestoreRequestDialog'; import { RestoreRequestDialog } from '~/containers/dialogs/RestoreRequestDialog';
import { TelegramAttachDialog } from '~/containers/dialogs/TelegramAttachDialog';
import { TestDialog } from '~/containers/dialogs/TestDialog'; import { TestDialog } from '~/containers/dialogs/TestDialog';
export enum Dialog { export enum Dialog {
@ -18,6 +19,7 @@ export enum Dialog {
Photoswipe = 'Photoswipe', Photoswipe = 'Photoswipe',
CreateNode = 'CreateNode', CreateNode = 'CreateNode',
EditNode = 'EditNode', EditNode = 'EditNode',
TelegramAttach = 'TelegramAttach',
} }
export const DIALOG_CONTENT = { export const DIALOG_CONTENT = {
@ -30,4 +32,5 @@ export const DIALOG_CONTENT = {
[Dialog.Photoswipe]: PhotoSwipe, [Dialog.Photoswipe]: PhotoSwipe,
[Dialog.CreateNode]: EditorCreateDialog, [Dialog.CreateNode]: EditorCreateDialog,
[Dialog.EditNode]: EditorEditDialog, [Dialog.EditNode]: EditorEditDialog,
[Dialog.TelegramAttach]: TelegramAttachDialog,
} as const; } as const;

View file

@ -1,49 +1,61 @@
import { FlowDisplayVariant, INode } from "~/types"; import { FlowDisplayVariant, INode } from '~/types';
export const URLS = { export const URLS = {
BASE: "/", BASE: '/',
LAB: "/lab", LAB: '/lab',
BORIS: "/boris", BORIS: '/boris',
AUTH: { AUTH: {
LOGIN: "/auth/login", LOGIN: '/auth/login',
}, },
EXAMPLES: { EXAMPLES: {
EDITOR: "/examples/edit", EDITOR: '/examples/edit',
IMAGE: "/examples/image", IMAGE: '/examples/image',
}, },
ERRORS: { ERRORS: {
NOT_FOUND: "/lost", NOT_FOUND: '/lost',
BACKEND_DOWN: "/oopsie", 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}`, PROFILE_PAGE: (username: string) => `/profile/${username}`,
SETTINGS: { SETTINGS: {
BASE: "/settings", BASE: '/settings',
NOTES: "/settings/notes", NOTES: '/settings/notes',
TRASH: "/settings/trash", TRASH: '/settings/trash',
}, },
NOTES: "/notes/", NOTES: '/notes/',
NOTE: (id: number) => `/notes/${id}`, NOTE: (id: number) => `/notes/${id}`,
}; };
export const ImagePresets = { export const imagePresets = {
"1600": "1600", '1600': '1600',
"600": "600", '900': '900',
"300": "300", '1200': '1200',
cover: "cover", '600': '600',
small_hero: "small_hero", '300': '300',
avatar: "avatar", cover: 'cover',
flow_square: "flow_square", small_hero: 'small_hero',
flow_vertical: "flow_vertical", avatar: 'avatar',
flow_horizontal: "flow_horizontal", flow_square: 'flow_square',
flow_vertical: 'flow_vertical',
flow_horizontal: 'flow_horizontal',
} as const; } as const;
export type ImagePreset = typeof imagePresets[keyof typeof imagePresets];
export const imageSrcSets: Partial<Record<ImagePreset, number>> = {
[imagePresets[1600]]: 1600,
[imagePresets[900]]: 900,
[imagePresets[1200]]: 1200,
[imagePresets[600]]: 600,
[imagePresets[300]]: 300,
};
export const flowDisplayToPreset: Record< export const flowDisplayToPreset: Record<
FlowDisplayVariant, FlowDisplayVariant,
typeof ImagePresets[keyof typeof ImagePresets] typeof imagePresets[keyof typeof imagePresets]
> = { > = {
single: "flow_square", single: 'flow_square',
quadro: "flow_square", quadro: 'flow_square',
vertical: "flow_vertical", vertical: 'flow_vertical',
horizontal: "flow_horizontal", horizontal: 'flow_horizontal',
}; };

View file

@ -17,21 +17,21 @@ const BorisComments: FC<IProps> = () => {
const user = useUserContext(); const user = useUserContext();
const { isUser } = useAuth(); const { isUser } = useAuth();
const { const { isLoading, comments, onSaveComment } = useCommentContext();
isLoading,
comments,
onSaveComment,
} = useCommentContext();
const { node } = useNodeContext(); const { node } = useNodeContext();
return ( return (
<Group> <Group>
{(isUser || isSSR) && ( {(isUser || isSSR) && (
<NodeCommentFormSSR user={user} nodeId={node.id} saveComment={onSaveComment} /> <NodeCommentFormSSR
user={user}
nodeId={node.id}
saveComment={onSaveComment}
/>
)} )}
{isLoading || !comments?.length ? ( {isLoading || !comments?.length ? (
<NodeNoComments is_loading count={7} /> <NodeNoComments loading count={7} />
) : ( ) : (
<NodeComments order="ASC" /> <NodeComments order="ASC" />
)} )}

View file

@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite';
import PhotoSwipeUI_Default from 'photoswipe/dist/photoswipe-ui-default.js'; import PhotoSwipeUI_Default from 'photoswipe/dist/photoswipe-ui-default.js';
import PhotoSwipeJs from 'photoswipe/dist/photoswipe.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 { useWindowSize } from '~/hooks/dom/useWindowSize';
import { useModal } from '~/hooks/modal/useModal'; import { useModal } from '~/hooks/modal/useModal';
import { IFile } from '~/types'; import { IFile } from '~/types';
@ -25,35 +25,47 @@ const PhotoSwipe: VFC<PhotoSwipeProps> = observer(({ index, items }) => {
const { isTablet } = useWindowSize(); const { isTablet } = useWindowSize();
useEffect(() => { useEffect(() => {
new Promise(async resolve => { new Promise(async (resolve) => {
const images = await Promise.all( const images = await Promise.all(
items.map( items.map(
image => (file) =>
new Promise(resolveImage => { 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(); const img = new Image();
img.onload = () => { img.onload = () => {
resolveImage({ resolve({
src: getURL( src,
image,
isTablet ? ImagePresets[900] : ImagePresets[1600],
),
h: img.naturalHeight, h: img.naturalHeight,
w: img.naturalWidth, w: img.naturalWidth,
}); });
}; };
img.onerror = () => { img.onerror = () => {
resolveImage({}); resolve({});
}; };
img.src = getURL(image, ImagePresets[1600]); img.src = getURL(file, imagePresets[1600]);
}), }),
), ),
); );
resolve(images); resolve(images);
}).then(images => { }).then((images) => {
const ps = new PhotoSwipeJs(ref.current, PhotoSwipeUI_Default, images, { const ps = new PhotoSwipeJs(ref.current, PhotoSwipeUI_Default, images, {
index: index || 0, index: index || 0,
closeOnScroll: false, closeOnScroll: false,

View file

@ -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<TelegramAttachDialogProps> = ({
onRequestClose,
}) => {
const { attach } = useTelegramAccount();
const onAttach = useCallback(
(data: TelegramUser) => attach(data, onRequestClose),
[onRequestClose],
);
const buttons = useMemo(
() => (
<Padder>
<Button stretchy onClick={onRequestClose}>
Отмена
</Button>
</Padder>
),
[onRequestClose],
);
if (!botName) {
onRequestClose();
return null;
}
return (
<BetterScrollDialog width={300} onClose={onRequestClose} footer={buttons}>
<TelegramLoginForm botName={botName} onSuccess={onAttach} />
</BetterScrollDialog>
);
};
export { TelegramAttachDialog };

View file

@ -1,5 +1,6 @@
import { FC, memo } from 'react'; import { FC, memo } from 'react';
import { Hoverable } from '~/components/common/Hoverable';
import { Columns } from '~/components/containers/Columns'; import { Columns } from '~/components/containers/Columns';
import { InfiniteScroll } from '~/components/containers/InfiniteScroll'; import { InfiniteScroll } from '~/components/containers/InfiniteScroll';
import { LabNoResults } from '~/components/lab/LabNoResults'; import { LabNoResults } from '~/components/lab/LabNoResults';
@ -11,27 +12,27 @@ import styles from './styles.module.scss';
interface IProps {} interface IProps {}
const LabGrid: FC<IProps> = memo(() => { const LabGrid: FC<IProps> = memo(() => {
const { nodes, hasMore, loadMore, search, setSearch } = useLabContext(); const { nodes, hasMore, loadMore, search, setSearch, isLoading } =
useLabContext();
if (search && !nodes.length) { if (search && !nodes.length) {
return <LabNoResults resetSearch={() => setSearch('')} />; return <LabNoResults resetSearch={() => setSearch('')} />;
} }
return ( return (
<InfiniteScroll hasMore={hasMore} loadMore={loadMore}>
<div className={styles.wrap}> <div className={styles.wrap}>
<Columns> <Columns hasMore={hasMore && !isLoading} onScrollEnd={loadMore}>
{nodes.map((node) => ( {nodes.map((node) => (
<Hoverable key={node.node.id} effect="shine">
<LabNode <LabNode
node={node.node} node={node.node}
key={node.node.id}
lastSeen={node.last_seen} lastSeen={node.last_seen}
commentCount={node.comment_count} commentCount={node.comment_count}
/> />
</Hoverable>
))} ))}
</Columns> </Columns>
</div> </div>
</InfiniteScroll>
); );
}); });

View file

@ -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 classNames from 'classnames';
import isBefore from 'date-fns/isBefore'; import isBefore from 'date-fns/isBefore';
@ -9,17 +9,16 @@ import { Authorized } from '~/components/containers/Authorized';
import { Filler } from '~/components/containers/Filler'; import { Filler } from '~/components/containers/Filler';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
import { Logo } from '~/components/main/Logo'; import { Logo } from '~/components/main/Logo';
import { UserButton } from '~/components/main/UserButton';
import { Dialog } from '~/constants/modal'; import { Dialog } from '~/constants/modal';
import { SidebarName } from '~/constants/sidebar';
import { URLS } from '~/constants/urls'; import { URLS } from '~/constants/urls';
import { useAuth } from '~/hooks/auth/useAuth'; import { useAuth } from '~/hooks/auth/useAuth';
import { useScrollTop } from '~/hooks/dom/useScrollTop'; import { useScrollTop } from '~/hooks/dom/useScrollTop';
import { useFlow } from '~/hooks/flow/useFlow'; import { useFlow } from '~/hooks/flow/useFlow';
import { useGetLabStats } from '~/hooks/lab/useGetLabStats';
import { useModal } from '~/hooks/modal/useModal'; import { useModal } from '~/hooks/modal/useModal';
import { useUpdates } from '~/hooks/updates/useUpdates'; 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'; import styles from './styles.module.scss';
@ -28,14 +27,10 @@ export interface HeaderProps {}
const Header: FC<HeaderProps> = observer(() => { const Header: FC<HeaderProps> = observer(() => {
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const { showModal } = useModal(); const { showModal } = useModal();
const { isUser, user } = useAuth(); const { isUser, user, fetched } = useAuth();
const { hasFlowUpdates, hasLabUpdates } = useFlow(); const { hasFlowUpdates, hasLabUpdates } = useFlow();
const { borisCommentedAt } = useUpdates(); const { borisCommentedAt } = useUpdates();
const { open } = useSidebar(); const { indicatorEnabled } = useNotifications();
const openProfileSidebar = useCallback(() => {
open(SidebarName.Settings, {});
}, [open]);
const onLogin = useCallback(() => showModal(Dialog.Login, {}), [showModal]); const onLogin = useCallback(() => showModal(Dialog.Login, {}), [showModal]);
@ -44,10 +39,11 @@ const Header: FC<HeaderProps> = observer(() => {
const hasBorisUpdates = useMemo( const hasBorisUpdates = useMemo(
() => () =>
isUser && isUser &&
!indicatorEnabled &&
borisCommentedAt && borisCommentedAt &&
(!user.last_seen_boris || ((fetched && !user.last_seen_boris) ||
isBefore(new Date(user.last_seen_boris), new Date(borisCommentedAt))), 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 // Needed for SSR
@ -66,11 +62,15 @@ const Header: FC<HeaderProps> = observer(() => {
<Filler className={styles.filler} /> <Filler className={styles.filler} />
<nav className={styles.plugs}> <nav
<Authorized> className={classNames(styles.plugs, {
[styles.active]: true,
})}
>
<Authorized hydratedOnly>
<Anchor <Anchor
className={classNames(styles.item, { className={classNames(styles.item, {
[styles.has_dot]: hasFlowUpdates, [styles.has_dot]: hasFlowUpdates && !indicatorEnabled,
})} })}
href={URLS.BASE} href={URLS.BASE}
> >
@ -79,7 +79,7 @@ const Header: FC<HeaderProps> = observer(() => {
<Anchor <Anchor
className={classNames(styles.item, styles.lab, { className={classNames(styles.item, styles.lab, {
[styles.has_dot]: hasLabUpdates, [styles.has_dot]: hasLabUpdates && !indicatorEnabled,
})} })}
href={URLS.LAB} href={URLS.LAB}
> >
@ -88,7 +88,7 @@ const Header: FC<HeaderProps> = observer(() => {
<Anchor <Anchor
className={classNames(styles.item, styles.boris, { className={classNames(styles.item, styles.boris, {
[styles.has_dot]: hasBorisUpdates, [styles.has_dot]: hasBorisUpdates && !indicatorEnabled,
})} })}
href={URLS.BORIS} href={URLS.BORIS}
> >
@ -97,13 +97,7 @@ const Header: FC<HeaderProps> = observer(() => {
</Authorized> </Authorized>
</nav> </nav>
{isUser && ( {isUser && <UserButtonWithNotifications />}
<UserButton
username={user.username}
photo={user.photo}
onClick={openProfileSidebar}
/>
)}
{!isUser && ( {!isUser && (
<Button className={styles.user_button} onClick={onLogin} round> <Button className={styles.user_button} onClick={onLogin} round>

View file

@ -2,10 +2,10 @@
@keyframes appear { @keyframes appear {
from { from {
transform: translate(0, -$header_height); opacity: 0;
} }
to { to {
transform: translate(0, 0); opacity: 1;
} }
} }
@ -55,6 +55,12 @@
user-select: none; user-select: none;
text-transform: uppercase; text-transform: uppercase;
align-items: center; align-items: center;
opacity: 0;
transition: all 250ms;
&.active {
opacity: 1;
}
@include tablet { @include tablet {
flex: 1; flex: 1;

View file

@ -0,0 +1,32 @@
import { FC, useCallback } from 'react';
import { UserButton } from '~/components/main/UserButton';
import { SidebarName } from '~/constants/sidebar';
import { useAuth } from '~/hooks/auth/useAuth';
import { useNotifications } from '~/utils/providers/NotificationProvider';
import { useSidebar } from '~/utils/providers/SidebarProvider';
interface UserButtonWithNotificationsProps {}
const UserButtonWithNotifications: FC<
UserButtonWithNotificationsProps
> = () => {
const { user } = useAuth();
const { open } = useSidebar();
const { hasNew, indicatorEnabled } = useNotifications();
const openProfileSidebar = useCallback(() => {
open(SidebarName.Settings, {});
}, [open]);
return (
<UserButton
hasUpdates={hasNew && indicatorEnabled}
username={user.username}
photo={user.photo}
onClick={openProfileSidebar}
/>
);
};
export { UserButtonWithNotifications };

View file

@ -26,7 +26,11 @@ interface IProps {
const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => { const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => {
const user = useUserContext(); const user = useUserContext();
const { node, isLoading } = useNodeContext(); const { node, isLoading } = useNodeContext();
const { comments, isLoading: isLoadingComments, onSaveComment } = useCommentContext(); const {
comments,
isLoading: isLoadingComments,
onSaveComment,
} = useCommentContext();
const { related, isLoading: isLoadingRelated } = useNodeRelatedContext(); const { related, isLoading: isLoadingRelated } = useNodeRelatedContext();
const { inline } = useNodeBlocks(node, isLoading); const { inline } = useNodeBlocks(node, isLoading);
const { isUser } = useAuthProvider(); const { isUser } = useAuthProvider();
@ -43,15 +47,21 @@ const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => {
{inline && <div className={styles.inline}>{inline}</div>} {inline && <div className={styles.inline}>{inline}</div>}
<article> <article>
{isLoading || isLoadingComments || (!comments.length && !inline) ? ( {isLoading ||
<NodeNoComments is_loading={isLoadingComments || isLoading} /> isLoadingComments ||
(!comments.length && !inline) ? (
<NodeNoComments loading={isLoadingComments || isLoading} />
) : ( ) : (
<NodeComments order={commentsOrder} /> <NodeComments order={commentsOrder} />
)} )}
</article> </article>
{isUser && !isLoading && ( {isUser && !isLoading && (
<NodeCommentFormSSR nodeId={node.id} saveComment={onSaveComment} user={user} /> <NodeCommentFormSSR
nodeId={node.id}
saveComment={onSaveComment}
user={user}
/>
)} )}
</Group> </Group>
@ -66,7 +76,11 @@ const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => {
<NodeTagsBlock /> <NodeTagsBlock />
</div> </div>
<div className={styles.left_item}> <div className={styles.left_item}>
<NodeRelatedBlock isLoading={isLoadingRelated} node={node} related={related} /> <NodeRelatedBlock
isLoading={isLoadingRelated}
node={node}
related={related}
/>
</div> </div>
</Sticky> </Sticky>
</div> </div>

View file

@ -34,15 +34,21 @@ const NodeComments: FC<IProps> = memo(({ order }) => {
const groupped: ICommentGroup[] = useGrouppedComments( const groupped: ICommentGroup[] = useGrouppedComments(
comments, comments,
order, order,
lastSeenCurrent ?? undefined lastSeenCurrent ?? undefined,
); );
const more = useMemo( const more = useMemo(
() => () =>
hasMore && <div className={styles.more}> hasMore &&
<LoadMoreButton isLoading={isLoadingMore} onClick={onLoadMoreComments} /> !isLoading && (
</div>, <div className={styles.more}>
[hasMore, onLoadMoreComments, isLoadingMore] <LoadMoreButton
isLoading={isLoadingMore}
onClick={onLoadMoreComments}
/>
</div>
),
[hasMore, onLoadMoreComments, isLoadingMore, isLoading],
); );
if (!node?.id) { if (!node?.id) {
@ -53,7 +59,7 @@ const NodeComments: FC<IProps> = memo(({ order }) => {
<div className={styles.wrap}> <div className={styles.wrap}>
{order === 'DESC' && more} {order === 'DESC' && more}
{groupped.map(group => ( {groupped.map((group) => (
<Comment <Comment
nodeId={node.id!} nodeId={node.id!}
key={group.ids.join()} key={group.ids.join()}

View file

@ -0,0 +1,71 @@
import { FC, useCallback, useEffect } from 'react';
import classNames from 'classnames';
import { Button } from '~/components/input/Button';
import { InputRow } from '~/components/input/InputRow';
import { LoaderScreen } from '~/components/input/LoaderScreen';
import { NotificationComment } from '~/components/notifications/NotificationComment';
import { NotificationNode } from '~/components/notifications/NotificationNode';
import { useNotificationsList } from '~/hooks/notifications/useNotificationsList';
import { NotificationItem, NotificationType } from '~/types/notifications';
import { useNotifications } from '~/utils/providers/NotificationProvider';
import styles from './styles.module.scss';
interface NotificationListProps {}
const NotificationList: FC<NotificationListProps> = () => {
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 <NotificationComment item={item} />;
case NotificationType.Node:
return <NotificationNode item={item} />;
default:
return null;
}
}, []);
if (isLoading) {
return <LoaderScreen align="top" />;
}
return (
<div className={styles.grid}>
{!enabled && (
<div className={styles.head}>
<InputRow
input={
<Button size="small" onClick={toggleEnabled}>
Включить
</Button>
}
>
Уведомления выключены
</InputRow>
</div>
)}
<div className={classNames(styles.list, { [styles.inactive]: !enabled })}>
<div className={styles.items}>
{items?.map((item) => (
<div className={styles.item} key={item.created_at}>
{renderItem(item)}
</div>
))}
</div>
</div>
</div>
);
};
export { NotificationList };

View file

@ -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;
}
}

View file

@ -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<NotificationSettingsProps> = () => {
const { settings, update } = useNotificationSettings();
const { hasTelegram, showTelegramModal } = useOAuth();
if (!settings) {
return <>{null}</>;
}
return (
<Padder>
<NotificationSettingsForm
value={settings}
onSubmit={update}
telegramConnected={hasTelegram}
onConnectTelegram={showTelegramModal}
/>
</Padder>
);
};
export { NotificationSettings };

View file

@ -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 { Group } from '~/components/containers/Group';
import { Button } from '~/components/input/Button'; import { Button } from '~/components/input/Button';
import { Icon } from '~/components/input/Icon'; import { Icon } from '~/components/input/Icon';
import { Placeholder } from '~/components/placeholders/Placeholder'; import { Placeholder } from '~/components/placeholders/Placeholder';
import { SOCIAL_ICONS } from '~/constants/auth/socials'; import { SOCIAL_ICONS } from '~/constants/auth/socials';
import { Dialog } from '~/constants/modal';
import { useOAuth } from '~/hooks/auth/useOAuth'; import { useOAuth } from '~/hooks/auth/useOAuth';
import { useModal } from '~/hooks/modal/useModal';
import styles from './styles.module.scss'; import styles from './styles.module.scss';
type ProfileAccountsProps = {}; type ProfileAccountsProps = {};
const ProfileAccounts: FC<ProfileAccountsProps> = () => { const ProfileAccounts: FC<ProfileAccountsProps> = () => {
const { isLoading, accounts, dropAccount, openOauthWindow } = useOAuth(); const {
isLoading,
accounts,
dropAccount,
openOauthWindow,
hasTelegram,
showTelegramModal,
} = useOAuth();
return ( return (
<Group className={styles.wrap}> <Group className={styles.wrap}>
<Group className={styles.info}> <Group className={styles.info}>
<p> <p>
Ты можешь входить в Убежище, используя аккаунты на других сайтах вместо ввода логина и Ты можешь входить в Убежище, используя аккаунты на других сайтах
пароля. вместо ввода логина и пароля.
</p> </p>
<p> <p>
Мы честно украдём и будем хранить твои имя, фото и адрес на этом сайте, но никому о них не Мы честно украдём и будем хранить твои имя, фото и адрес на этом
расскажем. сайте, но никому о них не расскажем.
</p> </p>
</Group> </Group>
@ -42,11 +52,13 @@ const ProfileAccounts: FC<ProfileAccountsProps> = () => {
{!isLoading && accounts.length > 0 && ( {!isLoading && accounts.length > 0 && (
<div className={styles.list}> <div className={styles.list}>
{!isLoading && {!isLoading &&
accounts.map(it => ( accounts.map((it) => (
<div className={styles.account} key={`${it.provider}-${it.id}`}> <div className={styles.account} key={`${it.provider}-${it.id}`}>
<div <div
className={styles.account__photo} className={styles.account__photo}
style={{ backgroundImage: it.photo ? `url(${it.photo})` : 'none' }} style={{
backgroundImage: it.photo ? `url(${it.photo})` : 'none',
}}
> >
<div className={styles.account__provider}> <div className={styles.account__provider}>
<Icon icon={SOCIAL_ICONS[it.provider]} size={12} /> <Icon icon={SOCIAL_ICONS[it.provider]} size={12} />
@ -56,7 +68,11 @@ const ProfileAccounts: FC<ProfileAccountsProps> = () => {
<div className={styles.account__name}>{it.name || it.id}</div> <div className={styles.account__name}>{it.name || it.id}</div>
<div className={styles.account__drop}> <div className={styles.account__drop}>
<Icon icon="close" size={22} onClick={() => dropAccount(it.provider, it.id)} /> <Icon
icon="close"
size={22}
onClick={() => dropAccount(it.provider, it.id)}
/>
</div> </div>
</div> </div>
))} ))}
@ -64,6 +80,19 @@ const ProfileAccounts: FC<ProfileAccountsProps> = () => {
)} )}
<Group horizontal className={styles.buttons}> <Group horizontal className={styles.buttons}>
<Superpower>
<Button
size="small"
type="button"
iconLeft="telegram"
color="gray"
onClick={showTelegramModal}
disabled={hasTelegram}
>
Телеграм
</Button>
</Superpower>
<Button <Button
size="small" size="small"
type="button" type="button"

View file

@ -3,7 +3,7 @@ import React, { FC } from 'react';
import { Avatar } from '~/components/common/Avatar'; import { Avatar } from '~/components/common/Avatar';
import { Markdown } from '~/components/containers/Markdown'; import { Markdown } from '~/components/containers/Markdown';
import { Placeholder } from '~/components/placeholders/Placeholder'; import { Placeholder } from '~/components/placeholders/Placeholder';
import { ImagePresets } from '~/constants/urls'; import { imagePresets } from '~/constants/urls';
import { IUser } from '~/types/auth'; import { IUser } from '~/types/auth';
import { formatText } from '~/utils/dom'; import { formatText } from '~/utils/dom';
@ -22,7 +22,7 @@ const ProfilePageLeft: FC<IProps> = ({ username, profile, isLoading }) => {
username={username} username={username}
url={profile?.photo?.url} url={profile?.photo?.url}
className={styles.avatar} className={styles.avatar}
preset={ImagePresets['600']} preset={imagePresets['600']}
/> />
<div className={styles.region}> <div className={styles.region}>

View file

@ -2,6 +2,7 @@ import React, { useCallback, VFC } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Superpower } from '~/components/boris/Superpower';
import { Filler } from '~/components/containers/Filler'; import { Filler } from '~/components/containers/Filler';
import { Group } from '~/components/containers/Group'; import { Group } from '~/components/containers/Group';
import { Zone } from '~/components/containers/Zone'; import { Zone } from '~/components/containers/Zone';
@ -12,6 +13,7 @@ import { ProfileStats } from '~/containers/profile/ProfileStats';
import { ThemeSwitcher } from '~/containers/settings/ThemeSwitcher'; import { ThemeSwitcher } from '~/containers/settings/ThemeSwitcher';
import { useAuth } from '~/hooks/auth/useAuth'; import { useAuth } from '~/hooks/auth/useAuth';
import markdown from '~/styles/common/markdown.module.scss'; import markdown from '~/styles/common/markdown.module.scss';
import { useNotifications } from '~/utils/providers/NotificationProvider';
import { ProfileSidebarLogoutButton } from '../ProfileSidebarLogoutButton'; import { ProfileSidebarLogoutButton } from '../ProfileSidebarLogoutButton';
import { ProfileToggles } from '../ProfileToggles'; import { ProfileToggles } from '../ProfileToggles';
@ -25,6 +27,7 @@ interface ProfileSidebarMenuProps {
const ProfileSidebarMenu: VFC<ProfileSidebarMenuProps> = ({ onClose }) => { const ProfileSidebarMenu: VFC<ProfileSidebarMenuProps> = ({ onClose }) => {
const { logout } = useAuth(); const { logout } = useAuth();
const { setActiveTab } = useStackContext(); const { setActiveTab } = useStackContext();
const { hasNew } = useNotifications();
const onLogout = useCallback(() => { const onLogout = useCallback(() => {
logout(); logout();
@ -44,7 +47,16 @@ const ProfileSidebarMenu: VFC<ProfileSidebarMenuProps> = ({ onClose }) => {
Настройки Настройки
</VerticalMenu.Item> </VerticalMenu.Item>
<VerticalMenu.Item onClick={() => setActiveTab(1)}> <Superpower>
<VerticalMenu.Item
onClick={() => setActiveTab(1)}
hasUpdates={hasNew}
>
Уведомления
</VerticalMenu.Item>
</Superpower>
<VerticalMenu.Item onClick={() => setActiveTab(2)}>
Заметки Заметки
</VerticalMenu.Item> </VerticalMenu.Item>
</VerticalMenu> </VerticalMenu>

View file

@ -28,7 +28,7 @@ const ThemeSwitcher: FC<ThemeSwitcherProps> = () => {
> >
<Group> <Group>
<div className={styles.palette}> <div className={styles.palette}>
{item.colors.reverse().map((color) => ( {item.colors.map((color) => (
<div <div
key={color} key={color}
className={styles.sample} className={styles.sample}

View file

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, VFC } from 'react';
import { CoverBackdrop } from '~/components/containers/CoverBackdrop'; import { CoverBackdrop } from '~/components/containers/CoverBackdrop';
import { ProfileSidebarNotes } from '~/components/profile/ProfileSidebarNotes'; import { ProfileSidebarNotes } from '~/components/profile/ProfileSidebarNotes';
import { ProfileSidebarNotifications } from '~/components/profile/ProfileSidebarNotifications';
import { ProfileSidebarSettings } from '~/components/profile/ProfileSidebarSettings'; import { ProfileSidebarSettings } from '~/components/profile/ProfileSidebarSettings';
import { SidebarStack } from '~/components/sidebar/SidebarStack'; import { SidebarStack } from '~/components/sidebar/SidebarStack';
import { SidebarStackCard } from '~/components/sidebar/SidebarStackCard'; import { SidebarStackCard } from '~/components/sidebar/SidebarStackCard';
@ -13,7 +14,7 @@ import { useUser } from '~/hooks/auth/useUser';
import type { SidebarComponentProps } from '~/types/sidebar'; import type { SidebarComponentProps } from '~/types/sidebar';
import { isNil } from '~/utils/ramda'; import { isNil } from '~/utils/ramda';
const tabs = ['profile', 'bookmarks'] as const; const tabs = ['profile', 'notifications', 'bookmarks'] as const;
type TabName = typeof tabs[number]; type TabName = typeof tabs[number];
interface SettingsSidebarProps interface SettingsSidebarProps
@ -71,6 +72,7 @@ const SettingsSidebar: VFC<SettingsSidebarProps> = ({
<SidebarStack.Cards> <SidebarStack.Cards>
<ProfileSidebarSettings /> <ProfileSidebarSettings />
<ProfileSidebarNotifications />
<ProfileSidebarNotes /> <ProfileSidebarNotes />
</SidebarStack.Cards> </SidebarStack.Cards>
</SidebarStack> </SidebarStack>

View file

@ -16,5 +16,6 @@ export const useAuth = () => {
setToken: auth.setToken, setToken: auth.setToken,
isTester: auth.isTester, isTester: auth.isTester,
setIsTester: auth.setIsTester, setIsTester: auth.setIsTester,
fetched: auth.fetched,
}; };
}; };

View file

@ -43,8 +43,6 @@ export const useOAuth = () => {
setToken(result.token); setToken(result.token);
hideModal(); hideModal();
} catch (error) { } catch (error) {
console.log(path(['response'], error));
const needsRegister = path(['response', 'status'], error) === 428; const needsRegister = path(['response', 'status'], error) === 428;
if (needsRegister && token) { if (needsRegister && token) {
@ -97,8 +95,21 @@ export const useOAuth = () => {
); );
const accounts = useMemo(() => data || [], [data]); const accounts = useMemo(() => data || [], [data]);
const refresh = useCallback(() => mutate(), []);
const hasTelegram = useMemo(
() => accounts.some((acc) => acc.provider === 'telegram'),
[accounts],
);
const showTelegramModal = useCallback(
() => showModal(Dialog.TelegramAttach, {}),
[],
);
return { return {
hasTelegram,
showTelegramModal,
openOauthWindow, openOauthWindow,
loginWithSocial, loginWithSocial,
createSocialAccount, createSocialAccount,
@ -106,5 +117,6 @@ export const useOAuth = () => {
dropAccount, dropAccount,
accounts, accounts,
isLoading: !data && isLoading, isLoading: !data && isLoading,
refresh,
}; };
}; };

Some files were not shown because too many files have changed in this diff Show more