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