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

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

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

View file

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

4
.husky/pre-commit Executable file
View file

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

View file

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

View file

@ -2,7 +2,7 @@
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
const withTM = require('next-transpile-modules')(['ramda']);
const withTM = require('next-transpile-modules')(['ramda', '@v9v/ts-react-telegram-login']);
module.exports = withBundleAnalyzer(
withTM({
@ -22,5 +22,19 @@ module.exports = withBundleAnalyzer(
/** don't try to optimize fonts */
optimizeFonts: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.vault48.org',
pathname: '/**',
},
{
protocol: 'https',
hostname: '*.ytimg.com',
pathname: '/**',
},
],
},
})
);

View file

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

View file

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

View file

@ -0,0 +1,35 @@
import { API } from '~/constants/api';
import { NotificationSettings } from '~/types/notifications';
import { api, cleanResult } from '~/utils/api';
import {
notificationSettingsFromRequest,
notificationSettingsToRequest,
} from '~/utils/notifications/notificationSettingsFromRequest';
import {
ApiGetNotificationSettingsResponse,
ApiGetNotificationsResponse,
ApiUpdateNotificationSettingsResponse,
} from './types';
export const apiGetNotificationSettings = (): Promise<NotificationSettings> =>
api
.get<ApiGetNotificationSettingsResponse>(API.NOTIFICATIONS.SETTINGS)
.then(cleanResult)
.then(notificationSettingsFromRequest);
export const apiGetNotifications = () =>
api
.get<ApiGetNotificationsResponse>(API.NOTIFICATIONS.LIST)
.then(cleanResult);
export const apiUpdateNotificationSettings = (
settings: Partial<NotificationSettings>,
) =>
api
.post<ApiUpdateNotificationSettingsResponse>(
API.NOTIFICATIONS.SETTINGS,
notificationSettingsToRequest(settings),
)
.then(cleanResult)
.then(notificationSettingsFromRequest);

View file

@ -0,0 +1,19 @@
import { NotificationItem } from '~/types/notifications';
export interface ApiGetNotificationSettingsResponse {
enabled: boolean;
flow: boolean;
comments: boolean;
send_telegram: boolean;
show_indicator: boolean;
last_seen?: string | null;
last_date?: string | null;
}
export type ApiUpdateNotificationSettingsResponse =
ApiGetNotificationSettingsResponse;
export type ApiUpdateNotificationSettingsRequest =
Partial<ApiGetNotificationSettingsResponse>;
export interface ApiGetNotificationsResponse {
items?: NotificationItem[];
}

View file

@ -0,0 +1,46 @@
import React, { FC } from 'react';
import TelegramLoginButton, {
TelegramUser,
} from '@v9v/ts-react-telegram-login';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import styles from './styles.module.scss';
interface TelegramLoginFormProps {
botName: string;
loading?: boolean;
onSuccess?: (token: TelegramUser) => void;
}
const TelegramLoginForm: FC<TelegramLoginFormProps> = ({
botName,
loading,
onSuccess,
}) => {
return (
<div className={styles.container}>
<div className={styles.text}>
{loading ? (
<LoaderCircle />
) : (
<div>
После успешной авторизации аккаунт появится в настройках вашего
профиля
</div>
)}
</div>
<div className={styles.button}>
<TelegramLoginButton
dataOnAuth={onSuccess}
botName={botName}
requestAccess
/>
</div>
</div>
);
};
export { TelegramLoginForm };

View file

@ -0,0 +1,22 @@
@import 'src/styles/variables';
.button {
flex: 0 0 48px;
}
.container {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: $gap;
}
.text {
text-align: center;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}

View file

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

View file

@ -117,35 +117,6 @@
touch-action: none;
}
.images {
cursor: pointer;
img {
max-height: 400px;
border-radius: $radius;
max-width: 100%;
}
&.multiple {
img {
max-height: none;
}
// Desktop devices
@include flexbin(25vh, $flexbin-space);
// Tablet devices
@media (max-width: $flexbin-tablet-max) {
@include flexbin($flexbin-row-height-tablet, $flexbin-space-tablet);
}
// Phone devices
@media (max-width: $flexbin-phone-max) {
@include flexbin($flexbin-row-height-phone, $flexbin-space-phone);
}
}
}
.audios {
& > div {
height: $comment_height;

View file

@ -0,0 +1,49 @@
import { FC } from 'react';
import classNames from 'classnames';
import { Hoverable } from '~/components/common/Hoverable';
import { Icon } from '~/components/input/Icon';
import { imagePresets } from '~/constants/urls';
import { IFile } from '~/types';
import { getURL } from '~/utils/dom';
import { getFileSrcSet } from '~/utils/srcset';
import styles from './styles.module.scss';
interface CommentImageGridProps {
files: IFile[];
onClick: (file: IFile) => void;
}
const singleSrcSet = '(max-width: 1024px) 40vw, 20vw';
const multipleSrcSet = '(max-width: 1024px) 50vw, 20vw';
const CommentImageGrid: FC<CommentImageGridProps> = ({ files, onClick }) => {
return (
<div
className={classNames(styles.images, {
[styles.multiple]: files.length > 1,
})}
>
{files.map((file) => (
<Hoverable
key={file.id}
onClick={() => onClick(file)}
className={styles.item}
icon={<Icon icon="zoom" size={30} />}
>
<img
srcSet={getFileSrcSet(file)}
src={getURL(file, imagePresets['300'])}
alt={file.name}
className={styles.image}
sizes={files.length > 1 ? singleSrcSet : multipleSrcSet}
/>
</Hoverable>
))}
</div>
);
};
export { CommentImageGrid };

View file

@ -0,0 +1,37 @@
@import 'src/styles/variables';
@import '~flexbin/flexbin';
.images {
cursor: pointer;
overflow: visible !important;
&.multiple {
// Desktop devices
@include flexbin(25vh, $flexbin-space);
// Tablet devices
@media (max-width: $flexbin-tablet-max) {
@include flexbin($flexbin-row-height-tablet, $flexbin-space-tablet);
}
// Phone devices
@media (max-width: $flexbin-phone-max) {
@include flexbin($flexbin-row-height-phone, $flexbin-space-phone);
}
}
}
.image {
max-height: 400px;
border-radius: $radius;
max-width: 100%;
.multiple & {
max-height: 250px;
max-inline-size: 250px;
}
}
.item {
border-radius: $radius;
}

View file

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

View file

@ -1,5 +1,20 @@
@import 'src/styles/variables';
.container {
&.has_dot::after {
content: ' ';
position: absolute;
bottom: 0;
right: 0;
width: 8px;
height: 8px;
border-radius: 8px;
background-color: $color_danger;
z-index: 1;
box-shadow: $content_bg 0 0 0 2px;
}
}
.avatar {
@include outer_shadow;
@ -12,6 +27,7 @@
background-position: center;
background-size: cover;
cursor: pointer;
position: relative;
img {
object-fit: cover;

View file

@ -0,0 +1,34 @@
import React, { FC, ReactNode } from 'react';
import classNames from 'classnames';
import { DivProps } from '~/utils/types';
import styles from './styles.module.scss';
type HoverableEffect = 'rise' | 'shine';
interface HoverableProps extends DivProps {
icon?: ReactNode;
effect?: HoverableEffect;
}
const Hoverable: FC<HoverableProps> = ({
children,
className,
icon,
effect = 'rise',
...rest
}) => (
<div
{...rest}
className={classNames(styles.hoverable, styles[effect], className, {
[styles.with_icon]: !!icon,
})}
>
{icon && <div className={styles.icon}>{icon}</div>}
{children}
</div>
);
export { Hoverable };

View file

@ -0,0 +1,71 @@
@import 'src/styles/variables';
.hoverable {
position: relative;
cursor: pointer;
&::after {
content: ' ';
position: absolute;
inset: 0;
border-radius: $radius;
opacity: 0;
transition: all 100ms;
touch-action: none;
pointer-events: none;
}
&.with_icon::after {
background: linear-gradient(325deg, $color_primary 20px, transparent 100px);
}
}
.hoverable.rise {
@media (hover: hover) {
&:hover {
z-index: 10;
transition: all 100ms;
transform: scale(1.025) translateY(-2%);
box-shadow: rgba(0, 0, 0, 0.5) 0 10px 10px 5px;
&::after {
opacity: 1;
}
}
}
}
.hoverable.shine {
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
150deg,
#{transparentize(yellow, 0.75)},
transparent
);
z-index: 1;
border-radius: $radius;
opacity: 0;
pointer-events: none;
touch-action: none;
transition: all 250ms;
}
&:hover::before {
opacity: 1;
}
}
.icon {
position: absolute;
bottom: 4px;
right: 4px;
z-index: 2;
opacity: 0;
.hoverable:hover & {
opacity: 1;
}
}

View file

@ -0,0 +1,52 @@
import React, {
CSSProperties,
FC,
useCallback,
useMemo,
useReducer,
useState,
} from 'react';
import classNames from 'classnames';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { DivProps } from '~/utils/types';
import styles from './styles.module.scss';
interface ImageLoadingWrapperProps extends Omit<DivProps, 'children'> {
children: (props: { loading: boolean; onLoad: () => void }) => void;
preview?: string;
}
const ImageLoadingWrapper: FC<ImageLoadingWrapperProps> = ({
className,
children,
preview,
color,
...props
}) => {
const [loading, onLoad] = useReducer((v) => false, true);
const style = useMemo<CSSProperties>(
() => ({
backgroundImage: `url('${preview}')`,
backgroundColor: color || 'var(--color-primary)',
}),
[preview, color],
);
return (
<div className={classNames(styles.wrapper, className)} {...props}>
{!!loading && !!preview && (
<div className={styles.preview}>
<div className={styles.thumbnail} style={style} />
<LoaderCircle size={32} />
</div>
)}
{children({ loading, onLoad })}
</div>
);
};
export { ImageLoadingWrapper };

View file

@ -0,0 +1,26 @@
@import 'src/styles/variables';
.wrapper {
position: relative;
}
.preview {
position: absolute;
inset: 0;
pointer-events: none;
touch-action: none;
display: flex;
align-items: flex-end;
justify-content: flex-end;
padding: $gap;
}
.thumbnail {
filter: blur(10px);
position: absolute;
inset: 0;
border-radius: $radius;
background-size: cover;
background-position: 50% 50%;
box-sizing: border-box;
}

View file

@ -0,0 +1,20 @@
import React, { FC } from 'react';
import { useColorFromString } from '~/hooks/color/useColorFromString';
import styles from './styles.module.scss';
interface InlineUsernameProps {
children: string;
}
const InlineUsername: FC<InlineUsernameProps> = ({ children }) => {
const backgroundColor = useColorFromString(children);
return (
<span style={{ backgroundColor }} className={styles.username}>
~{children}
</span>
);
};
export { InlineUsername };

View file

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

View file

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

View file

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

View file

@ -1,7 +1,9 @@
import React, { FC } from 'react';
import React, { FC, useEffect, useLayoutEffect, useRef, useState } from 'react';
import Masonry from 'react-masonry-css';
import { useScrollEnd } from '~/hooks/dom/useScrollEnd';
import styles from './styles.module.scss';
const defaultColumns = {
@ -11,12 +13,42 @@ const defaultColumns = {
interface ColumnsProps {
cols?: Record<number, number>;
onScrollEnd?: () => void;
hasMore?: boolean;
}
const Columns: FC<ColumnsProps> = ({ children, cols = defaultColumns }) => (
<Masonry className={styles.wrap} breakpointCols={cols} columnClassName={styles.column}>
{children}
</Masonry>
);
const Columns: FC<ColumnsProps> = ({
children,
cols = defaultColumns,
onScrollEnd,
hasMore,
}) => {
const ref = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState<Element[]>([]);
useEffect(() => {
const childs = ref.current?.querySelectorAll(`.${styles.column}`);
if (!childs) return;
const timeout = setTimeout(() => setColumns([...childs]), 150);
return () => clearTimeout(timeout);
}, [ref.current]);
useScrollEnd(columns, onScrollEnd, { active: hasMore, threshold: 2 });
return (
<div ref={ref}>
<Masonry
className={styles.wrap}
breakpointCols={cols}
columnClassName={styles.column}
>
{children}
</Masonry>
</div>
);
};
export { Columns };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,19 @@
import React, { FC, ReactNode } from 'react';
import classNames from 'classnames';
import styles from './styles.module.scss';
interface InputRowProps {
className?: string;
input?: ReactNode;
}
const InputRow: FC<InputRowProps> = ({ children, input, className }) => (
<div className={classNames(styles.row, className)}>
<div>{children}</div>
{!!input && <div>{input}</div>}
</div>
);
export { InputRow };

View file

@ -0,0 +1,9 @@
@import 'src/styles/variables';
.row {
display: grid;
grid-template-columns: 1fr auto;
row-gap: $gap;
align-items: center;
font: $font_14_medium;
}

View file

@ -0,0 +1,25 @@
import React, { FC } from 'react';
import classNames from 'classnames';
import { LoaderCircle } from '../LoaderCircle';
import styles from './styles.module.scss';
interface LoaderScreenProps {
className?: string;
align?: 'top' | 'middle';
}
const LoaderScreen: FC<LoaderScreenProps> = ({
className,
align = 'middle',
}) => (
<div
className={classNames(styles.screen, styles[`align-${align}`], className)}
>
<LoaderCircle size={32} />
</div>
);
export { LoaderScreen };

View file

@ -0,0 +1,14 @@
@import 'src/styles/variables';
.screen {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: $gap;
&.align-top {
align-items: flex-start;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -33,6 +33,19 @@ a.item {
cursor: pointer;
background-color: transparent;
transition: background-color 0.25s;
position: relative;
&.has_dot::after {
content: ' ';
position: absolute;
top: 50%;
right: 10px;
width: 8px;
height: 8px;
background: $color_danger;
border-radius: 8px;
transform: translate(0, -50%);
}
&:hover {
background-color: $content_bg_success;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import React, { memo, VFC } from 'react';
import classNames from 'classnames';
import { Authorized } from '~/components/containers/Authorized';
import { Icon } from '~/components/input/Icon';
import { SeparatedMenu } from '~/components/menu/SeparatedMenu';
import { NodeEditMenu } from '~/components/node/NodeEditMenu';
@ -76,37 +77,39 @@ const NodeTitle: VFC<IProps> = memo(
)}
</div>
<SeparatedMenu className={styles.buttons}>
{canEdit && (
<NodeEditMenu
className={styles.button}
canStar={canStar}
isHeroic={isHeroic}
isLocked={isLocked}
onStar={onStar}
onLock={onLock}
onEdit={onEdit}
/>
)}
<Authorized>
<SeparatedMenu className={styles.buttons}>
{canEdit && (
<NodeEditMenu
className={styles.button}
canStar={canStar}
isHeroic={isHeroic}
isLocked={isLocked}
onStar={onStar}
onLock={onLock}
onEdit={onEdit}
/>
)}
{canLike && (
<div
className={classNames(styles.button, styles.like, {
[styles.is_liked]: isLiked,
})}
>
{isLiked ? (
<Icon icon="heart_full" size={24} onClick={onLike} />
) : (
<Icon icon="heart" size={24} onClick={onLike} />
)}
{canLike && (
<div
className={classNames(styles.button, styles.like, {
[styles.is_liked]: isLiked,
})}
>
{isLiked ? (
<Icon icon="heart_full" size={24} onClick={onLike} />
) : (
<Icon icon="heart" size={24} onClick={onLike} />
)}
{!!likeCount && likeCount > 0 && (
<div className={styles.like_count}>{likeCount}</div>
)}
</div>
)}
</SeparatedMenu>
{!!likeCount && likeCount > 0 && (
<div className={styles.like_count}>{likeCount}</div>
)}
</div>
)}
</SeparatedMenu>
</Authorized>
</div>
</div>
);

View file

@ -0,0 +1,77 @@
import React, { FC } from 'react';
import { Anchor } from '~/components/common/Anchor';
import { InlineUsername } from '~/components/common/InlineUsername';
import { Square } from '~/components/common/Square';
import { Card } from '~/components/containers/Card';
import { FlowRecentItem } from '~/components/flow/FlowRecentItem';
import { NotificationItem, NotificationType } from '~/types/notifications';
import { formatText, getPrettyDate, getURLFromString } from '~/utils/dom';
import styles from './styles.module.scss';
interface NotificationBadgeProps {
item: NotificationItem;
}
const getTitle = (item: NotificationItem) => {
if (!item.user.username) {
return '';
}
switch (item.type) {
case NotificationType.Comment:
return (
<span>
<InlineUsername>{item.user.username}</InlineUsername> пишет:
</span>
);
case NotificationType.Node:
return (
<span>
Новый пост от <InlineUsername>{item.user.username}</InlineUsername>:
</span>
);
}
};
const getContent = (item: NotificationItem) => {
switch (item.type) {
case NotificationType.Comment:
return (
<div
dangerouslySetInnerHTML={{
__html: formatText(item.text),
}}
/>
);
case NotificationType.Node:
return (
<div
dangerouslySetInnerHTML={{
__html: formatText(item.text),
}}
/>
);
}
};
const getIcon = (item: NotificationItem) => {
return <Square image={getURLFromString(item.thumbnail, 'avatar')} />;
};
const NotificationBadge: FC<NotificationBadgeProps> = ({ item }) => (
<Anchor href={item.url} className={styles.link}>
<div className={styles.message}>
<div className={styles.icon}>{getIcon(item)}</div>
<div>
<b className={styles.title}>{getTitle(item)}</b>
<div className={styles.text}>{getContent(item)}</div>
<div className={styles.time}>{getPrettyDate(item.created_at)}</div>
</div>
</div>
</Anchor>
);
export { NotificationBadge };

View file

@ -0,0 +1,33 @@
@import 'src/styles/variables';
.link {
text-decoration: none;
color: inherit;
}
.message {
font: $font_14_regular;
line-height: 1.3em;
padding: $gap/2 $gap/2 $gap/4 $gap/2;
min-height: calc(1.3em * 3 + $gap);
display: grid;
grid-template-columns: 40px auto;
column-gap: $gap;
}
.text {
@include clamp(2, 14px);
text-overflow: ellipsis;
}
.title {
font: $font_14_semibold;
margin-bottom: $gap / 2;
}
.time {
font: $font_10_regular;
text-align: right;
margin-top: 2px;
color: $gray_75;
}

View file

@ -1,47 +0,0 @@
import React, { createElement, FC } from 'react';
import { Icon } from '~/components/input/Icon';
import { useRandomPhrase } from '~/constants/phrases';
import { INotification, NOTIFICATION_TYPES } from '~/types';
import { NotificationMessage } from '../NotificationMessage';
import styles from './styles.module.scss';
interface IProps {
notifications: INotification[];
onClick: (notification: INotification) => void;
}
const NOTIFICATION_RENDERERS = {
[NOTIFICATION_TYPES.message]: NotificationMessage,
};
const NotificationBubble: FC<IProps> = ({ notifications, onClick }) => {
const placeholder = useRandomPhrase('NOTHING_HERE');
return (
<div className={styles.wrap}>
<div className={styles.list}>
{notifications.length === 0 && (
<div className={styles.placeholder}>
<Icon icon="bell_ring" />
<div>{placeholder}</div>
</div>
)}
{notifications.length > 0 &&
notifications
.filter(notification => notification.type && NOTIFICATION_RENDERERS[notification.type])
.map(notification =>
createElement(NOTIFICATION_RENDERERS[notification.type], {
notification,
onClick,
key: notification.content.id,
})
)}
</div>
</div>
);
};
export { NotificationBubble };

View file

@ -1,106 +0,0 @@
@import 'src/styles/variables';
$notification_color: $content_bg_dark;
@keyframes appear {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.wrap {
position: absolute;
background: $notification_color;
top: 42px;
left: 50%;
transform: translate(-50%, 0);
border-radius: $radius;
animation: appear 0.25s forwards;
z-index: 2;
&::before {
content: ' ';
width: 0;
height: 0;
border-style: solid;
border-width: 0 0 16px 16px;
border-color: transparent transparent $notification_color transparent;
position: absolute;
left: 50%;
top: -16px;
transform: translate(-20px, 0);
}
}
.list {
width: 300px;
max-width: 100vw;
min-width: 0;
max-height: 400px;
overflow: auto;
}
.item {
display: flex;
align-items: stretch;
justify-content: stretch;
flex-direction: column;
padding: $gap;
min-width: 0;
cursor: pointer;
svg {
fill: white;
margin-right: $gap;
}
}
.item_head {
display: flex;
align-items: center;
justify-content: flex-start;
flex-direction: row;
}
.item_title {
flex: 1;
white-space: nowrap;
font: $font_14_semibold;
overflow: hidden;
text-overflow: ellipsis;
// text-transform: none;
}
.item_text {
font: $font_14_regular;
max-height: 2.4em;
padding-left: 30px;
overflow: hidden;
}
.placeholder {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-transform: uppercase;
font: $font_16_semibold;
box-sizing: border-box;
padding: 80px;
text-align: center;
line-height: 1.6em;
svg {
width: 120px;
height: 120px;
opacity: 0.05;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}

View file

@ -0,0 +1,54 @@
import { FC } from 'react';
import { Anchor } from '~/components/common/Anchor';
import { Avatar } from '~/components/common/Avatar';
import { InlineUsername } from '~/components/common/InlineUsername';
import { Square } from '~/components/common/Square';
import { NotificationItem } from '~/types/notifications';
import { formatText, getPrettyDate, getURLFromString } from '~/utils/dom';
import styles from './styles.module.scss';
interface NotificationCommentProps {
item: NotificationItem;
}
const NotificationComment: FC<NotificationCommentProps> = ({ item }) => (
<Anchor href={item.url} className={styles.link}>
<div className={styles.message}>
<div className={styles.icon}>
<Avatar
size={32}
url={item.user?.photo}
username={item.user?.username}
className={styles.circle}
/>
</div>
<div className={styles.content}>
<b className={styles.title}>
<span>
<InlineUsername>{item.user.username}</InlineUsername>
</span>
<span>-</span>
<Square
className={styles.item_image}
size={16}
image={getURLFromString(item.thumbnail, 'avatar')}
/>
<div className={styles.item_title}>{item.title}</div>
</b>
<div className={styles.text}>
<div
dangerouslySetInnerHTML={{
__html: formatText(item.text),
}}
/>
</div>
</div>
</div>
</Anchor>
);
export { NotificationComment };

View file

@ -0,0 +1,72 @@
@import 'src/styles/variables';
.link {
text-decoration: none;
color: inherit;
}
.message {
font: $font_14_regular;
line-height: 1.3em;
min-height: calc(1.3em * 3 + $gap);
display: grid;
grid-template-columns: 32px auto;
column-gap: 0;
}
.content {
background: $content_bg;
padding: 5px;
border-radius: 0 $radius $radius $radius;
position: relative;
min-width: 0;
&:before {
content: ' ';
position: absolute;
top: 8px;
right: 100%;
@include arrow_left(8px, $content_bg);
}
}
.text {
@include clamp(2, 14px);
text-overflow: ellipsis;
}
.title {
font: $font_14_medium;
margin-bottom: $gap / 2;
display: flex;
flex-direction: row;
align-items: center;
& > * {
padding-right: 5px;
}
}
.item_title {
flex: 1;
padding-left: 5px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.item_image {
flex: 0 0 16px;
border-radius: 2px;
}
.time {
font: $font_10_regular;
text-align: right;
margin-top: 2px;
color: $gray_75;
}
div.circle {
border-radius: 4px 0 0 4px;
}

View file

@ -1,32 +0,0 @@
import React, { FC, useCallback } from 'react';
import { Icon } from '~/components/input/Icon';
import styles from '~/components/notifications/NotificationBubble/styles.module.scss';
import { IMessageNotification, INotification } from '~/types';
interface IProps {
notification: IMessageNotification;
onClick: (notification: INotification) => void;
}
const NotificationMessage: FC<IProps> = ({
notification,
notification: {
content: { text, from },
},
onClick,
}) => {
const onMouseDown = useCallback(() => onClick(notification), [onClick, notification]);
return (
<div className={styles.item} onMouseDown={onMouseDown}>
<div className={styles.item_head}>
<Icon icon="message" />
<div className={styles.item_title}>Сообщение от ~{from?.username}:</div>
</div>
<div className={styles.item_text}>{text}</div>
</div>
);
};
export { NotificationMessage };

View file

@ -0,0 +1,39 @@
import { FC, useMemo } from 'react';
import { NodeThumbnail } from '~/components/node/NodeThumbnail';
import { NotificationItem } from '~/types/notifications';
import { getPrettyDate } from '~/utils/dom';
import styles from './styles.module.scss';
interface NotificationNodeProps {
item: NotificationItem;
}
const NotificationNode: FC<NotificationNodeProps> = ({ item }) => {
const thumbnail = useMemo(
() => ({
title: item.title,
thumbnail: item.thumbnail,
is_promoted: true,
}),
[item],
);
return (
<div className={styles.card}>
<div className={styles.image}>
<NodeThumbnail item={thumbnail} />
</div>
<div className={styles.text}>
<div className={styles.title}>{item.title || '...'}</div>
<div className={styles.user}>
~{item.user.username}, {getPrettyDate(item.created_at)}
</div>
</div>
</div>
);
};
export { NotificationNode };

View file

@ -0,0 +1,36 @@
@import 'src/styles/variables';
.card {
background-color: $content_bg;
display: flex;
flex-direction: row;
border-radius: $radius;
justify-content: center;
align-items: center;
padding: $gap/2;
}
.text {
flex: 1;
min-width: 0;
padding-left: $gap;
margin-top: -0.2em;
}
.title {
font-size: 1.2em;
font-weight: bold;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
text-transform: capitalize;
}
.user {
font-size: 0.7em;
color: $gray_50;
}
.image {
flex: 0 0 48px;
}

View file

@ -0,0 +1,86 @@
import React, { FC, useCallback } from 'react';
import { Card } from '~/components/containers/Card';
import { Group } from '~/components/containers/Group';
import { Zone } from '~/components/containers/Zone';
import { Button } from '~/components/input/Button';
import { InputRow } from '~/components/input/InputRow';
import { Toggle } from '~/components/input/Toggle';
import { useNotificationSettingsForm } from '~/hooks/notifications/useNotificationSettingsForm';
import { NotificationSettings } from '~/types/notifications';
import styles from './styles.module.scss';
interface NotificationSettingsFormProps {
value: NotificationSettings;
onSubmit: (val: Partial<NotificationSettings>) => Promise<unknown>;
telegramConnected: boolean;
onConnectTelegram: () => void;
}
const NotificationSettingsForm: FC<NotificationSettingsFormProps> = ({
value,
onSubmit,
telegramConnected,
onConnectTelegram,
}) => {
const { setFieldValue, values } = useNotificationSettingsForm(
value,
onSubmit,
);
const toggle = useCallback(
(key: keyof NotificationSettings, disabled?: boolean) => (
<Toggle
handler={(val) => setFieldValue(key, val)}
value={values[key]}
disabled={disabled}
/>
),
[setFieldValue, values],
);
const telegramInput = telegramConnected ? (
toggle('sendTelegram', !values.enabled)
) : (
<Button size="micro" onClick={onConnectTelegram}>
Подключить
</Button>
);
return (
<Group>
<Card>
<InputRow input={toggle('enabled')}>Получать уведомления</InputRow>
</Card>
<div />
<Zone title="Типы уведомлений">
<Group>
<InputRow input={toggle('flow', !values.enabled)}>
Новые посты
</InputRow>
<InputRow input={toggle('comments', !values.enabled)}>
Комментарии
</InputRow>
</Group>
</Zone>
<div />
<Zone title="Способы доставки">
<Group>
<InputRow input={toggle('showIndicator', !values.enabled)}>
На иконке профиля
</InputRow>
<InputRow input={telegramInput}>Телеграм</InputRow>
</Group>
</Zone>
</Group>
);
};
export { NotificationSettingsForm };

View file

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

View file

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

View file

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

View file

@ -0,0 +1,64 @@
import { useState, VFC } from 'react';
import { Group } from '~/components/containers/Group';
import { Button } from '~/components/input/Button';
import { Icon } from '~/components/input/Icon';
import { HorizontalMenu } from '~/components/menu/HorizontalMenu';
import { useStackContext } from '~/components/sidebar/SidebarStack';
import { SidebarStackCard } from '~/components/sidebar/SidebarStackCard';
import { NotificationList } from '~/containers/notifications/NotificationList';
import { NotificationSettings } from '~/containers/notifications/NotificationSettings';
import { useNotificationSettings } from '~/hooks/notifications/useNotificationSettings';
import styles from './styles.module.scss';
interface ProfileSidebarNotificationsProps {}
enum Tabs {
List,
Settings,
}
const ProfileSidebarNotifications: VFC<
ProfileSidebarNotificationsProps
> = () => {
const { closeAllTabs } = useStackContext();
const [tab, setTab] = useState(Tabs.List);
const { loading } = useNotificationSettings();
return (
<SidebarStackCard width={400} onBackPress={closeAllTabs}>
<div className={styles.grid}>
<Group className={styles.head} horizontal>
<HorizontalMenu className={styles.tabs}>
<HorizontalMenu.Item
active={tab === Tabs.List}
isLoading={loading}
onClick={() => setTab(Tabs.List)}
stretchy
>
Уведомления
</HorizontalMenu.Item>
<HorizontalMenu.Item
active={tab === Tabs.Settings}
isLoading={loading}
onClick={() => setTab(Tabs.Settings)}
stretchy
>
Настройки
</HorizontalMenu.Item>
</HorizontalMenu>
<Button iconLeft="right" color="link" onClick={closeAllTabs} />
</Group>
<div className={styles.list}>
{tab === Tabs.List ? <NotificationList /> : <NotificationSettings />}
</div>
</div>
</SidebarStackCard>
);
};
export { ProfileSidebarNotifications };

View file

@ -0,0 +1,31 @@
@import 'src/styles/variables';
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
z-index: 4;
height: 100%;
}
.head {
@include row_shadow;
width: 100%;
padding: $gap;
flex: 0;
}
.tabs {
flex: 1;
}
.list {
@include row_shadow;
overflow-y: auto;
flex: 1 1;
overflow: auto;
width: 100%;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,49 @@
import React, { FC, useCallback, useMemo } from 'react';
import { TelegramUser } from '@v9v/ts-react-telegram-login';
import { Padder } from '~/components/containers/Padder';
import { Button } from '~/components/input/Button';
import { useTelegramAccount } from '~/hooks/auth/useTelegramAccount';
import { DialogComponentProps } from '~/types/modal';
import { TelegramLoginForm } from '../../../components/auth/oauth/TelegramLoginForm/index';
import { BetterScrollDialog } from '../../../components/dialogs/BetterScrollDialog';
interface TelegramAttachDialogProps extends DialogComponentProps {}
const botName = process.env.NEXT_PUBLIC_BOT_USERNAME;
const TelegramAttachDialog: FC<TelegramAttachDialogProps> = ({
onRequestClose,
}) => {
const { attach } = useTelegramAccount();
const onAttach = useCallback(
(data: TelegramUser) => attach(data, onRequestClose),
[onRequestClose],
);
const buttons = useMemo(
() => (
<Padder>
<Button stretchy onClick={onRequestClose}>
Отмена
</Button>
</Padder>
),
[onRequestClose],
);
if (!botName) {
onRequestClose();
return null;
}
return (
<BetterScrollDialog width={300} onClose={onRequestClose} footer={buttons}>
<TelegramLoginForm botName={botName} onSuccess={onAttach} />
</BetterScrollDialog>
);
};
export { TelegramAttachDialog };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,57 @@
@import 'src/styles/variables';
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
z-index: 4;
height: 100%;
}
.head {
@include row_shadow;
width: 100%;
padding: $gap;
}
.list {
@include row_shadow;
overflow-y: auto;
flex: 1 1;
overflow: auto;
width: 100%;
position: relative;
&.inactive {
opacity: 0.3;
overflow: hidden;
&::after {
content: ' ';
inset: 0;
position: absolute;
background: linear-gradient(transparent, $content_bg_backdrop);
pointer-events: none;
touch-action: none;
}
}
}
.items {
display: grid;
grid-auto-flow: row;
}
.item {
@include row_shadow;
padding: $gap / 2 $gap $gap / 2 $gap / 2;
transition: background-color 0.25s;
min-width: 0;
&:hover {
background-color: $content_bg_lighter;
}
}

View file

@ -0,0 +1,30 @@
import { FC } from 'react';
import { Padder } from '~/components/containers/Padder';
import { NotificationSettingsForm } from '~/components/notifications/NotificationSettingsForm';
import { useOAuth } from '~/hooks/auth/useOAuth';
import { useNotificationSettings } from '~/hooks/notifications/useNotificationSettings';
interface NotificationSettingsProps {}
const NotificationSettings: FC<NotificationSettingsProps> = () => {
const { settings, update } = useNotificationSettings();
const { hasTelegram, showTelegramModal } = useOAuth();
if (!settings) {
return <>{null}</>;
}
return (
<Padder>
<NotificationSettingsForm
value={settings}
onSubmit={update}
telegramConnected={hasTelegram}
onConnectTelegram={showTelegramModal}
/>
</Padder>
);
};
export { NotificationSettings };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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