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:
commit
cddab9ceaa
128 changed files with 2823 additions and 711 deletions
|
@ -16,16 +16,18 @@ steps:
|
|||
NEXT_PUBLIC_API_HOST: https://pig.vault48.org/
|
||||
NEXT_PUBLIC_REMOTE_CURRENT: https://pig.vault48.org/static/
|
||||
NEXT_PUBLIC_PUBLIC_HOST: https://vault48.org/
|
||||
NEXT_PUBLIC_BOT_USERNAME: vault48bot
|
||||
settings:
|
||||
dockerfile: docker/nextjs/Dockerfile
|
||||
build_args_from_env:
|
||||
- NEXT_PUBLIC_API_HOST
|
||||
- NEXT_PUBLIC_REMOTE_CURRENT
|
||||
- NEXT_PUBLIC_PUBLIC_HOST
|
||||
- NEXT_PUBLIC_BOT_USERNAME
|
||||
tag:
|
||||
- ${DRONE_BRANCH}
|
||||
custom_labels:
|
||||
- "commit=${DRONE_COMMIT_SHA}"
|
||||
- 'commit=${DRONE_COMMIT_SHA}'
|
||||
username:
|
||||
from_secret: global_docker_login
|
||||
password:
|
||||
|
@ -43,16 +45,18 @@ steps:
|
|||
NEXT_PUBLIC_API_HOST: https://pig.staging.vault48.org/
|
||||
NEXT_PUBLIC_REMOTE_CURRENT: https://pig.staging.vault48.org/static/
|
||||
NEXT_PUBLIC_PUBLIC_HOST: https://staging.vault48.org/
|
||||
NEXT_PUBLIC_BOT_USERNAME: vault48bot
|
||||
settings:
|
||||
dockerfile: docker/nextjs/Dockerfile
|
||||
build_args_from_env:
|
||||
- NEXT_PUBLIC_API_HOST
|
||||
- NEXT_PUBLIC_REMOTE_CURRENT
|
||||
- NEXT_PUBLIC_PUBLIC_HOST
|
||||
- NEXT_PUBLIC_BOT_USERNAME
|
||||
tag:
|
||||
- ${DRONE_BRANCH}
|
||||
custom_labels:
|
||||
- "commit=${DRONE_COMMIT_SHA}"
|
||||
- 'commit=${DRONE_COMMIT_SHA}'
|
||||
username:
|
||||
from_secret: global_docker_login
|
||||
password:
|
||||
|
|
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
|
@ -7,10 +7,12 @@ COPY . .
|
|||
ARG NEXT_PUBLIC_API_HOST
|
||||
ARG NEXT_PUBLIC_REMOTE_CURRENT
|
||||
ARG NEXT_PUBLIC_PUBLIC_HOST
|
||||
ARG NEXT_PUBLIC_BOT_USERNAME
|
||||
|
||||
ENV NEXT_PUBLIC_API_HOST $NEXT_PUBLIC_API_HOST
|
||||
ENV NEXT_PUBLIC_REMOTE_CURRENT $NEXT_PUBLIC_REMOTE_CURRENT
|
||||
ENV NEXT_PUBLIC_PUBLIC_HOST $NEXT_PUBLIC_PUBLIC_HOST
|
||||
ENV NEXT_PUBLIC_BOT_USERNAME $NEXT_PUBLIC_BOT_USERNAME
|
||||
|
||||
RUN yarn next:build
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
});
|
||||
const withTM = require('next-transpile-modules')(['ramda']);
|
||||
const withTM = require('next-transpile-modules')(['ramda', '@v9v/ts-react-telegram-login']);
|
||||
|
||||
module.exports = withBundleAnalyzer(
|
||||
withTM({
|
||||
|
@ -22,5 +22,19 @@ module.exports = withBundleAnalyzer(
|
|||
|
||||
/** don't try to optimize fonts */
|
||||
optimizeFonts: false,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.vault48.org',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.ytimg.com',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@v9v/ts-react-telegram-login": "^1.1.1",
|
||||
"autosize": "^4.0.2",
|
||||
"axios": "^0.21.2",
|
||||
"body-scroll-lock": "^2.6.4",
|
||||
|
@ -39,7 +40,7 @@
|
|||
"react-router-dom": "^5.1.2",
|
||||
"react-sticky-box": "^1.0.2",
|
||||
"sass": "^1.49.0",
|
||||
"swiper": "^8.0.7",
|
||||
"swiper": "^8.4.4",
|
||||
"swr": "^1.0.1",
|
||||
"throttle-debounce": "^2.1.0",
|
||||
"typescript": "^4.0.5",
|
||||
|
@ -98,7 +99,10 @@
|
|||
},
|
||||
"lint-staged": {
|
||||
"./**/*.{js,jsx,ts,tsx}": [
|
||||
"next lint --fix"
|
||||
"eslint --fix"
|
||||
]
|
||||
},
|
||||
"husky": {
|
||||
"pre-push": "lint-staged"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { TelegramUser } from '@v9v/ts-react-telegram-login';
|
||||
|
||||
import {
|
||||
ApiAttachSocialRequest,
|
||||
ApiAttachSocialResult,
|
||||
|
@ -98,3 +100,6 @@ export const apiLoginWithSocial = ({
|
|||
password,
|
||||
})
|
||||
.then(cleanResult);
|
||||
|
||||
export const apiAttachTelegram = (data: TelegramUser) =>
|
||||
api.post(API.USER.ATTACH_TELEGRAM, data);
|
||||
|
|
35
src/api/notifications/settings.ts
Normal file
35
src/api/notifications/settings.ts
Normal 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);
|
19
src/api/notifications/types.ts
Normal file
19
src/api/notifications/types.ts
Normal 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[];
|
||||
}
|
46
src/components/auth/oauth/TelegramLoginForm/index.tsx
Normal file
46
src/components/auth/oauth/TelegramLoginForm/index.tsx
Normal 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 };
|
|
@ -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;
|
||||
}
|
|
@ -1,18 +1,28 @@
|
|||
import React, { createElement, FC, Fragment, memo, ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import React, {
|
||||
createElement,
|
||||
FC,
|
||||
Fragment,
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { CommentForm } from '~/components/comment/CommentForm';
|
||||
import { Authorized } from '~/components/containers/Authorized';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
|
||||
import { UploadType } from '~/constants/uploads';
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { IComment, IFile } from '~/types';
|
||||
import { formatCommentText, getPrettyDate, getURL } from '~/utils/dom';
|
||||
import { append, assocPath, path, reduce } from '~/utils/ramda';
|
||||
|
||||
import { CommentImageGrid } from '../CommentImageGrid';
|
||||
import { CommentMenu } from '../CommentMenu';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
@ -28,7 +38,15 @@ interface IProps {
|
|||
}
|
||||
|
||||
const CommentContent: FC<IProps> = memo(
|
||||
({ comment, canEdit, nodeId, saveComment, onDelete, onShowImageModal, prefix }) => {
|
||||
({
|
||||
comment,
|
||||
canEdit,
|
||||
nodeId,
|
||||
saveComment,
|
||||
onDelete,
|
||||
onShowImageModal,
|
||||
prefix,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]);
|
||||
|
@ -38,20 +56,36 @@ const CommentContent: FC<IProps> = memo(
|
|||
() =>
|
||||
reduce(
|
||||
(group, file) =>
|
||||
file.type ? assocPath([file.type], append(file, group[file.type]), group) : group,
|
||||
file.type
|
||||
? assocPath([file.type], append(file, group[file.type]), group)
|
||||
: group,
|
||||
{} as Record<UploadType, IFile[]>,
|
||||
comment.files
|
||||
comment.files,
|
||||
),
|
||||
[comment]
|
||||
[comment],
|
||||
);
|
||||
|
||||
const onLockClick = useCallback(() => {
|
||||
onDelete(comment.id, !comment.deleted_at);
|
||||
}, [comment, onDelete]);
|
||||
|
||||
const onImageClick = useCallback(
|
||||
(file: IFile) =>
|
||||
onShowImageModal(groupped.image, groupped.image.indexOf(file)),
|
||||
[onShowImageModal, groupped],
|
||||
);
|
||||
|
||||
const menu = useMemo(
|
||||
() => canEdit && <CommentMenu onDelete={onLockClick} onEdit={startEditing} />,
|
||||
[canEdit, startEditing, onLockClick]
|
||||
() => (
|
||||
<div>
|
||||
{canEdit && (
|
||||
<Authorized>
|
||||
<CommentMenu onDelete={onLockClick} onEdit={startEditing} />
|
||||
</Authorized>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[canEdit, startEditing, onLockClick],
|
||||
);
|
||||
|
||||
const blocks = useMemo(
|
||||
|
@ -59,7 +93,7 @@ const CommentContent: FC<IProps> = memo(
|
|||
!!comment.text.trim()
|
||||
? formatCommentText(path(['user', 'username'], comment), comment.text)
|
||||
: [],
|
||||
[comment]
|
||||
[comment],
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
|
@ -76,6 +110,7 @@ const CommentContent: FC<IProps> = memo(
|
|||
return (
|
||||
<div className={styles.wrap}>
|
||||
{!!prefix && <div className={styles.prefix}>{prefix}</div>}
|
||||
|
||||
{comment.text.trim() && (
|
||||
<Group className={classnames(styles.block, styles.block_text)}>
|
||||
{menu}
|
||||
|
@ -84,11 +119,16 @@ const CommentContent: FC<IProps> = memo(
|
|||
{blocks.map(
|
||||
(block, key) =>
|
||||
COMMENT_BLOCK_RENDERERS[block.type] &&
|
||||
createElement(COMMENT_BLOCK_RENDERERS[block.type], { block, key })
|
||||
createElement(COMMENT_BLOCK_RENDERERS[block.type], {
|
||||
block,
|
||||
key,
|
||||
}),
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||
<div className={styles.date}>
|
||||
{getPrettyDate(comment.created_at)}
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
|
@ -96,38 +136,35 @@ const CommentContent: FC<IProps> = memo(
|
|||
<div className={classnames(styles.block, styles.block_image)}>
|
||||
{menu}
|
||||
|
||||
<div
|
||||
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>
|
||||
<CommentImageGrid files={groupped.image} onClick={onImageClick} />
|
||||
|
||||
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||
<div className={styles.date}>
|
||||
{getPrettyDate(comment.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupped.audio && groupped.audio.length > 0 && (
|
||||
<Fragment>
|
||||
{groupped.audio.map(file => (
|
||||
<div className={classnames(styles.block, styles.block_audio)} key={file.id}>
|
||||
{groupped.audio.map((file) => (
|
||||
<div
|
||||
className={classnames(styles.block, styles.block_audio)}
|
||||
key={file.id}
|
||||
>
|
||||
{menu}
|
||||
|
||||
<AudioPlayer file={file} />
|
||||
|
||||
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||
<div className={styles.date}>
|
||||
{getPrettyDate(comment.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export { CommentContent };
|
||||
|
|
|
@ -117,35 +117,6 @@
|
|||
touch-action: none;
|
||||
}
|
||||
|
||||
.images {
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
max-height: 400px;
|
||||
border-radius: $radius;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&.multiple {
|
||||
img {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
// Desktop devices
|
||||
@include flexbin(25vh, $flexbin-space);
|
||||
|
||||
// Tablet devices
|
||||
@media (max-width: $flexbin-tablet-max) {
|
||||
@include flexbin($flexbin-row-height-tablet, $flexbin-space-tablet);
|
||||
}
|
||||
|
||||
// Phone devices
|
||||
@media (max-width: $flexbin-phone-max) {
|
||||
@include flexbin($flexbin-row-height-phone, $flexbin-space-phone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audios {
|
||||
& > div {
|
||||
height: $comment_height;
|
||||
|
|
49
src/components/comment/CommentImageGrid/index.tsx
Normal file
49
src/components/comment/CommentImageGrid/index.tsx
Normal 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 };
|
37
src/components/comment/CommentImageGrid/styles.module.scss
Normal file
37
src/components/comment/CommentImageGrid/styles.module.scss
Normal 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;
|
||||
}
|
|
@ -3,7 +3,7 @@ import React, { forwardRef } from 'react';
|
|||
import classNames from 'classnames';
|
||||
|
||||
import { Square } from '~/components/common/Square';
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { useColorGradientFromString } from '~/hooks/color/useColorGradientFromString';
|
||||
import { getURLFromString } from '~/utils/dom';
|
||||
import { DivProps } from '~/utils/types';
|
||||
|
@ -14,22 +14,37 @@ interface Props extends DivProps {
|
|||
url?: string;
|
||||
username?: string;
|
||||
size?: number;
|
||||
preset?: typeof ImagePresets[keyof typeof ImagePresets];
|
||||
hasUpdates?: boolean;
|
||||
preset?: typeof imagePresets[keyof typeof imagePresets];
|
||||
}
|
||||
|
||||
const Avatar = forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{ url, username, size, className, preset = ImagePresets.avatar, ...rest },
|
||||
{
|
||||
url,
|
||||
username,
|
||||
size,
|
||||
className,
|
||||
preset = imagePresets.avatar,
|
||||
hasUpdates,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<Square
|
||||
<div
|
||||
{...rest}
|
||||
image={getURLFromString(url, preset) || '/images/john_doe.svg'}
|
||||
className={classNames(styles.avatar, className)}
|
||||
size={size}
|
||||
ref={ref}
|
||||
/>
|
||||
className={classNames(styles.container, {
|
||||
[styles.has_dot]: hasUpdates,
|
||||
})}
|
||||
>
|
||||
<Square
|
||||
image={getURLFromString(url, preset) || '/images/john_doe.svg'}
|
||||
className={classNames(styles.avatar, className)}
|
||||
size={size}
|
||||
ref={ref}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,5 +1,20 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
.container {
|
||||
&.has_dot::after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 8px;
|
||||
background-color: $color_danger;
|
||||
z-index: 1;
|
||||
box-shadow: $content_bg 0 0 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@include outer_shadow;
|
||||
|
||||
|
@ -12,6 +27,7 @@
|
|||
background-position: center;
|
||||
background-size: cover;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
|
|
34
src/components/common/Hoverable/index.tsx
Normal file
34
src/components/common/Hoverable/index.tsx
Normal 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 };
|
71
src/components/common/Hoverable/styles.module.scss
Normal file
71
src/components/common/Hoverable/styles.module.scss
Normal 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;
|
||||
}
|
||||
}
|
52
src/components/common/ImageLoadingWrapper/index.tsx
Normal file
52
src/components/common/ImageLoadingWrapper/index.tsx
Normal 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 };
|
26
src/components/common/ImageLoadingWrapper/styles.module.scss
Normal file
26
src/components/common/ImageLoadingWrapper/styles.module.scss
Normal 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;
|
||||
}
|
20
src/components/common/InlineUsername/index.tsx
Normal file
20
src/components/common/InlineUsername/index.tsx
Normal 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 };
|
6
src/components/common/InlineUsername/styles.module.scss
Normal file
6
src/components/common/InlineUsername/styles.module.scss
Normal file
|
@ -0,0 +1,6 @@
|
|||
.username {
|
||||
font-size: 0.9em;
|
||||
padding: 0 2px;
|
||||
text-transform: lowercase;
|
||||
border-radius: 0.2em;
|
||||
}
|
|
@ -29,7 +29,6 @@
|
|||
top: $gap + 4px;
|
||||
left: 50%;
|
||||
font: $font_12_semibold;
|
||||
background: red;
|
||||
z-index: 100;
|
||||
transform: translate(-50%, 0);
|
||||
padding: 2px 10px;
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import { useAuth } from '~/hooks/auth/useAuth';
|
||||
|
||||
interface IProps {}
|
||||
interface IProps {
|
||||
// don't wait for user refetch, trust hydration
|
||||
hydratedOnly?: boolean;
|
||||
}
|
||||
|
||||
const Authorized: FC<IProps> = ({ children }) => {
|
||||
const { isUser } = useAuth();
|
||||
const Authorized: FC<IProps> = observer(({ children, hydratedOnly }) => {
|
||||
const { isUser, fetched } = useAuth();
|
||||
|
||||
if (!isUser) return null;
|
||||
if (!isUser || (!hydratedOnly && !fetched)) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
});
|
||||
|
||||
export { Authorized };
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import React, { FC } from 'react';
|
||||
import React, { FC, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import Masonry from 'react-masonry-css';
|
||||
|
||||
import { useScrollEnd } from '~/hooks/dom/useScrollEnd';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
const defaultColumns = {
|
||||
|
@ -11,12 +13,42 @@ const defaultColumns = {
|
|||
|
||||
interface ColumnsProps {
|
||||
cols?: Record<number, number>;
|
||||
onScrollEnd?: () => void;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
const Columns: FC<ColumnsProps> = ({ children, cols = defaultColumns }) => (
|
||||
<Masonry className={styles.wrap} breakpointCols={cols} columnClassName={styles.column}>
|
||||
{children}
|
||||
</Masonry>
|
||||
);
|
||||
const Columns: FC<ColumnsProps> = ({
|
||||
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}
|
||||
</Masonry>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Columns };
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
@import "src/styles/variables";
|
||||
@import "src/styles/mixins";
|
||||
@import 'src/styles/variables';
|
||||
@import 'src/styles/mixins';
|
||||
|
||||
div.wrap {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
padding: $gap $gap * 0.5;
|
||||
align-items: flex-start;
|
||||
|
||||
@include tablet {
|
||||
padding: 0 $gap * 0.5;
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { IUser } from '~/types/auth';
|
||||
import { getURL } from '~/utils/dom';
|
||||
|
||||
|
@ -19,14 +19,14 @@ const CoverBackdrop: FC<IProps> = ({ cover }) => {
|
|||
|
||||
const onLoad = useCallback(() => setIsLoaded(true), [setIsLoaded]);
|
||||
|
||||
const image = getURL(cover, ImagePresets.cover);
|
||||
const image = getURL(cover, imagePresets.cover);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cover || !cover.url || !ref || !ref.current) return;
|
||||
|
||||
ref.current.src = '';
|
||||
setIsLoaded(false);
|
||||
ref.current.src = getURL(cover, ImagePresets.cover);
|
||||
ref.current.src = getURL(cover, imagePresets.cover);
|
||||
}, [cover]);
|
||||
|
||||
if (!cover) return null;
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, { createContext, FC, useContext, useState } from 'react';
|
|||
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { IFile } from '~/types';
|
||||
import { getURL } from '~/utils/dom';
|
||||
|
||||
|
@ -27,9 +27,11 @@ const PageCoverProvider: FC = ({ children }) => {
|
|||
createPortal(
|
||||
<div
|
||||
className={styles.wrap}
|
||||
style={{ backgroundImage: `url("${getURL(cover, ImagePresets.cover)}")` }}
|
||||
style={{
|
||||
backgroundImage: `url("${getURL(cover, imagePresets.cover)}")`,
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, { ChangeEvent, FC, useCallback, useEffect } from 'react';
|
|||
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { UploadSubject, UploadTarget, UploadType } from '~/constants/uploads';
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { useUploader } from '~/hooks/data/useUploader';
|
||||
import { useNodeFormContext } from '~/hooks/node/useNodeFormFormik';
|
||||
import { IEditorComponentProps } from '~/types/node';
|
||||
|
@ -18,10 +18,12 @@ const EditorUploadCoverButton: FC<IProps> = () => {
|
|||
const { uploadFile, files, pendingImages } = useUploader(
|
||||
UploadSubject.Editor,
|
||||
UploadTarget.Nodes,
|
||||
values.cover ? [values.cover] : []
|
||||
values.cover ? [values.cover] : [],
|
||||
);
|
||||
|
||||
const background = values.cover ? getURL(values.cover, ImagePresets['300']) : null;
|
||||
const background = values.cover
|
||||
? getURL(values.cover, imagePresets['300'])
|
||||
: null;
|
||||
const preview = pendingImages?.[0]?.thumbnail || '';
|
||||
|
||||
const onDropCover = useCallback(() => {
|
||||
|
@ -31,13 +33,13 @@ const EditorUploadCoverButton: FC<IProps> = () => {
|
|||
const onInputChange = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || [])
|
||||
.filter(file => getFileType(file) === UploadType.Image)
|
||||
.filter((file) => getFileType(file) === UploadType.Image)
|
||||
.slice(0, 1);
|
||||
|
||||
const result = await uploadFile(files[0]);
|
||||
setFieldValue('cover', result);
|
||||
},
|
||||
[uploadFile, setFieldValue]
|
||||
[uploadFile, setFieldValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -119,7 +119,6 @@ const FlowCell: FC<Props> = ({
|
|||
{image && (
|
||||
<FlowCellImage
|
||||
src={image}
|
||||
height={400}
|
||||
className={styles.thumb}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { IMGProps } from '~/utils/types';
|
||||
|
||||
|
@ -10,9 +11,22 @@ interface Props extends IMGProps {
|
|||
height?: number;
|
||||
}
|
||||
|
||||
const FlowCellImage: FC<Props> = ({ className, children, ...rest }) => (
|
||||
const FlowCellImage: FC<Props> = ({
|
||||
className,
|
||||
children,
|
||||
src,
|
||||
alt,
|
||||
...rest
|
||||
}) => (
|
||||
<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}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -2,14 +2,4 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translate(-50%, -50%);
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import React, { FC, Fragment } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import { FlowCell } from '~/components/flow/FlowCell';
|
||||
import { flowDisplayToPreset, URLS } from '~/constants/urls';
|
||||
import { useAuth } from '~/hooks/auth/useAuth';
|
||||
import { FlowDisplay, IFlowNode, INode } from '~/types';
|
||||
import { IUser } from '~/types/auth';
|
||||
import { getURLFromString } from '~/utils/dom';
|
||||
|
@ -17,28 +19,38 @@ interface Props {
|
|||
onChangeCellView: (id: INode['id'], flow: FlowDisplay) => void;
|
||||
}
|
||||
|
||||
export const FlowGrid: FC<Props> = ({ user, nodes, onChangeCellView }) => {
|
||||
if (!nodes) {
|
||||
return null;
|
||||
}
|
||||
export const FlowGrid: FC<Props> = observer(
|
||||
({ user, nodes, onChangeCellView }) => {
|
||||
const { fetched, isUser } = useAuth();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{nodes.map(node => (
|
||||
<div className={classNames(styles.cell, styles[node.flow.display])} key={node.id}>
|
||||
<FlowCell
|
||||
id={node.id}
|
||||
color={node.flow.dominant_color}
|
||||
to={URLS.NODE_URL(node.id)}
|
||||
image={getURLFromString(node.thumbnail, flowDisplayToPreset[node.flow.display])}
|
||||
flow={node.flow}
|
||||
text={node.description}
|
||||
title={node.title}
|
||||
canEdit={canEditNode(node, user)}
|
||||
onChangeCellView={onChangeCellView}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
if (!nodes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{nodes.map((node) => (
|
||||
<div
|
||||
className={classNames(styles.cell, styles[node.flow.display])}
|
||||
key={node.id}
|
||||
>
|
||||
<FlowCell
|
||||
id={node.id}
|
||||
color={node.flow.dominant_color}
|
||||
to={URLS.NODE_URL(node.id)}
|
||||
image={getURLFromString(
|
||||
node.thumbnail,
|
||||
flowDisplayToPreset[node.flow.display],
|
||||
)}
|
||||
flow={node.flow}
|
||||
text={node.description}
|
||||
title={node.title}
|
||||
canEdit={fetched && isUser && canEditNode(node, user)}
|
||||
onChangeCellView={onChangeCellView}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import React, { FC, MouseEventHandler } from 'react';
|
||||
import { FC, MouseEventHandler } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Anchor } from '~/components/common/Anchor';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { NodeRelatedItem } from '~/components/node/NodeRelatedItem';
|
||||
import { NodeThumbnail } from '~/components/node/NodeThumbnail';
|
||||
import { URLS } from '~/constants/urls';
|
||||
import { INode } from '~/types';
|
||||
import { getPrettyDate } from '~/utils/dom';
|
||||
|
@ -31,7 +30,7 @@ const FlowRecentItem: FC<IProps> = ({ node, has_new, onClick }) => {
|
|||
[styles.lab]: !node.is_promoted,
|
||||
})}
|
||||
>
|
||||
<NodeRelatedItem item={node} />
|
||||
<NodeThumbnail item={node} />
|
||||
</div>
|
||||
|
||||
<div className={styles.info}>
|
||||
|
|
|
@ -7,7 +7,7 @@ import SwiperClass from 'swiper/types/swiper-class';
|
|||
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { LoaderCircle } from '~/components/input/LoaderCircle';
|
||||
import { ImagePresets, URLS } from '~/constants/urls';
|
||||
import { imagePresets, URLS } from '~/constants/urls';
|
||||
import { useWindowSize } from '~/hooks/dom/useWindowSize';
|
||||
import { useNavigation } from '~/hooks/navigation/useNavigation';
|
||||
import { IFlowNode } from '~/types';
|
||||
|
@ -29,8 +29,11 @@ const autoplay = {
|
|||
};
|
||||
|
||||
const lazy = {
|
||||
loadPrevNextAmount: 3,
|
||||
checkInView: false,
|
||||
enabled: true,
|
||||
loadPrevNextAmount: 2,
|
||||
loadOnTransitionStart: true,
|
||||
loadPrevNext: true,
|
||||
checkInView: true,
|
||||
};
|
||||
|
||||
export const FlowSwiperHero: FC<Props> = ({ heroes }) => {
|
||||
|
@ -42,7 +45,7 @@ export const FlowSwiperHero: FC<Props> = ({ heroes }) => {
|
|||
>(undefined);
|
||||
const [currentIndex, setCurrentIndex] = useState(heroes.length);
|
||||
const preset = useMemo(
|
||||
() => (isTablet ? ImagePresets.cover : ImagePresets.small_hero),
|
||||
() => (isTablet ? imagePresets.cover : imagePresets.small_hero),
|
||||
[isTablet],
|
||||
);
|
||||
|
||||
|
@ -130,13 +133,14 @@ export const FlowSwiperHero: FC<Props> = ({ heroes }) => {
|
|||
onClick={onClick}
|
||||
followFinger
|
||||
shortSwipes={false}
|
||||
watchSlidesProgress
|
||||
>
|
||||
{heroes
|
||||
.filter(node => node.thumbnail)
|
||||
.map(node => (
|
||||
.filter((node) => node.thumbnail)
|
||||
.map((node) => (
|
||||
<SwiperSlide key={node.id}>
|
||||
<img
|
||||
src={getURLFromString(node.thumbnail!, preset)}
|
||||
data-src={getURLFromString(node.thumbnail!, preset)}
|
||||
alt=""
|
||||
className={classNames(styles.preview, 'swiper-lazy')}
|
||||
/>
|
||||
|
|
|
@ -3,8 +3,4 @@
|
|||
.icon {
|
||||
fill: $color_danger;
|
||||
stroke: none;
|
||||
|
||||
//path {
|
||||
// transition: d 0.5s;
|
||||
//}
|
||||
}
|
||||
|
|
19
src/components/input/InputRow/index.tsx
Normal file
19
src/components/input/InputRow/index.tsx
Normal 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 };
|
9
src/components/input/InputRow/styles.module.scss
Normal file
9
src/components/input/InputRow/styles.module.scss
Normal 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;
|
||||
}
|
25
src/components/input/LoaderScreen/index.tsx
Normal file
25
src/components/input/LoaderScreen/index.tsx
Normal 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 };
|
14
src/components/input/LoaderScreen/styles.module.scss
Normal file
14
src/components/input/LoaderScreen/styles.module.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -38,6 +38,10 @@
|
|||
transition: transform 0.25s, color 0.25s, background-color;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.active {
|
||||
&::after {
|
||||
transform: translate(24px, 0);
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import Image from 'next/future/image';
|
||||
import SwiperCore, { A11y, Navigation, Pagination } from 'swiper';
|
||||
|
||||
import { ImagePreloader } from '~/components/media/ImagePreloader';
|
||||
import { Placeholder } from '~/components/placeholders/Placeholder';
|
||||
import { INodeComponentProps } from '~/constants/node';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { useGotoNode } from '~/hooks/node/useGotoNode';
|
||||
import { useNodeImages } from '~/hooks/node/useNodeImages';
|
||||
import { normalizeBrightColor } from '~/utils/color';
|
||||
import { getURL } from '~/utils/dom';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
|
@ -19,7 +22,7 @@ const LabImage: FC<IProps> = ({ node, isLoading }) => {
|
|||
const images = useNodeImages(node);
|
||||
const onClick = useGotoNode(node.id);
|
||||
|
||||
if (!images?.length && !isLoading) {
|
||||
if (!images?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -28,9 +31,12 @@ const LabImage: FC<IProps> = ({ node, isLoading }) => {
|
|||
return (
|
||||
<Placeholder active={isLoading} width="100%" height={400}>
|
||||
<div className={styles.wrapper}>
|
||||
<ImagePreloader
|
||||
file={file}
|
||||
<Image
|
||||
src={getURL(file, imagePresets[600])}
|
||||
width={file.metadata?.width}
|
||||
height={file.metadata?.height}
|
||||
onClick={onClick}
|
||||
alt=""
|
||||
className={styles.image}
|
||||
color={normalizeBrightColor(file?.metadata?.dominant_color)}
|
||||
/>
|
||||
|
|
|
@ -51,6 +51,8 @@
|
|||
max-height: calc(100vh - 70px - 70px);
|
||||
max-width: 100%;
|
||||
transition: box-shadow 1s;
|
||||
max-inline-size: 100%;
|
||||
block-size: auto;
|
||||
|
||||
@include tablet {
|
||||
padding-bottom: 0;
|
||||
|
|
|
@ -2,8 +2,7 @@ import { FC } from 'react';
|
|||
|
||||
import { Avatar } from '~/components/common/Avatar';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { IFile } from '~/types';
|
||||
import { getURL } from '~/utils/dom';
|
||||
|
||||
|
@ -12,15 +11,21 @@ import styles from './styles.module.scss';
|
|||
interface IProps {
|
||||
username: string;
|
||||
photo?: IFile;
|
||||
hasUpdates?: boolean;
|
||||
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const UserButton: FC<IProps> = ({ username, photo, onClick }) => {
|
||||
const UserButton: FC<IProps> = ({ username, photo, hasUpdates, onClick }) => {
|
||||
return (
|
||||
<button className={styles.wrap} onClick={onClick}>
|
||||
<Group horizontal className={styles.user_button}>
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
import React, { FC, MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import React, {
|
||||
FC,
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
|
@ -6,7 +12,7 @@ import { ImageWithSSRLoad } from '~/components/common/ImageWithSSRLoad';
|
|||
import { Icon } from '~/components/input/Icon';
|
||||
import { LoaderCircle } from '~/components/input/LoaderCircle';
|
||||
import { DEFAULT_DOMINANT_COLOR } from '~/constants/node';
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { useResizeHandler } from '~/hooks/dom/useResizeHandler';
|
||||
import { IFile } from '~/types';
|
||||
import { getURL } from '~/utils/dom';
|
||||
|
@ -24,7 +30,13 @@ interface IProps {
|
|||
const DEFAULT_WIDTH = 1920;
|
||||
const DEFAULT_HEIGHT = 1020;
|
||||
|
||||
const ImagePreloader: FC<IProps> = ({ file, color, onLoad, onClick, className }) => {
|
||||
const ImagePreloader: FC<IProps> = ({
|
||||
file,
|
||||
color,
|
||||
onLoad,
|
||||
onClick,
|
||||
className,
|
||||
}) => {
|
||||
const [maxHeight, setMaxHeight] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
@ -47,8 +59,11 @@ const ImagePreloader: FC<IProps> = ({ file, color, onLoad, onClick, className })
|
|||
}, [setHasError]);
|
||||
|
||||
const [width, height] = useMemo(
|
||||
() => [file?.metadata?.width || DEFAULT_WIDTH, file?.metadata?.height || DEFAULT_HEIGHT],
|
||||
[file]
|
||||
() => [
|
||||
file?.metadata?.width || DEFAULT_WIDTH,
|
||||
file?.metadata?.height || DEFAULT_HEIGHT,
|
||||
],
|
||||
[file],
|
||||
);
|
||||
|
||||
useResizeHandler(onResize);
|
||||
|
@ -74,11 +89,18 @@ const ImagePreloader: FC<IProps> = ({ file, color, onLoad, onClick, className })
|
|||
</defs>
|
||||
|
||||
<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 && (
|
||||
<image
|
||||
xlinkHref={getURL(file, ImagePresets['300'])}
|
||||
xlinkHref={getURL(file, imagePresets['300'])}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onLoad={onLoad}
|
||||
|
@ -88,8 +110,12 @@ const ImagePreloader: FC<IProps> = ({ file, color, onLoad, onClick, className })
|
|||
</svg>
|
||||
|
||||
<ImageWithSSRLoad
|
||||
className={classNames(styles.image, { [styles.is_loaded]: loaded }, className)}
|
||||
src={getURL(file, ImagePresets['1600'])}
|
||||
className={classNames(
|
||||
styles.image,
|
||||
{ [styles.is_loaded]: loaded },
|
||||
className,
|
||||
)}
|
||||
src={getURL(file, imagePresets['1600'])}
|
||||
alt=""
|
||||
key={file.id}
|
||||
onLoad={onImageLoad}
|
||||
|
@ -98,7 +124,9 @@ const ImagePreloader: FC<IProps> = ({ file, color, onLoad, onClick, className })
|
|||
onError={onError}
|
||||
/>
|
||||
|
||||
{!loaded && !hasError && <LoaderCircle className={styles.icon} size={64} />}
|
||||
{!loaded && !hasError && (
|
||||
<LoaderCircle className={styles.icon} size={64} />
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<div className={styles.error}>
|
||||
|
|
|
@ -14,6 +14,7 @@ interface HorizontalMenuItemProps {
|
|||
icon?: string;
|
||||
color?: 'green' | 'orange' | 'yellow';
|
||||
active?: boolean;
|
||||
stretchy?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
|
@ -31,6 +32,7 @@ HorizontalMenu.Item = ({
|
|||
children,
|
||||
isLoading,
|
||||
active,
|
||||
stretchy,
|
||||
onClick,
|
||||
}: PropsWithChildren<HorizontalMenuItemProps>) => {
|
||||
if (isLoading) {
|
||||
|
@ -44,7 +46,11 @@ HorizontalMenu.Item = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.item, { [styles.active]: active }, styles[color])}
|
||||
className={classNames(
|
||||
styles.item,
|
||||
{ [styles.active]: active, [styles.stretchy]: stretchy },
|
||||
styles[color],
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{!!icon && <Icon icon={icon} size={24} />}
|
||||
|
|
|
@ -57,6 +57,11 @@
|
|||
background: $warning_gradient;
|
||||
}
|
||||
}
|
||||
|
||||
&.stretchy {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
|
|
|
@ -4,7 +4,7 @@ import classNames from 'classnames';
|
|||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface SeparatedMenuProps {
|
||||
export interface SeparatedMenuProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
@ -14,7 +14,7 @@ const SeparatedMenu: FC<SeparatedMenuProps> = ({ children, className }) => {
|
|||
return [];
|
||||
}
|
||||
|
||||
return (Array.isArray(children) ? children : [children]).filter(it => it);
|
||||
return (Array.isArray(children) ? children : [children]).filter((it) => it);
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React, { PropsWithChildren } from 'react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Card } from '~/components/containers/Card';
|
||||
import { Anchor } from '~/components/common/Anchor';
|
||||
import { DivProps, LinkProps } from '~/utils/types';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
@ -11,7 +11,9 @@ interface VerticalMenuProps extends DivProps {
|
|||
appearance?: 'inset' | 'flat' | 'default';
|
||||
}
|
||||
|
||||
interface VerticalMenuItemProps extends Omit<LinkProps, 'href'> {}
|
||||
interface VerticalMenuItemProps extends Omit<LinkProps, 'href'> {
|
||||
hasUpdates?: boolean;
|
||||
}
|
||||
|
||||
function VerticalMenu({
|
||||
children,
|
||||
|
@ -28,8 +30,13 @@ function VerticalMenu({
|
|||
);
|
||||
}
|
||||
|
||||
VerticalMenu.Item = ({ ...props }: VerticalMenuItemProps) => (
|
||||
<a {...props} className={classNames(styles.item, props.className)} />
|
||||
VerticalMenu.Item = ({ hasUpdates, ...props }: VerticalMenuItemProps) => (
|
||||
<a
|
||||
{...props}
|
||||
className={classNames(styles.item, props.className, {
|
||||
[styles.has_dot]: hasUpdates,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
export { VerticalMenu };
|
||||
|
|
|
@ -33,6 +33,19 @@ a.item {
|
|||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.25s;
|
||||
position: relative;
|
||||
|
||||
&.has_dot::after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: $color_danger;
|
||||
border-radius: 8px;
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $content_bg_success;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { INodeComponentProps } from '~/constants/node';
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { useNodeImages } from '~/hooks/node/useNodeImages';
|
||||
import { getURL } from '~/utils/dom';
|
||||
import { path } from '~/utils/ramda';
|
||||
|
@ -19,7 +19,12 @@ const NodeAudioImageBlock: FC<IProps> = ({ node }) => {
|
|||
<div className={styles.wrap}>
|
||||
<div
|
||||
className={styles.slide}
|
||||
style={{ backgroundImage: `url("${getURL(path([0], images), ImagePresets.small_hero)}")` }}
|
||||
style={{
|
||||
backgroundImage: `url("${getURL(
|
||||
path([0], images),
|
||||
imagePresets.small_hero,
|
||||
)}")`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,20 +1,30 @@
|
|||
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import SwiperCore, { Keyboard, Navigation, Pagination, SwiperOptions } from 'swiper';
|
||||
import SwiperCore, {
|
||||
Keyboard,
|
||||
Lazy,
|
||||
Navigation,
|
||||
Pagination,
|
||||
SwiperOptions,
|
||||
} from 'swiper';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import SwiperClass from 'swiper/types/swiper-class';
|
||||
|
||||
import { ImagePreloader } from '~/components/media/ImagePreloader';
|
||||
import { ImageLoadingWrapper } from '~/components/common/ImageLoadingWrapper/index';
|
||||
import { INodeComponentProps } from '~/constants/node';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { useModal } from '~/hooks/modal/useModal';
|
||||
import { useImageModal } from '~/hooks/navigation/useImageModal';
|
||||
import { useNodeImages } from '~/hooks/node/useNodeImages';
|
||||
import { normalizeBrightColor } from '~/utils/color';
|
||||
import { getURL } from '~/utils/dom';
|
||||
import { getFileSrcSet } from '~/utils/srcset';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
SwiperCore.use([Navigation, Pagination, Keyboard]);
|
||||
SwiperCore.use([Navigation, Pagination, Keyboard, Lazy]);
|
||||
|
||||
interface IProps extends INodeComponentProps {}
|
||||
|
||||
|
@ -26,8 +36,18 @@ const breakpoints: SwiperOptions['breakpoints'] = {
|
|||
|
||||
const pagination = { type: 'fraction' as const };
|
||||
|
||||
const lazy = {
|
||||
enabled: true,
|
||||
loadPrevNextAmount: 1,
|
||||
loadOnTransitionStart: true,
|
||||
loadPrevNext: true,
|
||||
checkInView: true,
|
||||
};
|
||||
|
||||
const NodeImageSwiperBlock: FC<IProps> = observer(({ node }) => {
|
||||
const [controlledSwiper, setControlledSwiper] = useState<SwiperClass | undefined>(undefined);
|
||||
const [controlledSwiper, setControlledSwiper] = useState<
|
||||
SwiperClass | undefined
|
||||
>(undefined);
|
||||
const showPhotoSwiper = useImageModal();
|
||||
const { isOpened: isModalActive } = useModal();
|
||||
|
||||
|
@ -38,7 +58,7 @@ const NodeImageSwiperBlock: FC<IProps> = observer(({ node }) => {
|
|||
enabled: !isModalActive,
|
||||
onlyInViewport: true,
|
||||
}),
|
||||
[isModalActive]
|
||||
[isModalActive],
|
||||
);
|
||||
|
||||
const updateSwiper = useCallback(() => {
|
||||
|
@ -53,14 +73,17 @@ const NodeImageSwiperBlock: FC<IProps> = observer(({ node }) => {
|
|||
|
||||
const onOpenPhotoSwipe = useCallback(
|
||||
(index: number) => {
|
||||
if (index !== controlledSwiper?.activeIndex && controlledSwiper?.slideTo) {
|
||||
if (
|
||||
index !== controlledSwiper?.activeIndex &&
|
||||
controlledSwiper?.slideTo
|
||||
) {
|
||||
controlledSwiper.slideTo(index, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
showPhotoSwiper(images, index);
|
||||
},
|
||||
[images, controlledSwiper, showPhotoSwiper]
|
||||
[images, controlledSwiper, showPhotoSwiper],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -80,18 +103,6 @@ const NodeImageSwiperBlock: FC<IProps> = observer(({ node }) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (images.length === 1) {
|
||||
return (
|
||||
<div className={styles.single}>
|
||||
<ImagePreloader
|
||||
file={images[0]}
|
||||
onClick={() => onOpenPhotoSwipe(0)}
|
||||
className={styles.image}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Swiper
|
||||
|
@ -113,16 +124,31 @@ const NodeImageSwiperBlock: FC<IProps> = observer(({ node }) => {
|
|||
autoHeight
|
||||
zoom
|
||||
navigation
|
||||
watchSlidesProgress
|
||||
lazy={lazy}
|
||||
>
|
||||
{images.map((file, i) => (
|
||||
{images.map((file, index) => (
|
||||
<SwiperSlide className={styles.slide} key={file.id}>
|
||||
<ImagePreloader
|
||||
file={file}
|
||||
onLoad={updateSwiper}
|
||||
onClick={() => onOpenPhotoSwipe(i)}
|
||||
className={styles.image}
|
||||
color={normalizeBrightColor(file?.metadata?.dominant_color)}
|
||||
/>
|
||||
<ImageLoadingWrapper
|
||||
preview={getURL(file, imagePresets['300'])}
|
||||
color={file.metadata?.dominant_color}
|
||||
>
|
||||
{({ 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)}
|
||||
alt=""
|
||||
sizes="(max-width: 560px) 100vw, 50vh"
|
||||
/>
|
||||
)}
|
||||
</ImageLoadingWrapper>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
|
|
@ -95,12 +95,12 @@
|
|||
width: auto;
|
||||
max-width: 100vw;
|
||||
opacity: 1;
|
||||
//transform: translate(0, 10px);
|
||||
transform: scale(0.99);
|
||||
filter: brightness(50%) saturate(0.5);
|
||||
transition: opacity 0.5s, filter 0.5s, transform 0.5s;
|
||||
padding-bottom: $gap * 1.5;
|
||||
padding-top: $gap;
|
||||
position: relative;
|
||||
|
||||
&:global(.swiper-slide-active) {
|
||||
opacity: 1;
|
||||
|
@ -117,12 +117,16 @@
|
|||
}
|
||||
|
||||
.image {
|
||||
max-height: calc(100vh - 70px - 70px);
|
||||
max-width: 100%;
|
||||
max-inline-size: calc(100vh - 150px);
|
||||
writing-mode: vertical-rl;
|
||||
block-size: auto;
|
||||
border-radius: $radius;
|
||||
transition: box-shadow 1s;
|
||||
box-shadow: transparentize(black, 0.7) 0 3px 5px;
|
||||
opacity: 0;
|
||||
|
||||
&.loading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:global(.swiper-slide-active) & {
|
||||
box-shadow: transparentize(black, 0.9) 0 10px 5px 4px,
|
||||
|
@ -134,7 +138,9 @@
|
|||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
@media (orientation: portrait) {
|
||||
max-inline-size: 100vw;
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,20 +9,25 @@ import { t } from '~/utils/trans';
|
|||
import styles from './styles.module.scss';
|
||||
|
||||
interface IProps {
|
||||
is_loading?: boolean;
|
||||
loading?: boolean;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
const NodeNoComments: FC<IProps> = ({ is_loading = false, count = 3 }) => {
|
||||
const NodeNoComments: FC<IProps> = ({ loading = false, count = 3 }) => {
|
||||
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 (
|
||||
<Group className={classNames(styles.wrap, { is_loading })}>
|
||||
<Group className={classNames(styles.wrap, { [styles.loading]: loading })}>
|
||||
{items}
|
||||
{!is_loading && <div className={styles.nothing}>{t(ERRORS.NO_COMMENTS)}</div>}
|
||||
{!loading && (
|
||||
<div className={styles.nothing}>{t(ERRORS.NO_COMMENTS)}</div>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.wrap {
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
|
@ -17,7 +26,7 @@
|
|||
bottom: 0;
|
||||
}
|
||||
|
||||
&:global(.is_loading) {
|
||||
&.loading {
|
||||
opacity: 1;
|
||||
|
||||
.card {
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import React, { FC, ReactElement } from 'react';
|
||||
|
||||
import { Hoverable } from '~/components/common/Hoverable';
|
||||
import { SubTitle } from '~/components/common/SubTitle';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { NodeRelatedItem } from '~/components/node/NodeRelatedItem';
|
||||
import { NodeThumbnail } from '~/components/node/NodeThumbnail';
|
||||
import { INode } from '~/types';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
|
||||
interface IProps {
|
||||
title: ReactElement | string;
|
||||
items: Partial<INode>[];
|
||||
|
@ -19,8 +19,10 @@ const NodeRelated: FC<IProps> = ({ title, items }) => {
|
|||
<SubTitle className={styles.title}>{title}</SubTitle>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{items.map(item => (
|
||||
<NodeRelatedItem item={item} key={item.id} />
|
||||
{items.map((item) => (
|
||||
<Hoverable key={item.id} className={styles.item}>
|
||||
<NodeThumbnail item={item} />
|
||||
</Hoverable>
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
|
|
|
@ -3,7 +3,7 @@ import React, { FC, memo } from 'react';
|
|||
import classNames from 'classnames';
|
||||
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import cell_style from '~/components/node/NodeRelatedItem/styles.module.scss';
|
||||
import cell_style from '~/components/node/NodeThumbnail/styles.module.scss';
|
||||
import { Placeholder } from '~/components/placeholders/Placeholder';
|
||||
import { range } from '~/utils/ramda';
|
||||
|
||||
|
@ -23,7 +23,7 @@ const NodeRelatedPlaceholder: FC<IProps> = memo(() => {
|
|||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{range(0, 6).map(el => (
|
||||
{range(0, 6).map((el) => (
|
||||
<div className={cell_style.item} key={el} />
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.wrap {
|
||||
border-radius: $panel_radius;
|
||||
padding: $gap 0;
|
||||
|
@ -39,6 +48,11 @@
|
|||
.grid {
|
||||
div {
|
||||
background: $placeholder_bg;
|
||||
animation: fade 0.5s infinite alternate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
|
|
@ -1,20 +1,24 @@
|
|||
import React, { FC, memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FC, memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { ImageWithSSRLoad } from '~/components/common/ImageWithSSRLoad';
|
||||
import { Square } from '~/components/common/Square';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { useColorGradientFromString } from '~/hooks/color/useColorGradientFromString';
|
||||
import { useGotoNode } from '~/hooks/node/useGotoNode';
|
||||
import { INode } from '~/types';
|
||||
import { getURL, getURLFromString } from '~/utils/dom';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
type IProps = {
|
||||
item: Partial<INode>;
|
||||
type NodeThumbnailProps = {
|
||||
item: {
|
||||
thumbnail?: string;
|
||||
title?: string;
|
||||
is_promoted?: boolean;
|
||||
id?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type CellSize = 'small' | 'medium' | 'large';
|
||||
|
@ -33,7 +37,7 @@ const getTitleLetters = (title?: string): string => {
|
|||
: words[0].substr(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const NodeRelatedItem: FC<IProps> = memo(({ item }) => {
|
||||
const NodeThumbnail: FC<NodeThumbnailProps> = memo(({ item }) => {
|
||||
const onClick = useGotoNode(item.id);
|
||||
const [is_loaded, setIsLoaded] = useState(false);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
@ -42,7 +46,7 @@ const NodeRelatedItem: FC<IProps> = memo(({ item }) => {
|
|||
const thumb = useMemo(
|
||||
() =>
|
||||
item.thumbnail
|
||||
? getURL({ url: item.thumbnail }, ImagePresets.avatar)
|
||||
? getURL({ url: item.thumbnail }, imagePresets.avatar)
|
||||
: '',
|
||||
[item],
|
||||
);
|
||||
|
@ -68,7 +72,7 @@ const NodeRelatedItem: FC<IProps> = memo(({ item }) => {
|
|||
}, [width]);
|
||||
|
||||
const image = useMemo(
|
||||
() => getURL({ url: item.thumbnail }, ImagePresets.avatar),
|
||||
() => getURL({ url: item.thumbnail }, imagePresets.avatar),
|
||||
[item.thumbnail],
|
||||
);
|
||||
|
||||
|
@ -118,4 +122,4 @@ const NodeRelatedItem: FC<IProps> = memo(({ item }) => {
|
|||
);
|
||||
});
|
||||
|
||||
export { NodeRelatedItem };
|
||||
export { NodeThumbnail };
|
|
@ -2,6 +2,7 @@ import React, { memo, VFC } from 'react';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Authorized } from '~/components/containers/Authorized';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { SeparatedMenu } from '~/components/menu/SeparatedMenu';
|
||||
import { NodeEditMenu } from '~/components/node/NodeEditMenu';
|
||||
|
@ -76,37 +77,39 @@ const NodeTitle: VFC<IProps> = memo(
|
|||
)}
|
||||
</div>
|
||||
|
||||
<SeparatedMenu className={styles.buttons}>
|
||||
{canEdit && (
|
||||
<NodeEditMenu
|
||||
className={styles.button}
|
||||
canStar={canStar}
|
||||
isHeroic={isHeroic}
|
||||
isLocked={isLocked}
|
||||
onStar={onStar}
|
||||
onLock={onLock}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
)}
|
||||
<Authorized>
|
||||
<SeparatedMenu className={styles.buttons}>
|
||||
{canEdit && (
|
||||
<NodeEditMenu
|
||||
className={styles.button}
|
||||
canStar={canStar}
|
||||
isHeroic={isHeroic}
|
||||
isLocked={isLocked}
|
||||
onStar={onStar}
|
||||
onLock={onLock}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canLike && (
|
||||
<div
|
||||
className={classNames(styles.button, styles.like, {
|
||||
[styles.is_liked]: isLiked,
|
||||
})}
|
||||
>
|
||||
{isLiked ? (
|
||||
<Icon icon="heart_full" size={24} onClick={onLike} />
|
||||
) : (
|
||||
<Icon icon="heart" size={24} onClick={onLike} />
|
||||
)}
|
||||
{canLike && (
|
||||
<div
|
||||
className={classNames(styles.button, styles.like, {
|
||||
[styles.is_liked]: isLiked,
|
||||
})}
|
||||
>
|
||||
{isLiked ? (
|
||||
<Icon icon="heart_full" size={24} onClick={onLike} />
|
||||
) : (
|
||||
<Icon icon="heart" size={24} onClick={onLike} />
|
||||
)}
|
||||
|
||||
{!!likeCount && likeCount > 0 && (
|
||||
<div className={styles.like_count}>{likeCount}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SeparatedMenu>
|
||||
{!!likeCount && likeCount > 0 && (
|
||||
<div className={styles.like_count}>{likeCount}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SeparatedMenu>
|
||||
</Authorized>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
77
src/components/notifications/NotificationBadge/index.tsx
Normal file
77
src/components/notifications/NotificationBadge/index.tsx
Normal 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 };
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
|
@ -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%);
|
||||
}
|
||||
}
|
54
src/components/notifications/NotificationComment/index.tsx
Normal file
54
src/components/notifications/NotificationComment/index.tsx
Normal 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 };
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
39
src/components/notifications/NotificationNode/index.tsx
Normal file
39
src/components/notifications/NotificationNode/index.tsx
Normal 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 };
|
|
@ -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;
|
||||
}
|
|
@ -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 };
|
|
@ -0,0 +1,7 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
row-gap: $gap;
|
||||
}
|
|
@ -2,7 +2,7 @@ import React, { ChangeEvent, FC, useCallback } from 'react';
|
|||
|
||||
import { Avatar } from '~/components/common/Avatar';
|
||||
import { Button } from '~/components/input/Button';
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { IFile } from '~/types';
|
||||
import { getURL } from '~/utils/dom';
|
||||
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
@import "src/styles/variables";
|
||||
|
||||
.scroller {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: $gap;
|
||||
}
|
64
src/components/profile/ProfileSidebarNotifications/index.tsx
Normal file
64
src/components/profile/ProfileSidebarNotifications/index.tsx
Normal 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 };
|
|
@ -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%;
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
import React, { FC, useMemo } from 'react';
|
||||
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import { ImageUpload } from '~/components/upload/ImageUpload';
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { UploadStatus } from '~/store/uploader/UploaderStore';
|
||||
import { IFile } from '~/types';
|
||||
import { getURL } from '~/utils/dom';
|
||||
|
@ -18,18 +20,31 @@ interface SortableImageGridProps {
|
|||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
const renderItem = ({ item, onDelete }: { item: IFile; onDelete: (fileId: number) => void }) => (
|
||||
<ImageUpload id={item.id} thumb={getURL(item, ImagePresets.cover)} onDrop={onDelete} />
|
||||
const renderItem = observer(
|
||||
({ item, onDelete }: { item: IFile; onDelete: (fileId: number) => void }) => (
|
||||
<ImageUpload
|
||||
id={item.id}
|
||||
thumb={getURL(item, imagePresets.cover)}
|
||||
onDrop={onDelete}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
const renderLocked = ({
|
||||
locked,
|
||||
onDelete,
|
||||
}: {
|
||||
locked: UploadStatus;
|
||||
onDelete: (fileId: number) => void;
|
||||
}) => (
|
||||
<ImageUpload thumb={locked.thumbnail} onDrop={onDelete} progress={locked.progress} uploading />
|
||||
const renderLocked = observer(
|
||||
({
|
||||
locked,
|
||||
onDelete,
|
||||
}: {
|
||||
locked: UploadStatus;
|
||||
onDelete: (fileId: number) => void;
|
||||
}) => (
|
||||
<ImageUpload
|
||||
thumb={locked.thumbnail}
|
||||
onDrop={onDelete}
|
||||
progress={locked.progress}
|
||||
uploading
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
const SortableImageGrid: FC<SortableImageGridProps> = ({
|
||||
|
@ -46,8 +61,8 @@ const SortableImageGrid: FC<SortableImageGridProps> = ({
|
|||
<SortableGrid
|
||||
items={items}
|
||||
locked={locked}
|
||||
getID={it => it.id}
|
||||
getLockedID={it => it.id}
|
||||
getID={(it) => it.id}
|
||||
getLockedID={(it) => it.id}
|
||||
renderItem={renderItem}
|
||||
renderItemProps={props}
|
||||
renderLocked={renderLocked}
|
||||
|
|
|
@ -24,6 +24,7 @@ export const API = {
|
|||
DROP_SOCIAL: (provider, id) => `/oauth/${provider}/${id}`,
|
||||
ATTACH_SOCIAL: `/oauth`,
|
||||
LOGIN_WITH_SOCIAL: `/oauth`,
|
||||
ATTACH_TELEGRAM: '/oauth/telegram/attach',
|
||||
},
|
||||
NODES: {
|
||||
SAVE: '/nodes/',
|
||||
|
@ -62,4 +63,8 @@ export const API = {
|
|||
STATS: '/nodes/lab/stats',
|
||||
UPDATES: '/nodes/lab/updates',
|
||||
},
|
||||
NOTIFICATIONS: {
|
||||
LIST: '/notifications/',
|
||||
SETTINGS: '/notifications/settings',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -3,4 +3,5 @@ import { OAuthProvider } from '~/types/auth';
|
|||
export const SOCIAL_ICONS: Record<OAuthProvider, string> = {
|
||||
vkontakte: 'vk',
|
||||
google: 'google',
|
||||
telegram: 'telegram',
|
||||
};
|
||||
|
|
|
@ -6,6 +6,7 @@ import { LoginSocialRegisterDialog } from '~/containers/dialogs/LoginSocialRegis
|
|||
import { PhotoSwipe } from '~/containers/dialogs/PhotoSwipe';
|
||||
import { RestorePasswordDialog } from '~/containers/dialogs/RestorePasswordDialog';
|
||||
import { RestoreRequestDialog } from '~/containers/dialogs/RestoreRequestDialog';
|
||||
import { TelegramAttachDialog } from '~/containers/dialogs/TelegramAttachDialog';
|
||||
import { TestDialog } from '~/containers/dialogs/TestDialog';
|
||||
|
||||
export enum Dialog {
|
||||
|
@ -18,6 +19,7 @@ export enum Dialog {
|
|||
Photoswipe = 'Photoswipe',
|
||||
CreateNode = 'CreateNode',
|
||||
EditNode = 'EditNode',
|
||||
TelegramAttach = 'TelegramAttach',
|
||||
}
|
||||
|
||||
export const DIALOG_CONTENT = {
|
||||
|
@ -30,4 +32,5 @@ export const DIALOG_CONTENT = {
|
|||
[Dialog.Photoswipe]: PhotoSwipe,
|
||||
[Dialog.CreateNode]: EditorCreateDialog,
|
||||
[Dialog.EditNode]: EditorEditDialog,
|
||||
[Dialog.TelegramAttach]: TelegramAttachDialog,
|
||||
} as const;
|
||||
|
|
|
@ -1,49 +1,61 @@
|
|||
import { FlowDisplayVariant, INode } from "~/types";
|
||||
import { FlowDisplayVariant, INode } from '~/types';
|
||||
|
||||
export const URLS = {
|
||||
BASE: "/",
|
||||
LAB: "/lab",
|
||||
BORIS: "/boris",
|
||||
BASE: '/',
|
||||
LAB: '/lab',
|
||||
BORIS: '/boris',
|
||||
AUTH: {
|
||||
LOGIN: "/auth/login",
|
||||
LOGIN: '/auth/login',
|
||||
},
|
||||
EXAMPLES: {
|
||||
EDITOR: "/examples/edit",
|
||||
IMAGE: "/examples/image",
|
||||
EDITOR: '/examples/edit',
|
||||
IMAGE: '/examples/image',
|
||||
},
|
||||
ERRORS: {
|
||||
NOT_FOUND: "/lost",
|
||||
BACKEND_DOWN: "/oopsie",
|
||||
NOT_FOUND: '/lost',
|
||||
BACKEND_DOWN: '/oopsie',
|
||||
},
|
||||
NODE_URL: (id: INode["id"] | string) => `/post${id}`,
|
||||
NODE_URL: (id: INode['id'] | string) => `/post${id}`,
|
||||
PROFILE_PAGE: (username: string) => `/profile/${username}`,
|
||||
SETTINGS: {
|
||||
BASE: "/settings",
|
||||
NOTES: "/settings/notes",
|
||||
TRASH: "/settings/trash",
|
||||
BASE: '/settings',
|
||||
NOTES: '/settings/notes',
|
||||
TRASH: '/settings/trash',
|
||||
},
|
||||
NOTES: "/notes/",
|
||||
NOTES: '/notes/',
|
||||
NOTE: (id: number) => `/notes/${id}`,
|
||||
};
|
||||
|
||||
export const ImagePresets = {
|
||||
"1600": "1600",
|
||||
"600": "600",
|
||||
"300": "300",
|
||||
cover: "cover",
|
||||
small_hero: "small_hero",
|
||||
avatar: "avatar",
|
||||
flow_square: "flow_square",
|
||||
flow_vertical: "flow_vertical",
|
||||
flow_horizontal: "flow_horizontal",
|
||||
export const imagePresets = {
|
||||
'1600': '1600',
|
||||
'900': '900',
|
||||
'1200': '1200',
|
||||
'600': '600',
|
||||
'300': '300',
|
||||
cover: 'cover',
|
||||
small_hero: 'small_hero',
|
||||
avatar: 'avatar',
|
||||
flow_square: 'flow_square',
|
||||
flow_vertical: 'flow_vertical',
|
||||
flow_horizontal: 'flow_horizontal',
|
||||
} as const;
|
||||
|
||||
export type ImagePreset = typeof imagePresets[keyof typeof imagePresets];
|
||||
|
||||
export const imageSrcSets: Partial<Record<ImagePreset, number>> = {
|
||||
[imagePresets[1600]]: 1600,
|
||||
[imagePresets[900]]: 900,
|
||||
[imagePresets[1200]]: 1200,
|
||||
[imagePresets[600]]: 600,
|
||||
[imagePresets[300]]: 300,
|
||||
};
|
||||
|
||||
export const flowDisplayToPreset: Record<
|
||||
FlowDisplayVariant,
|
||||
typeof ImagePresets[keyof typeof ImagePresets]
|
||||
typeof imagePresets[keyof typeof imagePresets]
|
||||
> = {
|
||||
single: "flow_square",
|
||||
quadro: "flow_square",
|
||||
vertical: "flow_vertical",
|
||||
horizontal: "flow_horizontal",
|
||||
single: 'flow_square',
|
||||
quadro: 'flow_square',
|
||||
vertical: 'flow_vertical',
|
||||
horizontal: 'flow_horizontal',
|
||||
};
|
||||
|
|
|
@ -17,27 +17,27 @@ const BorisComments: FC<IProps> = () => {
|
|||
const user = useUserContext();
|
||||
const { isUser } = useAuth();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
comments,
|
||||
onSaveComment,
|
||||
} = useCommentContext();
|
||||
const { isLoading, comments, onSaveComment } = useCommentContext();
|
||||
const { node } = useNodeContext();
|
||||
|
||||
return (
|
||||
<Group>
|
||||
{(isUser || isSSR) && (
|
||||
<NodeCommentFormSSR user={user} nodeId={node.id} saveComment={onSaveComment} />
|
||||
)}
|
||||
<Group>
|
||||
{(isUser || isSSR) && (
|
||||
<NodeCommentFormSSR
|
||||
user={user}
|
||||
nodeId={node.id}
|
||||
saveComment={onSaveComment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading || !comments?.length ? (
|
||||
<NodeNoComments is_loading count={7} />
|
||||
) : (
|
||||
<NodeComments order="ASC" />
|
||||
)}
|
||||
{isLoading || !comments?.length ? (
|
||||
<NodeNoComments loading count={7} />
|
||||
) : (
|
||||
<NodeComments order="ASC" />
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</Group>
|
||||
<Footer />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite';
|
|||
import PhotoSwipeUI_Default from 'photoswipe/dist/photoswipe-ui-default.js';
|
||||
import PhotoSwipeJs from 'photoswipe/dist/photoswipe.js';
|
||||
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { useWindowSize } from '~/hooks/dom/useWindowSize';
|
||||
import { useModal } from '~/hooks/modal/useModal';
|
||||
import { IFile } from '~/types';
|
||||
|
@ -25,35 +25,47 @@ const PhotoSwipe: VFC<PhotoSwipeProps> = observer(({ index, items }) => {
|
|||
const { isTablet } = useWindowSize();
|
||||
|
||||
useEffect(() => {
|
||||
new Promise(async resolve => {
|
||||
new Promise(async (resolve) => {
|
||||
const images = await Promise.all(
|
||||
items.map(
|
||||
image =>
|
||||
new Promise(resolveImage => {
|
||||
(file) =>
|
||||
new Promise((resolve) => {
|
||||
const src = getURL(
|
||||
file,
|
||||
isTablet ? imagePresets[900] : imagePresets[1600],
|
||||
);
|
||||
|
||||
if (file.metadata?.width && file.metadata.height) {
|
||||
resolve({
|
||||
src,
|
||||
w: file.metadata.width,
|
||||
h: file.metadata.height,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
resolveImage({
|
||||
src: getURL(
|
||||
image,
|
||||
isTablet ? ImagePresets[900] : ImagePresets[1600],
|
||||
),
|
||||
resolve({
|
||||
src,
|
||||
h: img.naturalHeight,
|
||||
w: img.naturalWidth,
|
||||
});
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
resolveImage({});
|
||||
resolve({});
|
||||
};
|
||||
|
||||
img.src = getURL(image, ImagePresets[1600]);
|
||||
img.src = getURL(file, imagePresets[1600]);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
resolve(images);
|
||||
}).then(images => {
|
||||
}).then((images) => {
|
||||
const ps = new PhotoSwipeJs(ref.current, PhotoSwipeUI_Default, images, {
|
||||
index: index || 0,
|
||||
closeOnScroll: false,
|
||||
|
|
49
src/containers/dialogs/TelegramAttachDialog/index.tsx
Normal file
49
src/containers/dialogs/TelegramAttachDialog/index.tsx
Normal 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 };
|
|
@ -1,5 +1,6 @@
|
|||
import { FC, memo } from 'react';
|
||||
|
||||
import { Hoverable } from '~/components/common/Hoverable';
|
||||
import { Columns } from '~/components/containers/Columns';
|
||||
import { InfiniteScroll } from '~/components/containers/InfiniteScroll';
|
||||
import { LabNoResults } from '~/components/lab/LabNoResults';
|
||||
|
@ -11,27 +12,27 @@ import styles from './styles.module.scss';
|
|||
interface IProps {}
|
||||
|
||||
const LabGrid: FC<IProps> = memo(() => {
|
||||
const { nodes, hasMore, loadMore, search, setSearch } = useLabContext();
|
||||
const { nodes, hasMore, loadMore, search, setSearch, isLoading } =
|
||||
useLabContext();
|
||||
|
||||
if (search && !nodes.length) {
|
||||
return <LabNoResults resetSearch={() => setSearch('')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteScroll hasMore={hasMore} loadMore={loadMore}>
|
||||
<div className={styles.wrap}>
|
||||
<Columns>
|
||||
{nodes.map((node) => (
|
||||
<div className={styles.wrap}>
|
||||
<Columns hasMore={hasMore && !isLoading} onScrollEnd={loadMore}>
|
||||
{nodes.map((node) => (
|
||||
<Hoverable key={node.node.id} effect="shine">
|
||||
<LabNode
|
||||
node={node.node}
|
||||
key={node.node.id}
|
||||
lastSeen={node.last_seen}
|
||||
commentCount={node.comment_count}
|
||||
/>
|
||||
))}
|
||||
</Columns>
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
</Hoverable>
|
||||
))}
|
||||
</Columns>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import isBefore from 'date-fns/isBefore';
|
||||
|
@ -9,17 +9,16 @@ import { Authorized } from '~/components/containers/Authorized';
|
|||
import { Filler } from '~/components/containers/Filler';
|
||||
import { Button } from '~/components/input/Button';
|
||||
import { Logo } from '~/components/main/Logo';
|
||||
import { UserButton } from '~/components/main/UserButton';
|
||||
import { Dialog } from '~/constants/modal';
|
||||
import { SidebarName } from '~/constants/sidebar';
|
||||
import { URLS } from '~/constants/urls';
|
||||
import { useAuth } from '~/hooks/auth/useAuth';
|
||||
import { useScrollTop } from '~/hooks/dom/useScrollTop';
|
||||
import { useFlow } from '~/hooks/flow/useFlow';
|
||||
import { useGetLabStats } from '~/hooks/lab/useGetLabStats';
|
||||
import { useModal } from '~/hooks/modal/useModal';
|
||||
import { useUpdates } from '~/hooks/updates/useUpdates';
|
||||
import { useSidebar } from '~/utils/providers/SidebarProvider';
|
||||
import { useNotifications } from '~/utils/providers/NotificationProvider';
|
||||
|
||||
import { UserButtonWithNotifications } from '../UserButtonWithNotifications';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
|
@ -28,14 +27,10 @@ export interface HeaderProps {}
|
|||
const Header: FC<HeaderProps> = observer(() => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const { showModal } = useModal();
|
||||
const { isUser, user } = useAuth();
|
||||
const { isUser, user, fetched } = useAuth();
|
||||
const { hasFlowUpdates, hasLabUpdates } = useFlow();
|
||||
const { borisCommentedAt } = useUpdates();
|
||||
const { open } = useSidebar();
|
||||
|
||||
const openProfileSidebar = useCallback(() => {
|
||||
open(SidebarName.Settings, {});
|
||||
}, [open]);
|
||||
const { indicatorEnabled } = useNotifications();
|
||||
|
||||
const onLogin = useCallback(() => showModal(Dialog.Login, {}), [showModal]);
|
||||
|
||||
|
@ -44,10 +39,11 @@ const Header: FC<HeaderProps> = observer(() => {
|
|||
const hasBorisUpdates = useMemo(
|
||||
() =>
|
||||
isUser &&
|
||||
!indicatorEnabled &&
|
||||
borisCommentedAt &&
|
||||
(!user.last_seen_boris ||
|
||||
((fetched && !user.last_seen_boris) ||
|
||||
isBefore(new Date(user.last_seen_boris), new Date(borisCommentedAt))),
|
||||
[borisCommentedAt, isUser, user.last_seen_boris],
|
||||
[borisCommentedAt, isUser, user.last_seen_boris, fetched],
|
||||
);
|
||||
|
||||
// Needed for SSR
|
||||
|
@ -66,11 +62,15 @@ const Header: FC<HeaderProps> = observer(() => {
|
|||
|
||||
<Filler className={styles.filler} />
|
||||
|
||||
<nav className={styles.plugs}>
|
||||
<Authorized>
|
||||
<nav
|
||||
className={classNames(styles.plugs, {
|
||||
[styles.active]: true,
|
||||
})}
|
||||
>
|
||||
<Authorized hydratedOnly>
|
||||
<Anchor
|
||||
className={classNames(styles.item, {
|
||||
[styles.has_dot]: hasFlowUpdates,
|
||||
[styles.has_dot]: hasFlowUpdates && !indicatorEnabled,
|
||||
})}
|
||||
href={URLS.BASE}
|
||||
>
|
||||
|
@ -79,7 +79,7 @@ const Header: FC<HeaderProps> = observer(() => {
|
|||
|
||||
<Anchor
|
||||
className={classNames(styles.item, styles.lab, {
|
||||
[styles.has_dot]: hasLabUpdates,
|
||||
[styles.has_dot]: hasLabUpdates && !indicatorEnabled,
|
||||
})}
|
||||
href={URLS.LAB}
|
||||
>
|
||||
|
@ -88,7 +88,7 @@ const Header: FC<HeaderProps> = observer(() => {
|
|||
|
||||
<Anchor
|
||||
className={classNames(styles.item, styles.boris, {
|
||||
[styles.has_dot]: hasBorisUpdates,
|
||||
[styles.has_dot]: hasBorisUpdates && !indicatorEnabled,
|
||||
})}
|
||||
href={URLS.BORIS}
|
||||
>
|
||||
|
@ -97,13 +97,7 @@ const Header: FC<HeaderProps> = observer(() => {
|
|||
</Authorized>
|
||||
</nav>
|
||||
|
||||
{isUser && (
|
||||
<UserButton
|
||||
username={user.username}
|
||||
photo={user.photo}
|
||||
onClick={openProfileSidebar}
|
||||
/>
|
||||
)}
|
||||
{isUser && <UserButtonWithNotifications />}
|
||||
|
||||
{!isUser && (
|
||||
<Button className={styles.user_button} onClick={onLogin} round>
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
@keyframes appear {
|
||||
from {
|
||||
transform: translate(0, -$header_height);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translate(0, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -55,6 +55,12 @@
|
|||
user-select: none;
|
||||
text-transform: uppercase;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: all 250ms;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
flex: 1;
|
||||
|
|
32
src/containers/main/UserButtonWithNotifications/index.tsx
Normal file
32
src/containers/main/UserButtonWithNotifications/index.tsx
Normal 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 };
|
|
@ -26,7 +26,11 @@ interface IProps {
|
|||
const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => {
|
||||
const user = useUserContext();
|
||||
const { node, isLoading } = useNodeContext();
|
||||
const { comments, isLoading: isLoadingComments, onSaveComment } = useCommentContext();
|
||||
const {
|
||||
comments,
|
||||
isLoading: isLoadingComments,
|
||||
onSaveComment,
|
||||
} = useCommentContext();
|
||||
const { related, isLoading: isLoadingRelated } = useNodeRelatedContext();
|
||||
const { inline } = useNodeBlocks(node, isLoading);
|
||||
const { isUser } = useAuthProvider();
|
||||
|
@ -43,15 +47,21 @@ const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => {
|
|||
{inline && <div className={styles.inline}>{inline}</div>}
|
||||
|
||||
<article>
|
||||
{isLoading || isLoadingComments || (!comments.length && !inline) ? (
|
||||
<NodeNoComments is_loading={isLoadingComments || isLoading} />
|
||||
{isLoading ||
|
||||
isLoadingComments ||
|
||||
(!comments.length && !inline) ? (
|
||||
<NodeNoComments loading={isLoadingComments || isLoading} />
|
||||
) : (
|
||||
<NodeComments order={commentsOrder} />
|
||||
)}
|
||||
</article>
|
||||
|
||||
{isUser && !isLoading && (
|
||||
<NodeCommentFormSSR nodeId={node.id} saveComment={onSaveComment} user={user} />
|
||||
<NodeCommentFormSSR
|
||||
nodeId={node.id}
|
||||
saveComment={onSaveComment}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
|
@ -66,7 +76,11 @@ const NodeBottomBlock: FC<IProps> = ({ commentsOrder }) => {
|
|||
<NodeTagsBlock />
|
||||
</div>
|
||||
<div className={styles.left_item}>
|
||||
<NodeRelatedBlock isLoading={isLoadingRelated} node={node} related={related} />
|
||||
<NodeRelatedBlock
|
||||
isLoading={isLoadingRelated}
|
||||
node={node}
|
||||
related={related}
|
||||
/>
|
||||
</div>
|
||||
</Sticky>
|
||||
</div>
|
||||
|
|
|
@ -34,15 +34,21 @@ const NodeComments: FC<IProps> = memo(({ order }) => {
|
|||
const groupped: ICommentGroup[] = useGrouppedComments(
|
||||
comments,
|
||||
order,
|
||||
lastSeenCurrent ?? undefined
|
||||
lastSeenCurrent ?? undefined,
|
||||
);
|
||||
|
||||
const more = useMemo(
|
||||
() =>
|
||||
hasMore && <div className={styles.more}>
|
||||
<LoadMoreButton isLoading={isLoadingMore} onClick={onLoadMoreComments} />
|
||||
</div>,
|
||||
[hasMore, onLoadMoreComments, isLoadingMore]
|
||||
hasMore &&
|
||||
!isLoading && (
|
||||
<div className={styles.more}>
|
||||
<LoadMoreButton
|
||||
isLoading={isLoadingMore}
|
||||
onClick={onLoadMoreComments}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[hasMore, onLoadMoreComments, isLoadingMore, isLoading],
|
||||
);
|
||||
|
||||
if (!node?.id) {
|
||||
|
@ -53,7 +59,7 @@ const NodeComments: FC<IProps> = memo(({ order }) => {
|
|||
<div className={styles.wrap}>
|
||||
{order === 'DESC' && more}
|
||||
|
||||
{groupped.map(group => (
|
||||
{groupped.map((group) => (
|
||||
<Comment
|
||||
nodeId={node.id!}
|
||||
key={group.ids.join()}
|
||||
|
|
71
src/containers/notifications/NotificationList/index.tsx
Normal file
71
src/containers/notifications/NotificationList/index.tsx
Normal 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 };
|
|
@ -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;
|
||||
}
|
||||
}
|
30
src/containers/notifications/NotificationSettings/index.tsx
Normal file
30
src/containers/notifications/NotificationSettings/index.tsx
Normal 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 };
|
|
@ -1,30 +1,40 @@
|
|||
import React, { FC, Fragment } from 'react';
|
||||
import React, { FC, Fragment, useCallback, useMemo } from 'react';
|
||||
|
||||
import { Superpower } from '~/components/boris/Superpower';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { Button } from '~/components/input/Button';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { Placeholder } from '~/components/placeholders/Placeholder';
|
||||
import { SOCIAL_ICONS } from '~/constants/auth/socials';
|
||||
import { Dialog } from '~/constants/modal';
|
||||
import { useOAuth } from '~/hooks/auth/useOAuth';
|
||||
import { useModal } from '~/hooks/modal/useModal';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
type ProfileAccountsProps = {};
|
||||
|
||||
const ProfileAccounts: FC<ProfileAccountsProps> = () => {
|
||||
const { isLoading, accounts, dropAccount, openOauthWindow } = useOAuth();
|
||||
const {
|
||||
isLoading,
|
||||
accounts,
|
||||
dropAccount,
|
||||
openOauthWindow,
|
||||
hasTelegram,
|
||||
showTelegramModal,
|
||||
} = useOAuth();
|
||||
|
||||
return (
|
||||
<Group className={styles.wrap}>
|
||||
<Group className={styles.info}>
|
||||
<p>
|
||||
Ты можешь входить в Убежище, используя аккаунты на других сайтах вместо ввода логина и
|
||||
пароля.
|
||||
Ты можешь входить в Убежище, используя аккаунты на других сайтах
|
||||
вместо ввода логина и пароля.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Мы честно украдём и будем хранить твои имя, фото и адрес на этом сайте, но никому о них не
|
||||
расскажем.
|
||||
Мы честно украдём и будем хранить твои имя, фото и адрес на этом
|
||||
сайте, но никому о них не расскажем.
|
||||
</p>
|
||||
</Group>
|
||||
|
||||
|
@ -42,11 +52,13 @@ const ProfileAccounts: FC<ProfileAccountsProps> = () => {
|
|||
{!isLoading && accounts.length > 0 && (
|
||||
<div className={styles.list}>
|
||||
{!isLoading &&
|
||||
accounts.map(it => (
|
||||
accounts.map((it) => (
|
||||
<div className={styles.account} key={`${it.provider}-${it.id}`}>
|
||||
<div
|
||||
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}>
|
||||
<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__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>
|
||||
))}
|
||||
|
@ -64,6 +80,19 @@ const ProfileAccounts: FC<ProfileAccountsProps> = () => {
|
|||
)}
|
||||
|
||||
<Group horizontal className={styles.buttons}>
|
||||
<Superpower>
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
iconLeft="telegram"
|
||||
color="gray"
|
||||
onClick={showTelegramModal}
|
||||
disabled={hasTelegram}
|
||||
>
|
||||
Телеграм
|
||||
</Button>
|
||||
</Superpower>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
type="button"
|
||||
|
|
|
@ -3,7 +3,7 @@ import React, { FC } from 'react';
|
|||
import { Avatar } from '~/components/common/Avatar';
|
||||
import { Markdown } from '~/components/containers/Markdown';
|
||||
import { Placeholder } from '~/components/placeholders/Placeholder';
|
||||
import { ImagePresets } from '~/constants/urls';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { IUser } from '~/types/auth';
|
||||
import { formatText } from '~/utils/dom';
|
||||
|
||||
|
@ -22,7 +22,7 @@ const ProfilePageLeft: FC<IProps> = ({ username, profile, isLoading }) => {
|
|||
username={username}
|
||||
url={profile?.photo?.url}
|
||||
className={styles.avatar}
|
||||
preset={ImagePresets['600']}
|
||||
preset={imagePresets['600']}
|
||||
/>
|
||||
|
||||
<div className={styles.region}>
|
||||
|
|
|
@ -2,6 +2,7 @@ import React, { useCallback, VFC } from 'react';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Superpower } from '~/components/boris/Superpower';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { Zone } from '~/components/containers/Zone';
|
||||
|
@ -12,6 +13,7 @@ import { ProfileStats } from '~/containers/profile/ProfileStats';
|
|||
import { ThemeSwitcher } from '~/containers/settings/ThemeSwitcher';
|
||||
import { useAuth } from '~/hooks/auth/useAuth';
|
||||
import markdown from '~/styles/common/markdown.module.scss';
|
||||
import { useNotifications } from '~/utils/providers/NotificationProvider';
|
||||
|
||||
import { ProfileSidebarLogoutButton } from '../ProfileSidebarLogoutButton';
|
||||
import { ProfileToggles } from '../ProfileToggles';
|
||||
|
@ -25,6 +27,7 @@ interface ProfileSidebarMenuProps {
|
|||
const ProfileSidebarMenu: VFC<ProfileSidebarMenuProps> = ({ onClose }) => {
|
||||
const { logout } = useAuth();
|
||||
const { setActiveTab } = useStackContext();
|
||||
const { hasNew } = useNotifications();
|
||||
|
||||
const onLogout = useCallback(() => {
|
||||
logout();
|
||||
|
@ -44,7 +47,16 @@ const ProfileSidebarMenu: VFC<ProfileSidebarMenuProps> = ({ onClose }) => {
|
|||
Настройки
|
||||
</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>
|
||||
|
|
|
@ -28,7 +28,7 @@ const ThemeSwitcher: FC<ThemeSwitcherProps> = () => {
|
|||
>
|
||||
<Group>
|
||||
<div className={styles.palette}>
|
||||
{item.colors.reverse().map((color) => (
|
||||
{item.colors.map((color) => (
|
||||
<div
|
||||
key={color}
|
||||
className={styles.sample}
|
||||
|
|
|
@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, VFC } from 'react';
|
|||
|
||||
import { CoverBackdrop } from '~/components/containers/CoverBackdrop';
|
||||
import { ProfileSidebarNotes } from '~/components/profile/ProfileSidebarNotes';
|
||||
import { ProfileSidebarNotifications } from '~/components/profile/ProfileSidebarNotifications';
|
||||
import { ProfileSidebarSettings } from '~/components/profile/ProfileSidebarSettings';
|
||||
import { SidebarStack } from '~/components/sidebar/SidebarStack';
|
||||
import { SidebarStackCard } from '~/components/sidebar/SidebarStackCard';
|
||||
|
@ -13,7 +14,7 @@ import { useUser } from '~/hooks/auth/useUser';
|
|||
import type { SidebarComponentProps } from '~/types/sidebar';
|
||||
import { isNil } from '~/utils/ramda';
|
||||
|
||||
const tabs = ['profile', 'bookmarks'] as const;
|
||||
const tabs = ['profile', 'notifications', 'bookmarks'] as const;
|
||||
type TabName = typeof tabs[number];
|
||||
|
||||
interface SettingsSidebarProps
|
||||
|
@ -71,6 +72,7 @@ const SettingsSidebar: VFC<SettingsSidebarProps> = ({
|
|||
|
||||
<SidebarStack.Cards>
|
||||
<ProfileSidebarSettings />
|
||||
<ProfileSidebarNotifications />
|
||||
<ProfileSidebarNotes />
|
||||
</SidebarStack.Cards>
|
||||
</SidebarStack>
|
||||
|
|
|
@ -16,5 +16,6 @@ export const useAuth = () => {
|
|||
setToken: auth.setToken,
|
||||
isTester: auth.isTester,
|
||||
setIsTester: auth.setIsTester,
|
||||
fetched: auth.fetched,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -43,8 +43,6 @@ export const useOAuth = () => {
|
|||
setToken(result.token);
|
||||
hideModal();
|
||||
} catch (error) {
|
||||
console.log(path(['response'], error));
|
||||
|
||||
const needsRegister = path(['response', 'status'], error) === 428;
|
||||
|
||||
if (needsRegister && token) {
|
||||
|
@ -97,8 +95,21 @@ export const useOAuth = () => {
|
|||
);
|
||||
|
||||
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 {
|
||||
hasTelegram,
|
||||
showTelegramModal,
|
||||
openOauthWindow,
|
||||
loginWithSocial,
|
||||
createSocialAccount,
|
||||
|
@ -106,5 +117,6 @@ export const useOAuth = () => {
|
|||
dropAccount,
|
||||
accounts,
|
||||
isLoading: !data && isLoading,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue