mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 04:46:40 +07:00
#35 finally using swiperjs as image slider
This commit is contained in:
parent
d3473eab4c
commit
68249a4c62
11 changed files with 202 additions and 29 deletions
|
@ -7,6 +7,7 @@
|
||||||
"@testing-library/jest-dom": "^5.11.4",
|
"@testing-library/jest-dom": "^5.11.4",
|
||||||
"@testing-library/react": "^11.1.0",
|
"@testing-library/react": "^11.1.0",
|
||||||
"@testing-library/user-event": "^12.1.10",
|
"@testing-library/user-event": "^12.1.10",
|
||||||
|
"@types/swiper": "^5.4.2",
|
||||||
"autosize": "^4.0.2",
|
"autosize": "^4.0.2",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.1",
|
||||||
"body-scroll-lock": "^2.6.4",
|
"body-scroll-lock": "^2.6.4",
|
||||||
|
@ -34,6 +35,7 @@
|
||||||
"redux-saga": "^1.1.1",
|
"redux-saga": "^1.1.1",
|
||||||
"resize-sensor": "^0.0.6",
|
"resize-sensor": "^0.0.6",
|
||||||
"sticky-sidebar": "^3.3.1",
|
"sticky-sidebar": "^3.3.1",
|
||||||
|
"swiper": "^6.5.0",
|
||||||
"throttle-debounce": "^2.1.0",
|
"throttle-debounce": "^2.1.0",
|
||||||
"typescript": "^4.0.5",
|
"typescript": "^4.0.5",
|
||||||
"uuid4": "^1.1.4",
|
"uuid4": "^1.1.4",
|
||||||
|
|
|
@ -8,6 +8,8 @@ import { selectUploads } from '~/redux/uploads/selectors';
|
||||||
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
import { NodeEditorProps } from '~/redux/node/types';
|
import { NodeEditorProps } from '~/redux/node/types';
|
||||||
|
import { useNodeImages } from '~/utils/hooks/node/useNodeImages';
|
||||||
|
import { useNodeAudios } from '~/utils/hooks/node/useNodeAudios';
|
||||||
|
|
||||||
const mapStateToProps = selectUploads;
|
const mapStateToProps = selectUploads;
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
|
@ -17,10 +19,7 @@ const mapDispatchToProps = {
|
||||||
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & NodeEditorProps;
|
type IProps = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & NodeEditorProps;
|
||||||
|
|
||||||
const AudioEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) => {
|
const AudioEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) => {
|
||||||
const images = useMemo(
|
const images = useNodeImages(data);
|
||||||
() => data.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE),
|
|
||||||
[data.files]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pending_images = useMemo(
|
const pending_images = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
@ -30,10 +29,7 @@ const AudioEditorUnconnected: FC<IProps> = ({ data, setData, temp, statuses }) =
|
||||||
[temp, statuses]
|
[temp, statuses]
|
||||||
);
|
);
|
||||||
|
|
||||||
const audios = useMemo(
|
const audios = useNodeAudios(data);
|
||||||
() => data.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO),
|
|
||||||
[data.files]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pending_audios = useMemo(
|
const pending_audios = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
@ -1,7 +1,83 @@
|
||||||
import React, { FC } from 'react';
|
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { INodeComponentProps } from '~/redux/node/constants';
|
||||||
|
import SwiperCore, { A11y, Pagination, SwiperOptions } from 'swiper';
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
|
import 'swiper/swiper.scss';
|
||||||
|
import 'swiper/components/navigation/navigation.scss';
|
||||||
|
import 'swiper/components/pagination/pagination.scss';
|
||||||
|
import 'swiper/components/scrollbar/scrollbar.scss';
|
||||||
|
|
||||||
interface IProps {}
|
import styles from './styles.module.scss';
|
||||||
|
import { useNodeImages } from '~/utils/hooks/node/useNodeImages';
|
||||||
|
import { getURL } from '~/utils/dom';
|
||||||
|
import { PRESETS } from '~/constants/urls';
|
||||||
|
import SwiperClass from 'swiper/types/swiper-class';
|
||||||
|
|
||||||
const NodeImageSwiperBlock: FC<IProps> = () => <div>SWIPER</div>;
|
SwiperCore.use([Pagination, A11y]);
|
||||||
|
|
||||||
|
interface IProps extends INodeComponentProps {}
|
||||||
|
|
||||||
|
const breakpoints: SwiperOptions['breakpoints'] = {
|
||||||
|
599: {
|
||||||
|
spaceBetween: 20,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const NodeImageSwiperBlock: FC<IProps> = ({ node }) => {
|
||||||
|
const [controlledSwiper, setControlledSwiper] = useState<SwiperClass | undefined>(undefined);
|
||||||
|
|
||||||
|
const images = useNodeImages(node);
|
||||||
|
|
||||||
|
const updateSwiper = useCallback(() => {
|
||||||
|
if (!controlledSwiper) return;
|
||||||
|
|
||||||
|
controlledSwiper.updateSlides();
|
||||||
|
controlledSwiper.updateSize();
|
||||||
|
controlledSwiper.update();
|
||||||
|
}, [controlledSwiper]);
|
||||||
|
|
||||||
|
const resetSwiper = useCallback(() => {
|
||||||
|
if (!controlledSwiper) return;
|
||||||
|
controlledSwiper.slideTo(0);
|
||||||
|
}, [controlledSwiper]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateSwiper();
|
||||||
|
resetSwiper();
|
||||||
|
}, [images, updateSwiper, resetSwiper]);
|
||||||
|
|
||||||
|
if (!images?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<Swiper
|
||||||
|
slidesPerView="auto"
|
||||||
|
centeredSlides
|
||||||
|
onSwiper={setControlledSwiper}
|
||||||
|
grabCursor
|
||||||
|
autoHeight
|
||||||
|
breakpoints={breakpoints}
|
||||||
|
pagination={{ type: 'fraction' }}
|
||||||
|
observeSlideChildren
|
||||||
|
observeParents
|
||||||
|
resizeObserver
|
||||||
|
watchOverflow
|
||||||
|
>
|
||||||
|
{images.map(file => (
|
||||||
|
<SwiperSlide className={styles.slide} key={file.id}>
|
||||||
|
<img
|
||||||
|
className={styles.image}
|
||||||
|
src={getURL(file, PRESETS['1600'])}
|
||||||
|
alt={node.title}
|
||||||
|
onLoad={updateSwiper}
|
||||||
|
/>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export { NodeImageSwiperBlock };
|
export { NodeImageSwiperBlock };
|
||||||
|
|
66
src/components/node/NodeImageSwiperBlock/styles.module.scss
Normal file
66
src/components/node/NodeImageSwiperBlock/styles.module.scss
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
@import "~/styles/variables.scss";
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
border-radius: $radius;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
:global(.swiper-pagination) {
|
||||||
|
left: 50%;
|
||||||
|
bottom: $gap * 2;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
background: darken($comment_bg, 4%);
|
||||||
|
width: auto;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font: $font_10_semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.swiper-container) {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide {
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font: $font_32_bold;
|
||||||
|
display: flex;
|
||||||
|
border-radius: $radius;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100vw;
|
||||||
|
opacity: 1;
|
||||||
|
filter: brightness(50%) saturate(0.5);
|
||||||
|
transition: opacity 0.5s, filter 1s, transform 1s;
|
||||||
|
padding-bottom: $gap * 1.5;
|
||||||
|
padding-top: $gap;
|
||||||
|
|
||||||
|
&:global(.swiper-slide-active) {
|
||||||
|
opacity: 1;
|
||||||
|
filter: brightness(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
max-height: calc(100vh - 70px - 70px);
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: $radius;
|
||||||
|
|
||||||
|
box-shadow: transparentize(black, 0.9) 0 10px 5px 4px,
|
||||||
|
transparentize(black, 0.7) 0 5px 5px,
|
||||||
|
transparentize(white, 0.95) 0 -1px 2px,
|
||||||
|
transparentize(white, 0.95) 0 -1px;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
padding-bottom: 0;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,4 +4,9 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: $content_width;
|
max-width: $content_width;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
padding: 0 $gap;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,17 +2,12 @@
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 $gap;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
@include tablet {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { IComment, INode, ValueOf } from '../types';
|
import { IComment, INode, ValueOf } from '../types';
|
||||||
import { NodeImageSlideBlock } from '~/components/node/NodeImageSlideBlock';
|
|
||||||
import { NodeTextBlock } from '~/components/node/NodeTextBlock';
|
import { NodeTextBlock } from '~/components/node/NodeTextBlock';
|
||||||
import { NodeAudioBlock } from '~/components/node/NodeAudioBlock';
|
import { NodeAudioBlock } from '~/components/node/NodeAudioBlock';
|
||||||
import { NodeVideoBlock } from '~/components/node/NodeVideoBlock';
|
import { NodeVideoBlock } from '~/components/node/NodeVideoBlock';
|
||||||
|
@ -14,6 +13,7 @@ import { EditorAudioUploadButton } from '~/components/editors/EditorAudioUploadB
|
||||||
import { EditorUploadCoverButton } from '~/components/editors/EditorUploadCoverButton';
|
import { EditorUploadCoverButton } from '~/components/editors/EditorUploadCoverButton';
|
||||||
import { IEditorComponentProps, NodeEditorProps } from '~/redux/node/types';
|
import { IEditorComponentProps, NodeEditorProps } from '~/redux/node/types';
|
||||||
import { EditorFiller } from '~/components/editors/EditorFiller';
|
import { EditorFiller } from '~/components/editors/EditorFiller';
|
||||||
|
import { NodeImageSwiperBlock } from '~/components/node/NodeImageSwiperBlock';
|
||||||
|
|
||||||
const prefix = 'NODE.';
|
const prefix = 'NODE.';
|
||||||
export const NODE_ACTIONS = {
|
export const NODE_ACTIONS = {
|
||||||
|
@ -81,7 +81,7 @@ export type INodeComponentProps = {
|
||||||
export type INodeComponents = Record<ValueOf<typeof NODE_TYPES>, FC<INodeComponentProps>>;
|
export type INodeComponents = Record<ValueOf<typeof NODE_TYPES>, FC<INodeComponentProps>>;
|
||||||
|
|
||||||
export const NODE_HEADS: INodeComponents = {
|
export const NODE_HEADS: INodeComponents = {
|
||||||
[NODE_TYPES.IMAGE]: NodeImageSlideBlock,
|
[NODE_TYPES.IMAGE]: NodeImageSwiperBlock,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NODE_COMPONENTS: INodeComponents = {
|
export const NODE_COMPONENTS: INodeComponents = {
|
||||||
|
|
9
src/utils/hooks/node/useNodeAudios.ts
Normal file
9
src/utils/hooks/node/useNodeAudios.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
|
||||||
|
export const useNodeAudios = (node: INode) => {
|
||||||
|
return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), [
|
||||||
|
node.files,
|
||||||
|
]);
|
||||||
|
};
|
9
src/utils/hooks/node/useNodeImages.ts
Normal file
9
src/utils/hooks/node/useNodeImages.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { INode } from '~/redux/types';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||||
|
|
||||||
|
export const useNodeImages = (node: INode) => {
|
||||||
|
return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [
|
||||||
|
node.files,
|
||||||
|
]);
|
||||||
|
};
|
|
@ -1,10 +1,8 @@
|
||||||
import { USER_ROLES } from '~/redux/auth/constants';
|
import { USER_ROLES } from '~/redux/auth/constants';
|
||||||
import { ICommentGroup, IFile, INode } from '~/redux/types';
|
import { ICommentGroup, INode } from '~/redux/types';
|
||||||
import { IUser } from '~/redux/auth/types';
|
import { IUser } from '~/redux/auth/types';
|
||||||
import { path } from 'ramda';
|
import { path } from 'ramda';
|
||||||
import { NODE_TYPES } from '~/redux/node/constants';
|
import { NODE_TYPES } from '~/redux/node/constants';
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
|
||||||
|
|
||||||
export const canEditNode = (node: Partial<INode>, user: Partial<IUser>): boolean =>
|
export const canEditNode = (node: Partial<INode>, user: Partial<IUser>): boolean =>
|
||||||
path(['role'], user) === USER_ROLES.ADMIN ||
|
path(['role'], user) === USER_ROLES.ADMIN ||
|
||||||
|
@ -21,11 +19,3 @@ export const canStarNode = (node: Partial<INode>, user: Partial<IUser>): boolean
|
||||||
node.type === NODE_TYPES.IMAGE &&
|
node.type === NODE_TYPES.IMAGE &&
|
||||||
path(['role'], user) &&
|
path(['role'], user) &&
|
||||||
path(['role'], user) === USER_ROLES.ADMIN;
|
path(['role'], user) === USER_ROLES.ADMIN;
|
||||||
|
|
||||||
export const useNodeImages = (node: INode): IFile[] => {
|
|
||||||
return useMemo(
|
|
||||||
() =>
|
|
||||||
(node && node.files && node.files.filter(({ type }) => type === UPLOAD_TYPES.IMAGE)) || [],
|
|
||||||
[node.files]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
25
yarn.lock
25
yarn.lock
|
@ -1761,6 +1761,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
|
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
|
||||||
integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
|
integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
|
||||||
|
|
||||||
|
"@types/swiper@^5.4.2":
|
||||||
|
version "5.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/swiper/-/swiper-5.4.2.tgz#ff206cf5aea787f580b5dd9b466b4bcb8e0442f3"
|
||||||
|
integrity sha512-/7MaVDZ8ltMCZb6yfg1HWBRjwFjy9ytKpuPSZfNTrxpkQCaGQZdpceDSqKaSfGmJcVF0NcBFRsGTStyytV7grw==
|
||||||
|
|
||||||
"@types/testing-library__jest-dom@^5.9.1":
|
"@types/testing-library__jest-dom@^5.9.1":
|
||||||
version "5.9.5"
|
version "5.9.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz#5bf25c91ad2d7b38f264b12275e5c92a66d849b0"
|
resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz#5bf25c91ad2d7b38f264b12275e5c92a66d849b0"
|
||||||
|
@ -4084,6 +4089,13 @@ dom-serializer@0:
|
||||||
domelementtype "^2.0.1"
|
domelementtype "^2.0.1"
|
||||||
entities "^2.0.0"
|
entities "^2.0.0"
|
||||||
|
|
||||||
|
dom7@^3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/dom7/-/dom7-3.0.0.tgz#b861ce5d67a6becd7aaa3ad02942ff14b1240331"
|
||||||
|
integrity sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==
|
||||||
|
dependencies:
|
||||||
|
ssr-window "^3.0.0-alpha.1"
|
||||||
|
|
||||||
domain-browser@^1.1.1:
|
domain-browser@^1.1.1:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
|
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
|
||||||
|
@ -10449,6 +10461,11 @@ sshpk@^1.7.0:
|
||||||
safer-buffer "^2.0.2"
|
safer-buffer "^2.0.2"
|
||||||
tweetnacl "~0.14.0"
|
tweetnacl "~0.14.0"
|
||||||
|
|
||||||
|
ssr-window@^3.0.0, ssr-window@^3.0.0-alpha.1:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/ssr-window/-/ssr-window-3.0.0.tgz#fd5b82801638943e0cc704c4691801435af7ac37"
|
||||||
|
integrity sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==
|
||||||
|
|
||||||
ssri@^6.0.1:
|
ssri@^6.0.1:
|
||||||
version "6.0.1"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8"
|
resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8"
|
||||||
|
@ -10789,6 +10806,14 @@ svgo@^1.0.0, svgo@^1.2.2:
|
||||||
unquote "~1.1.1"
|
unquote "~1.1.1"
|
||||||
util.promisify "~1.0.0"
|
util.promisify "~1.0.0"
|
||||||
|
|
||||||
|
swiper@^6.5.0:
|
||||||
|
version "6.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/swiper/-/swiper-6.5.0.tgz#4ca2243b44fccef47ee28199377666607d8c5141"
|
||||||
|
integrity sha512-cSx1SpfgrHlgwku++3Ce3cjPBpXgB7P+bGik5S3+F+j6ID0NUeV6qtmedFdr3C8jXR/W+TJPVNIT9fH/cwVAiA==
|
||||||
|
dependencies:
|
||||||
|
dom7 "^3.0.0"
|
||||||
|
ssr-window "^3.0.0"
|
||||||
|
|
||||||
symbol-observable@^1.2.0:
|
symbol-observable@^1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
|
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue