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

Merge branch 'master' into 15-use-cra-or-nextjs

# Conflicts:
#	src/components/node/CommentForm/index.tsx
#	src/utils/node.ts
This commit is contained in:
Fedor Katurov 2020-11-19 16:29:03 +07:00
commit 332d09e3bd
46 changed files with 936 additions and 487 deletions

84
.drone.yml Normal file
View file

@ -0,0 +1,84 @@
kind: pipeline
name: build
type: docker
platform:
os: linux
arch: amd64
steps:
- name: compress
image: alpine
commands:
- rm -rf ./app.tar.bz2
- tar -cjf ./app.tar.bz2 -C ./ .
- name: upload
image: drillster/drone-rsync
when:
branch:
- master
- develop
environment:
RSYNC_KEY:
from_secret: rsync_key
RSYNC_USER:
from_secret: rsync_user
PLUGIN_ARGS: -zz -O --no-perms
settings:
port: 22522
hosts:
- vault48.org
source: ./
user: ${rsync_user}
key: ${rsync_key}
target: /tmp/vault-frontend-${DRONE_BRANCH}
include:
- "app.tar.bz2"
exclude:
- "*"
- name: build
image: appleboy/drone-ssh
when:
branch:
- master
- develop
environment:
BUILD_PATH:
from_secret: build_path
ENV_PATH:
from_secret: env_path
settings:
host: vault48.org
username:
from_secret: rsync_user
key:
from_secret: rsync_key
envs: [build_path, env_path]
port: 22522
script_stop: true
script:
- mkdir -p $${BUILD_PATH}/${DRONE_BRANCH}
- rm -rf $${BUILD_PATH}/${DRONE_BRANCH}/*
- cd $${BUILD_PATH}/${DRONE_BRANCH}
- tar -xjf /tmp/vault-frontend-${DRONE_BRANCH}/app.tar.bz2 -C ./
- cp -a $${ENV_PATH}/${DRONE_BRANCH}/. $${BUILD_PATH}/${DRONE_BRANCH}
- docker-compose build
- docker-compose up -d
- name: telgram_notify
image: appleboy/drone-telegram
when:
status:
- success
- failure
settings:
token:
from_secret: telegram_token
to:
from_secret: telegram_chat_id
format: markdown
message: >
{{#success build.status}}🤓{{else}}😨{{/success}}
{{ datetime build.finished "01.02.2006 15:04:05" "UTC" }} [{{repo.name}} / {{commit.branch}}]({{ build.link }})
```
{{ commit.message }}
```

View file

@ -1,5 +1,7 @@
This is [vault48.org](https://vault48.org) frontend.
[![Build Status](https://jenkins.vault48.org/api/badges/muerwre/vault-frontend/status.svg)](https://vault48.org/)
### Installation
1. Clone this repo `git clone git@github.com:muerwre/vault-frontend.git`
2. Run `yarn install`

11
docker-compose.yml Normal file
View file

@ -0,0 +1,11 @@
version: '3'
services:
www:
restart: always
build:
context: .
dockerfile: docker/www/Dockerfile
ports:
- ${EXPOSE}:80
volumes:
- /etc/localtime:/etc/localtime:ro

13
docker/www/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
# stage1 as builder
FROM node:10.13 as builder
COPY package.json yarn.lock ./
RUN yarn
COPY . .
RUN yarn build
FROM nginx:alpine
COPY docker/www/nginx.conf /etc/nginx/nginx.conf
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /dist /usr/share/nginx/html
EXPOSE ${EXPOSE} 80
ENTRYPOINT ["nginx", "-g", "daemon off;"]

46
docker/www/nginx.conf Normal file
View file

@ -0,0 +1,46 @@
worker_processes 4;
events { worker_connections 1024; }
http {
server {
listen 80;
root /usr/share/nginx/html;
include /etc/nginx/mime.types;
gzip on;
gzip_min_length 1000;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain application/xml application/javascript;
## All static files will be served directly.
location ~* ^.+\.(?:css|cur|js|jpe?g|gif|htc|ico|png|xml|otf|ttf|eot|woff|woff2|svg)$ {
access_log off;
expires 30d;
add_header Cache-Control public;
gzip_static on;
## No need to bleed constant updates. Send the all shebang in one
## fell swoop.
tcp_nodelay off;
## Set the OS file cache.
open_file_cache max=3000 inactive=120s;
open_file_cache_valid 45s;
open_file_cache_min_uses 2;
open_file_cache_errors off;
}
location / {
gzip_static on;
try_files $uri @index;
}
location @index {
add_header Cache-Control "no-store, no-cache, must-revalidate";
expires -1;
try_files /index.html =404;
}
}
}

View file

@ -112,6 +112,7 @@
"scrypt": "^6.0.3",
"sticky-sidebar": "^3.3.1",
"throttle-debounce": "^2.1.0",
"tiny-slider-react": "^0.5.3",
"tinycolor": "^0.0.1",
"tslint": "^5.20.0",
"tslint-config-airbnb": "^5.11.2",

View file

@ -30,7 +30,10 @@ const BorisStatsGit: FC<IProps> = ({ stats }) => {
return (
<div className={styles.wrap}>
<div className={styles.stats__title}>КОММИТС</div>
<div className={styles.stats__title}>
<span>КОММИТС</span>
<img src="https://jenkins.vault48.org/api/badges/muerwre/vault-golang/status.svg" />
</div>
{stats.git
.filter(data => data.commit && data.timestamp && data.subject)

View file

@ -4,7 +4,14 @@
&__title {
font: $font_12_semibold;
text-transform: uppercase;
opacity: 0.3;
margin: $gap * 2 0 $gap;
span {
opacity: 0.3;
}
img {
float: right;
}
}
}

View file

@ -1,12 +1,12 @@
import React, { FC, HTMLAttributes, memo } from 'react';
import { CommentWrapper } from '~/components/containers/CommentWrapper';
import { ICommentGroup, IComment } from '~/redux/types';
import { CommentContent } from '~/components/node/CommentContent';
import { ICommentGroup } from '~/redux/types';
import { CommentContent } from '~/components/comment/CommentContent';
import styles from './styles.module.scss';
import { nodeLockComment, nodeEditComment } from '~/redux/node/actions';
import { nodeEditComment, nodeLockComment } from '~/redux/node/actions';
import { INodeState } from '~/redux/node/reducer';
import { CommentForm } from '../CommentForm';
import { CommendDeleted } from '../CommendDeleted';
import { CommendDeleted } from '../../node/CommendDeleted';
import * as MODAL_ACTIONS from '~/redux/modal/actions';
type IProps = HTMLAttributes<HTMLDivElement> & {

View file

@ -0,0 +1,234 @@
import React, { FC, KeyboardEventHandler, memo, useCallback, useEffect, useMemo } from 'react';
import { Textarea } from '~/components/input/Textarea';
import styles from './styles.module.scss';
import { Filler } from '~/components/containers/Filler';
import { Button } from '~/components/input/Button';
import assocPath from 'ramda/es/assocPath';
import { IComment, IFileWithUUID, InputHandler } from '~/redux/types';
import { connect } from 'react-redux';
import * as NODE_ACTIONS from '~/redux/node/actions';
import { selectNode } from '~/redux/node/selectors';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { Group } from '~/components/containers/Group';
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants';
import uuid from 'uuid4';
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
import { selectUploads } from '~/redux/uploads/selectors';
import { IState } from '~/redux/store';
import { getFileType } from '~/utils/uploader';
import { getRandomPhrase } from '~/constants/phrases';
import { ERROR_LITERAL } from '~/constants/errors';
import { CommentFormAttaches } from '~/components/comment/CommentFormAttaches';
import { CommentFormAttachButtons } from '~/components/comment/CommentFormButtons';
import { CommentFormDropzone } from '~/components/comment/CommentFormDropzone';
const mapStateToProps = (state: IState) => ({
node: selectNode(state),
uploads: selectUploads(state),
});
const mapDispatchToProps = {
nodePostComment: NODE_ACTIONS.nodePostComment,
nodeCancelCommentEdit: NODE_ACTIONS.nodeCancelCommentEdit,
nodeSetCommentData: NODE_ACTIONS.nodeSetCommentData,
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
};
type IProps = ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps & {
id: number;
is_before?: boolean;
};
const CommentFormUnconnected: FC<IProps> = memo(
({
node: { comment_data, is_sending_comment },
uploads: { statuses, files },
id,
is_before = false,
nodePostComment,
nodeSetCommentData,
uploadUploadFiles,
nodeCancelCommentEdit,
}) => {
const comment = useMemo(() => comment_data[id], [comment_data, id]);
const onUpload = useCallback(
(files: File[]) => {
console.log(files);
const items: IFileWithUUID[] = files.map(
(file: File): IFileWithUUID => ({
file,
temp_id: uuid(),
subject: UPLOAD_SUBJECTS.COMMENT,
target: UPLOAD_TARGETS.COMMENTS,
type: getFileType(file),
})
);
const temps = items.map(file => file.temp_id);
nodeSetCommentData(id, assocPath(['temp_ids'], [...comment.temp_ids, ...temps], comment));
uploadUploadFiles(items);
},
[uploadUploadFiles, comment, id, nodeSetCommentData]
);
const onInput = useCallback<InputHandler>(
text => {
nodeSetCommentData(id, assocPath(['text'], text, comment));
},
[nodeSetCommentData, comment, id]
);
useEffect(() => {
const temp_ids = (comment && comment.temp_ids) || [];
const added_files = temp_ids
.map(temp_uuid => statuses[temp_uuid] && statuses[temp_uuid].uuid)
.map(el => !!el && files[el])
.filter(el => !!el && !comment.files.some(file => file && file.id === el.id));
const filtered_temps = temp_ids.filter(
temp_id =>
statuses[temp_id] &&
(!statuses[temp_id].uuid || !added_files.some(file => file.id === statuses[temp_id].uuid))
);
if (added_files.length) {
nodeSetCommentData(id, {
...comment,
temp_ids: filtered_temps,
files: [...comment.files, ...added_files],
});
}
}, [statuses, files]);
const isUploadingNow = useMemo(() => comment.temp_ids.length > 0, [comment.temp_ids]);
const onSubmit = useCallback(
event => {
if (event) event.preventDefault();
if (isUploadingNow || is_sending_comment) return;
nodePostComment(id, is_before);
},
[nodePostComment, id, is_before, isUploadingNow, is_sending_comment]
);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
({ ctrlKey, key }) => {
if (!!ctrlKey && key === 'Enter') onSubmit(null);
},
[onSubmit]
);
const images = useMemo(
() => comment.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE),
[comment.files]
);
const locked_images = useMemo(
() =>
comment.temp_ids
.filter(temp => statuses[temp] && statuses[temp].type === UPLOAD_TYPES.IMAGE)
.map(temp_id => statuses[temp_id]),
[statuses, comment.temp_ids]
);
const audios = useMemo(
() => comment.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO),
[comment.files]
);
const locked_audios = useMemo(
() =>
comment.temp_ids
.filter(temp => statuses[temp] && statuses[temp].type === UPLOAD_TYPES.AUDIO)
.map(temp_id => statuses[temp_id]),
[statuses, comment.temp_ids]
);
const onCancelEdit = useCallback(() => {
nodeCancelCommentEdit(id);
}, [nodeCancelCommentEdit, comment.id]);
const placeholder = getRandomPhrase('SIMPLE');
const clearError = useCallback(() => nodeSetCommentData(id, { error: '' }), [
id,
nodeSetCommentData,
]);
useEffect(() => {
if (comment.error) clearError();
}, [comment.files, comment.text]);
const setData = useCallback(
(data: Partial<IComment>) => {
nodeSetCommentData(id, data);
},
[nodeSetCommentData, id]
);
return (
<CommentFormDropzone onUpload={onUpload}>
<form onSubmit={onSubmit} className={styles.wrap}>
<div className={styles.input}>
<Textarea
value={comment.text}
handler={onInput}
onKeyDown={onKeyDown}
disabled={is_sending_comment}
placeholder={placeholder}
minRows={2}
/>
{comment.error && (
<div className={styles.error} onClick={clearError}>
{ERROR_LITERAL[comment.error] || comment.error}
</div>
)}
</div>
<CommentFormAttaches
images={images}
audios={audios}
locked_audios={locked_audios}
locked_images={locked_images}
comment={comment}
setComment={setData}
onUpload={onUpload}
/>
<Group horizontal className={styles.buttons}>
<CommentFormAttachButtons onUpload={onUpload} />
<Filler />
{(is_sending_comment || isUploadingNow) && <LoaderCircle size={20} />}
{id !== 0 && (
<Button size="small" color="link" type="button" onClick={onCancelEdit}>
Отмена
</Button>
)}
<Button
size="small"
color="gray"
iconRight={id === 0 ? 'enter' : 'check'}
disabled={is_sending_comment || isUploadingNow}
>
{id === 0 ? 'Сказать' : 'Сохранить'}
</Button>
</Group>
</form>
</CommentFormDropzone>
);
}
);
const CommentForm = connect(mapStateToProps, mapDispatchToProps)(CommentFormUnconnected);
export { CommentForm, CommentFormUnconnected };

View file

@ -0,0 +1,139 @@
import React, { FC, useCallback } from 'react';
import styles from '~/components/comment/CommentForm/styles.module.scss';
import { SortableImageGrid } from '~/components/editors/SortableImageGrid';
import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid';
import { IComment, IFile } from '~/redux/types';
import { IUploadStatus } from '~/redux/uploads/reducer';
import { SortEnd } from 'react-sortable-hoc';
import assocPath from 'ramda/es/assocPath';
import { moveArrItem } from '~/utils/fn';
import { useDropZone } from '~/utils/hooks';
import { COMMENT_FILE_TYPES } from '~/redux/uploads/constants';
interface IProps {
images: IFile[];
audios: IFile[];
locked_images: IUploadStatus[];
locked_audios: IUploadStatus[];
comment: IComment;
setComment: (data: IComment) => void;
onUpload: (files: File[]) => void;
}
const CommentFormAttaches: FC<IProps> = ({
images,
audios,
locked_images,
locked_audios,
comment,
setComment,
onUpload,
}) => {
const onDrop = useDropZone(onUpload, COMMENT_FILE_TYPES);
const hasImageAttaches = images.length > 0 || locked_images.length > 0;
const hasAudioAttaches = audios.length > 0 || locked_audios.length > 0;
const hasAttaches = hasImageAttaches || hasAudioAttaches;
const onImageMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => {
setComment(
assocPath(
['files'],
[
...audios,
...(moveArrItem(
oldIndex,
newIndex,
images.filter(file => !!file)
) as IFile[]),
],
comment
)
);
},
[images, audios, comment, setComment]
);
const onFileDelete = useCallback(
(fileId: IFile['id']) => {
setComment(
assocPath(
['files'],
comment.files.filter(file => file.id != fileId),
comment
)
);
},
[setComment, comment]
);
const onTitleChange = useCallback(
(fileId: IFile['id'], title: IFile['metadata']['title']) => {
setComment(
assocPath(
['files'],
comment.files.map(file =>
file.id === fileId ? { ...file, metadata: { ...file.metadata, title } } : file
),
comment
)
);
},
[comment, setComment]
);
const onAudioMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => {
setComment(
assocPath(
['files'],
[
...images,
...(moveArrItem(
oldIndex,
newIndex,
audios.filter(file => !!file)
) as IFile[]),
],
comment
)
);
},
[images, audios, comment, setComment]
);
return (
hasAttaches && (
<div className={styles.attaches} onDropCapture={onDrop}>
{hasImageAttaches && (
<SortableImageGrid
onDelete={onFileDelete}
onSortEnd={onImageMove}
axis="xy"
items={images}
locked={locked_images}
pressDelay={50}
helperClass={styles.helper}
size={120}
/>
)}
{hasAudioAttaches && (
<SortableAudioGrid
items={audios}
onDelete={onFileDelete}
onTitleChange={onTitleChange}
onSortEnd={onAudioMove}
axis="y"
locked={locked_audios}
pressDelay={50}
helperClass={styles.helper}
/>
)}
</div>
)
);
};
export { CommentFormAttaches };

View file

@ -0,0 +1,38 @@
import React, { FC, useCallback } from 'react';
import { ButtonGroup } from '~/components/input/ButtonGroup';
import { Button } from '~/components/input/Button';
import { COMMENT_FILE_TYPES } from '~/redux/uploads/constants';
interface IProps {
onUpload: (files: File[]) => void;
}
const CommentFormAttachButtons: FC<IProps> = ({ onUpload }) => {
const onInputChange = useCallback(
event => {
event.preventDefault();
const files = Array.from(event.target?.files as File[]).filter((file: File) =>
COMMENT_FILE_TYPES.includes(file.type)
);
if (!files || !files.length) return;
onUpload(files);
},
[onUpload]
);
return (
<ButtonGroup>
<Button iconLeft="photo" size="small" color="gray" iconOnly>
<input type="file" onInput={onInputChange} multiple accept="image/*" />
</Button>
<Button iconRight="audio" size="small" color="gray" iconOnly>
<input type="file" onInput={onInputChange} multiple accept="audio/*" />
</Button>
</ButtonGroup>
);
};
export { CommentFormAttachButtons };

View file

@ -0,0 +1,14 @@
import React, { FC } from 'react';
import { COMMENT_FILE_TYPES } from '~/redux/uploads/constants';
import { useDropZone } from '~/utils/hooks';
interface IProps {
onUpload: (files: File[]) => void;
}
const CommentFormDropzone: FC<IProps> = ({ children, onUpload }) => {
const onDrop = useDropZone(onUpload, COMMENT_FILE_TYPES);
return <div onDropCapture={onDrop}>{children}</div>;
};
export { CommentFormDropzone };

View file

@ -0,0 +1,52 @@
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import styles from './styles.module.scss';
import ResizeSensor from 'resize-sensor';
interface IProps {
onRefresh?: (width: number) => void;
}
const FullWidth: FC<IProps> = ({ children, onRefresh }) => {
const sample = useRef<HTMLDivElement>(null);
const [clientWidth, setClientWidth] = useState(document.documentElement.clientWidth);
const style = useMemo(() => {
if (!sample.current) return { display: 'none' };
const { width } = sample.current.getBoundingClientRect();
const { clientWidth } = document.documentElement;
onRefresh(clientWidth);
return {
width: clientWidth,
transform: `translate(-${(clientWidth - width) / 2}px, 0)`,
};
}, [sample.current, clientWidth, onRefresh]);
const onResize = useCallback(() => setClientWidth(document.documentElement.clientWidth), []);
useEffect(() => {
if (!sample.current) return;
window.addEventListener('resize', onResize);
new ResizeSensor(document.body, onResize);
return () => {
window.removeEventListener('resize', onResize);
ResizeSensor.detach(document.body, onResize);
};
}, []);
return (
<div className={styles.wrap}>
<div className={styles.slider} style={style}>
{children}
</div>
<div className={styles.sample} ref={sample} />
</div>
);
};
export { FullWidth };

View file

@ -0,0 +1,10 @@
.sample {
width: 100%;
display: block;
background: green;
height: 0;
}
.slider {
display: block;
}

View file

@ -16,7 +16,13 @@ interface IProps {
const AudioGrid: FC<IProps> = ({ files, setFiles, locked }) => {
const onMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => {
setFiles(moveArrItem(oldIndex, newIndex, files.filter(file => !!file)) as IFile[]);
setFiles(
moveArrItem(
oldIndex,
newIndex,
files.filter(file => !!file)
) as IFile[]
);
},
[setFiles, files]
);
@ -41,7 +47,7 @@ const AudioGrid: FC<IProps> = ({ files, setFiles, locked }) => {
return (
<SortableAudioGrid
onDrop={onDrop}
onDelete={onDrop}
onTitleChange={onTitleChange}
onSortEnd={onMove}
axis="xy"

View file

@ -15,7 +15,13 @@ interface IProps {
const ImageGrid: FC<IProps> = ({ files, setFiles, locked }) => {
const onMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => {
setFiles(moveArrItem(oldIndex, newIndex, files.filter(file => !!file)) as IFile[]);
setFiles(
moveArrItem(
oldIndex,
newIndex,
files.filter(file => !!file)
) as IFile[]
);
},
[setFiles, files]
);
@ -29,7 +35,7 @@ const ImageGrid: FC<IProps> = ({ files, setFiles, locked }) => {
return (
<SortableImageGrid
onDrop={onDrop}
onDelete={onDrop}
onSortEnd={onMove}
axis="xy"
items={files}

View file

@ -11,23 +11,26 @@ const SortableAudioGrid = SortableContainer(
({
items,
locked,
onDrop,
onDelete,
onTitleChange,
}: {
items: IFile[];
locked: IUploadStatus[];
onDrop: (file_id: IFile['id']) => void;
onDelete: (file_id: IFile['id']) => void;
onTitleChange: (file_id: IFile['id'], title: IFile['metadata']['title']) => void;
}) => {
console.log(locked);
return (
<div className={styles.grid}>
{items
.filter(file => file && file.id)
.map((file, index) => (
<SortableAudioGridItem key={file.id} index={index} collection={0}>
<AudioPlayer file={file} onDrop={onDrop} onTitleChange={onTitleChange} isEditing />
<AudioPlayer
file={file}
onDelete={onDelete}
onTitleChange={onTitleChange}
isEditing
/>
</SortableAudioGridItem>
))}

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { SortableContainer } from 'react-sortable-hoc';
import { ImageUpload } from '~/components/upload/ImageUpload';
import styles from './styles.module.scss';
@ -12,33 +12,38 @@ const SortableImageGrid = SortableContainer(
({
items,
locked,
onDrop,
onDelete,
size = 200,
}: {
items: IFile[];
locked: IUploadStatus[];
onDrop: (file_id: IFile['id']) => void;
onDelete: (file_id: IFile['id']) => void;
size?: number;
}) => (
<div
className={styles.grid}
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${size}px, 1fr))` }}
>
{items
.filter(file => file && file.id)
.map((file, index) => (
<SortableImageGridItem key={file.id} index={index} collection={0}>
<ImageUpload id={file.id} thumb={getURL(file, PRESETS.cover)} onDrop={onDrop} />
}) => {
const preventEvent = useCallback(event => event.preventDefault(), []);
return (
<div
className={styles.grid}
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${size}px, 1fr))` }}
onDropCapture={preventEvent}
>
{items
.filter(file => file && file.id)
.map((file, index) => (
<SortableImageGridItem key={file.id} index={index} collection={0}>
<ImageUpload id={file.id} thumb={getURL(file, PRESETS.cover)} onDrop={onDelete} />
</SortableImageGridItem>
))}
{locked.map((item, index) => (
<SortableImageGridItem key={item.temp_id} index={index} collection={1} disabled>
<ImageUpload thumb={item.preview} progress={item.progress} is_uploading />
</SortableImageGridItem>
))}
{locked.map((item, index) => (
<SortableImageGridItem key={item.temp_id} index={index} collection={1} disabled>
<ImageUpload thumb={item.preview} progress={item.progress} is_uploading />
</SortableImageGridItem>
))}
</div>
)
</div>
);
}
);
export { SortableImageGrid };

View file

@ -25,14 +25,14 @@ type Props = ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps & {
file: IFile;
isEditing?: boolean;
onDrop?: (id: IFile['id']) => void;
onDelete?: (id: IFile['id']) => void;
onTitleChange?: (file_id: IFile['id'], title: IFile['metadata']['title']) => void;
};
const AudioPlayerUnconnected = memo(
({
file,
onDrop,
onDelete,
isEditing,
onTitleChange,
player: { file: current, status },
@ -78,10 +78,10 @@ const AudioPlayerUnconnected = memo(
);
const onDropClick = useCallback(() => {
if (!onDrop) return;
if (!onDelete) return;
onDrop(file.id);
}, [file, onDrop]);
onDelete(file.id);
}, [file, onDelete]);
const title = useMemo(
() =>
@ -111,7 +111,7 @@ const AudioPlayerUnconnected = memo(
return (
<div onClick={onPlay} className={classNames(styles.wrap, { playing })}>
{onDrop && (
{onDelete && (
<div className={styles.drop} onMouseDown={onDropClick}>
<Icon icon="close" />
</div>
@ -149,7 +149,4 @@ const AudioPlayerUnconnected = memo(
}
);
export const AudioPlayer = connect(
mapStateToProps,
mapDispatchToProps
)(AudioPlayerUnconnected);
export const AudioPlayer = connect(mapStateToProps, mapDispatchToProps)(AudioPlayerUnconnected);

View file

@ -1,336 +0,0 @@
import React, { FC, KeyboardEventHandler, memo, useCallback, useEffect, useMemo } from 'react';
import { Textarea } from '~/components/input/Textarea';
import styles from './styles.module.scss';
import { Filler } from '~/components/containers/Filler';
import { Button } from '~/components/input/Button';
import { assocPath } from 'ramda';
import { IFile, IFileWithUUID, InputHandler } from '~/redux/types';
import { connect } from 'react-redux';
import * as NODE_ACTIONS from '~/redux/node/actions';
import { selectNode } from '~/redux/node/selectors';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { Group } from '~/components/containers/Group';
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants';
import uuid from 'uuid4';
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
import { selectUploads } from '~/redux/uploads/selectors';
import { IState } from '~/redux/store';
import { getFileType } from '~/utils/uploader';
import { ButtonGroup } from '~/components/input/ButtonGroup';
import { SortableImageGrid } from '~/components/editors/SortableImageGrid';
import { moveArrItem } from '~/utils/fn';
import { SortEnd } from 'react-sortable-hoc';
import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid';
import { getRandomPhrase } from '~/constants/phrases';
import { ERROR_LITERAL } from '~/constants/errors';
const mapStateToProps = (state: IState) => ({
node: selectNode(state),
uploads: selectUploads(state),
});
const mapDispatchToProps = {
nodePostComment: NODE_ACTIONS.nodePostComment,
nodeCancelCommentEdit: NODE_ACTIONS.nodeCancelCommentEdit,
nodeSetCommentData: NODE_ACTIONS.nodeSetCommentData,
uploadUploadFiles: UPLOAD_ACTIONS.uploadUploadFiles,
};
type IProps = ReturnType<typeof mapStateToProps> &
typeof mapDispatchToProps & {
id: number;
is_before?: boolean;
};
const CommentFormUnconnected: FC<IProps> = memo(
({
node: { comment_data, is_sending_comment },
uploads: { statuses, files },
id,
is_before = false,
nodePostComment,
nodeSetCommentData,
uploadUploadFiles,
nodeCancelCommentEdit,
}) => {
const onInputChange = useCallback(
event => {
event.preventDefault();
if (!event.target.files || !event.target.files.length) return;
const items: IFileWithUUID[] = Array.from(event.target.files).map(
(file: File): IFileWithUUID => ({
file,
temp_id: uuid(),
subject: UPLOAD_SUBJECTS.COMMENT,
target: UPLOAD_TARGETS.COMMENTS,
type: getFileType(file),
})
);
const temps = items.map(file => file.temp_id);
nodeSetCommentData(
id,
assocPath(['temp_ids'], [...comment_data[id].temp_ids, ...temps], comment_data[id])
);
uploadUploadFiles(items);
},
[uploadUploadFiles, comment_data, id, nodeSetCommentData]
);
const onInput = useCallback<InputHandler>(
text => {
nodeSetCommentData(id, assocPath(['text'], text, comment_data[id]));
},
[nodeSetCommentData, comment_data, id]
);
useEffect(() => {
const temp_ids = (comment_data && comment_data[id] && comment_data[id].temp_ids) || [];
const added_files = temp_ids
.map(temp_uuid => statuses[temp_uuid] && statuses[temp_uuid].uuid)
.map(el => !!el && files[el])
.filter(el => !!el && !comment_data[id].files.some(file => file && file.id === el.id));
const filtered_temps = temp_ids.filter(
temp_id =>
statuses[temp_id] &&
(!statuses[temp_id].uuid || !added_files.some(file => file.id === statuses[temp_id].uuid))
);
if (added_files.length) {
nodeSetCommentData(id, {
...comment_data[id],
temp_ids: filtered_temps,
files: [...comment_data[id].files, ...added_files],
});
}
}, [statuses, files]);
const comment = comment_data[id];
const is_uploading_files = useMemo(() => comment.temp_ids.length > 0, [comment.temp_ids]);
const onSubmit = useCallback(
event => {
if (event) event.preventDefault();
if (is_uploading_files || is_sending_comment) return;
nodePostComment(id, is_before);
},
[nodePostComment, id, is_before, is_uploading_files, is_sending_comment]
);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
({ ctrlKey, key }) => {
if (!!ctrlKey && key === 'Enter') onSubmit(null);
},
[onSubmit]
);
const images = useMemo(
() => comment.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE),
[comment.files]
);
const locked_images = useMemo(
() =>
comment.temp_ids
.filter(temp => statuses[temp] && statuses[temp].type === UPLOAD_TYPES.IMAGE)
.map(temp_id => statuses[temp_id]),
[statuses, comment.temp_ids]
);
const audios = useMemo(
() => comment.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO),
[comment.files]
);
const locked_audios = useMemo(
() =>
comment.temp_ids
.filter(temp => statuses[temp] && statuses[temp].type === UPLOAD_TYPES.AUDIO)
.map(temp_id => statuses[temp_id]),
[statuses, comment.temp_ids]
);
const onFileDrop = useCallback(
(fileId: IFile['id']) => {
nodeSetCommentData(
id,
assocPath(
['files'],
comment.files.filter(file => file.id != fileId),
comment_data[id]
)
);
},
[comment_data, id, nodeSetCommentData]
);
const onTitleChange = useCallback(
(fileId: IFile['id'], title: IFile['metadata']['title']) => {
nodeSetCommentData(
id,
assocPath(
['files'],
comment.files.map(file =>
file.id === fileId ? { ...file, metadata: { ...file.metadata, title } } : file
),
comment_data[id]
)
);
},
[comment_data, id, nodeSetCommentData]
);
const onImageMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => {
nodeSetCommentData(
id,
assocPath(
['files'],
[
...audios,
...(moveArrItem(
oldIndex,
newIndex,
images.filter(file => !!file)
) as IFile[]),
],
comment_data[id]
)
);
},
[images, audios, comment_data, nodeSetCommentData]
);
const onAudioMove = useCallback(
({ oldIndex, newIndex }: SortEnd) => {
nodeSetCommentData(
id,
assocPath(
['files'],
[
...images,
...(moveArrItem(
oldIndex,
newIndex,
audios.filter(file => !!file)
) as IFile[]),
],
comment_data[id]
)
);
},
[images, audios, comment_data, nodeSetCommentData]
);
const onCancelEdit = useCallback(() => {
nodeCancelCommentEdit(id);
}, [nodeCancelCommentEdit, comment.id]);
const placeholder = getRandomPhrase('SIMPLE');
const hasImageAttaches = images.length > 0 || locked_images.length > 0;
const hasAudioAttaches = audios.length > 0 || locked_audios.length > 0;
const hasAttaches = hasImageAttaches || hasAudioAttaches;
const clearError = useCallback(() => nodeSetCommentData(id, { error: '' }), [
id,
nodeSetCommentData,
]);
useEffect(() => {
if (comment.error) clearError();
}, [comment.files, comment.text]);
return (
<form onSubmit={onSubmit} className={styles.wrap}>
<div className={styles.input}>
<Textarea
value={comment.text}
handler={onInput}
onKeyDown={onKeyDown}
disabled={is_sending_comment}
placeholder={placeholder}
minRows={2}
/>
{comment.error && (
<div className={styles.error} onClick={clearError}>
{ERROR_LITERAL[comment.error] || comment.error}
</div>
)}
</div>
{hasAttaches && (
<div className={styles.attaches}>
{hasImageAttaches && (
<SortableImageGrid
onDrop={onFileDrop}
onSortEnd={onImageMove}
axis="xy"
items={images}
locked={locked_images}
pressDelay={50}
helperClass={styles.helper}
size={120}
/>
)}
{hasAudioAttaches && (
<SortableAudioGrid
items={audios}
onDrop={onFileDrop}
onTitleChange={onTitleChange}
onSortEnd={onAudioMove}
axis="y"
locked={locked_audios}
pressDelay={50}
helperClass={styles.helper}
/>
)}
</div>
)}
<Group horizontal className={styles.buttons}>
<ButtonGroup>
<Button iconLeft="photo" size="small" color="gray" iconOnly>
<input type="file" onInput={onInputChange} multiple accept="image/*" />
</Button>
<Button iconRight="audio" size="small" color="gray" iconOnly>
<input type="file" onInput={onInputChange} multiple accept="audio/*" />
</Button>
</ButtonGroup>
<Filler />
{(is_sending_comment || is_uploading_files) && <LoaderCircle size={20} />}
{id !== 0 && (
<Button size="small" color="link" type="button" onClick={onCancelEdit}>
Отмена
</Button>
)}
<Button
size="small"
color="gray"
iconRight={id === 0 ? 'enter' : 'check'}
disabled={is_sending_comment || is_uploading_files}
>
{id === 0 ? 'Сказать' : 'Сохранить'}
</Button>
</Group>
</form>
);
}
);
const CommentForm = connect(mapStateToProps, mapDispatchToProps)(CommentFormUnconnected);
export { CommentForm, CommentFormUnconnected };

View file

@ -13,7 +13,7 @@ import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
import { selectUploads } from '~/redux/uploads/selectors';
import { IState } from '~/redux/store';
import { selectUser, selectAuthUser } from '~/redux/auth/selectors';
import { CommentForm } from '../CommentForm';
import { CommentForm } from '../../comment/CommentForm';
const mapStateToProps = state => ({
user: selectAuthUser(state),

View file

@ -1,5 +1,5 @@
import React, { FC, useMemo, memo } from 'react';
import { Comment } from '../Comment';
import { Comment } from '../../comment/Comment';
import { Filler } from '~/components/containers/Filler';
import styles from './styles.module.scss';

View file

@ -8,6 +8,7 @@ import { PRESETS } from '~/constants/urls';
import { LoaderCircle } from '~/components/input/LoaderCircle';
import { throttle } from 'throttle-debounce';
import { Icon } from '~/components/input/Icon';
import { useArrows } from '~/utils/hooks/keys';
interface IProps extends INodeComponentProps {}
@ -77,8 +78,8 @@ const NodeImageSlideBlock: FC<IProps> = ({
]);
// update outside hooks
useEffect(() => updateLayout(), [loaded, height, images]);
useEffect(() => updateSizes(), [refs, current, loaded, images]);
useEffect(updateLayout, [loaded, height, images]);
useEffect(updateSizes, [refs, current, loaded, images]);
useEffect(() => {
const timeout = setTimeout(updateLayout, 300);
@ -239,29 +240,7 @@ const NodeImageSlideBlock: FC<IProps> = ({
images,
]);
const onKeyDown = useCallback(
event => {
if (
(event.target.tagName && ['TEXTAREA', 'INPUT'].includes(event.target.tagName)) ||
is_modal_shown
)
return;
switch (event.key) {
case 'ArrowLeft':
return onPrev();
case 'ArrowRight':
return onNext();
}
},
[onNext, onPrev, is_modal_shown]
);
useEffect(() => {
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onKeyDown]);
useArrows(onNext, onPrev, is_modal_shown);
useEffect(() => {
setOffset(0);
@ -270,16 +249,6 @@ const NodeImageSlideBlock: FC<IProps> = ({
return (
<div className={styles.wrap}>
<div className={classNames(styles.cutter, { [styles.is_loading]: is_loading })} ref={wrap}>
<div
className={classNames(styles.placeholder, {
[styles.is_loading]: is_loading || !loaded[current],
})}
>
<div>
<LoaderCircle size={96} />
</div>
</div>
<div
className={classNames(styles.image_container, { [styles.is_dragging]: is_dragging })}
style={{
@ -295,13 +264,57 @@ const NodeImageSlideBlock: FC<IProps> = ({
images.map((file, index) => (
<div
className={classNames(styles.image_wrap, {
is_active: index === current && loaded[index],
[styles.is_active]: index === current,
})}
ref={setRef(index)}
key={node.updated_at + file.id}
>
<svg
viewBox={`0 0 ${file.metadata.width} ${file.metadata.height}`}
className={classNames(styles.preview, { [styles.is_loaded]: loaded[index] })}
style={{
maxHeight: max_height,
width: '100%',
}}
>
<defs>
<filter id="f1" x="0" y="0">
<feBlend
mode="multiply"
x="0%"
y="0%"
width="100%"
height="100%"
in="SourceGraphic"
in2="SourceGraphic"
result="blend"
/>
<feGaussianBlur
stdDeviation="15 15"
x="0%"
y="0%"
width="100%"
height="100%"
in="blend"
edgeMode="none"
result="blur2"
/>
</filter>
</defs>
<rect fill="#242222" width="100%" height="100%" stroke="none" rx="8" ry="8" />
<image
xlinkHref={getURL(file, PRESETS['300'])}
width="100%"
height="100%"
filter="url(#f1)"
/>
</svg>
<img
className={styles.image}
className={classNames(styles.image, { [styles.is_loaded]: loaded[index] })}
src={getURL(file, PRESETS['1600'])}
alt=""
key={file.id}

View file

@ -41,18 +41,6 @@
transition: none;
}
.image {
max-width: 100%;
opacity: 1;
border-radius: $radius;
box-shadow: transparentize($color: white, $amount: 0.95) 0 -1px,
transparentize($color: #000000, $amount: 0.6) 0 2px 5px;
@include tablet {
border-radius: 0;
}
}
&.is_dragging {
transition: none;
}
@ -69,7 +57,7 @@
padding: 0 $gap / 2;
position: relative;
&:global(.is_active) {
&.is_active {
opacity: 1;
}
@ -157,7 +145,6 @@
align-items: center;
justify-content: center;
background: $content_bg;
z-index: 0;
pointer-events: none;
touch-action: none;
transition: opacity 2s;
@ -173,3 +160,28 @@
fill: white;
}
}
.image, .preview {
max-width: 100%;
border-radius: $radius;
@include tablet {
border-radius: 0;
}
}
.image {
position: absolute;
opacity: 0;
&.is_loaded {
opacity: 1;
position: static;
}
}
.preview {
&.is_loaded {
display: none;
}
}

View file

@ -5,7 +5,7 @@ import { formatText, getPrettyDate, getURL } from '~/utils/dom';
import { PRESETS } from '~/constants/urls';
import classNames from 'classnames';
import { Group } from '~/components/containers/Group';
import { CommentMenu } from '~/components/node/CommentMenu';
import { CommentMenu } from '~/components/comment/CommentMenu';
import { MessageForm } from '~/components/profile/MessageForm';
import { Filler } from '~/components/containers/Filler';
import { Button } from '~/components/input/Button';

View file

@ -104,9 +104,11 @@ const TagInput: FC<IProps> = ({ exclude, onAppend, onClearTag, onSubmit }) => {
const feature = useMemo(() => (input?.substr(0, 1) === '/' ? 'green' : ''), [input]);
useEffect(() => {
if (!focused) return;
document.addEventListener('click', onBlur);
return () => document.removeEventListener('click', onBlur);
}, [onBlur]);
}, [onBlur, focused]);
return (
<div className={styles.wrap} ref={wrapper}>

View file

@ -25,6 +25,7 @@ const Component: FC<IProps> = ({ modal: { is_shown } }) => {
<div>
<BlurWrapper is_blurred={is_shown}>
<PageCover />
<MainLayout>
<Modal />
<Sprites />

View file

@ -1,10 +1,9 @@
import React, { FC, useEffect, useState } from 'react';
import { useRouteMatch, withRouter, RouteComponentProps } from 'react-router';
import { RouteComponentProps, useRouteMatch, withRouter } from 'react-router';
import styles from './styles.module.scss';
import { NodeNoComments } from '~/components/node/NodeNoComments';
import { Grid } from '~/components/containers/Grid';
import { CommentForm } from '~/components/node/CommentForm';
import { ProfileInfo } from '../ProfileInfo';
import { CommentForm } from '~/components/comment/CommentForm';
import * as NODE_ACTIONS from '~/redux/node/actions';
import { connect } from 'react-redux';
import { IUser } from '~/redux/auth/types';

View file

@ -15,7 +15,6 @@
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 0, 0, 0.3);
display: flex;
flex-direction: row;
z-index: 26;
@ -23,11 +22,7 @@
overflow: hidden;
animation: appear 0.25s forwards;
@include can_backdrop {
background: transparentize($content_bg, 0.15);
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
}
@include sidebar
}
.content {

View file

@ -1,18 +1,18 @@
@import "src/styles/variables";
.wrap {
@include outer_shadow;
height: 100%;
box-sizing: border-box;
display: flex;
flex: 0 1 400px;
max-width: 100vw;
position: relative;
@include sidebar_content;
}
.content {
background: $content_bg;
border-radius: $radius;
height: 100%;
box-sizing: border-box;
overflow: auto;
@ -28,7 +28,7 @@
align-items: center;
justify-content: center;
padding: $gap;
background: lighten($content_bg, 2%);
box-shadow: transparentize(white, 0.95) 0 1px;
}
.tag {

View file

@ -69,3 +69,8 @@ export const FILE_MIMES = {
[UPLOAD_TYPES.AUDIO]: ['audio/mpeg3', 'audio/mpeg', 'audio/mp3'],
[UPLOAD_TYPES.OTHER]: [],
};
export const COMMENT_FILE_TYPES = [
...FILE_MIMES[UPLOAD_TYPES.IMAGE],
...FILE_MIMES[UPLOAD_TYPES.AUDIO],
];

View file

@ -2,6 +2,7 @@
html {
min-height: 100vh;
box-sizing: border-box;
}
body {

View file

@ -185,3 +185,18 @@ $login_dialog_padding: $gap $gap 30px $gap;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
@mixin sidebar {
background: transparentize($content_bg, 0.15);
@include can_backdrop {
background: transparentize($content_bg, 0.3);
-webkit-backdrop-filter: blur(15px);
backdrop-filter: blur(15px);
}
}
@mixin sidebar_content {
background: transparentize($content_bg, 0.4);
box-shadow: transparentize(white, 0.95) -1px 0;
}

View file

@ -1,36 +0,0 @@
import {
useCallback, useEffect, useRef, useState
} from 'react';
export const useCloseOnEscape = (onRequestClose: () => void, ignore_inputs = false) => {
const onEscape = useCallback(
(event) => {
if (event.key !== 'Escape') return;
if (
ignore_inputs
&& (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA')
) return;
onRequestClose();
},
[ignore_inputs, onRequestClose],
);
useEffect(() => {
window.addEventListener('keyup', onEscape);
return () => {
window.removeEventListener('keyup', onEscape);
};
}, [onEscape]);
};
export const useDelayedReady = (setReady: (val: boolean) => void, delay: number = 500) => {
useEffect(() => {
const timer = setTimeout(() => setReady(true), delay);
return () => {
if (timer) clearTimeout(timer);
};
}, [delay, setReady]);
};

56
src/utils/hooks/index.ts Normal file
View file

@ -0,0 +1,56 @@
import { useCallback, useEffect } from 'react';
export const useCloseOnEscape = (onRequestClose: () => void, ignore_inputs = false) => {
const onEscape = useCallback(
event => {
if (event.key !== 'Escape') return;
if (
ignore_inputs &&
(event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA')
)
return;
onRequestClose();
},
[ignore_inputs, onRequestClose]
);
useEffect(() => {
window.addEventListener('keyup', onEscape);
return () => {
window.removeEventListener('keyup', onEscape);
};
}, [onEscape]);
};
export const useDelayedReady = (setReady: (val: boolean) => void, delay: number = 500) => {
useEffect(() => {
const timer = setTimeout(() => setReady(true), delay);
return () => {
if (timer) clearTimeout(timer);
};
}, [delay, setReady]);
};
/**
* useDropZone returns onDrop handler to upload files
* @param onUpload -- upload callback
* @param allowedTypes -- list of allowed types
*/
export const useDropZone = (onUpload: (file: File[]) => void, allowedTypes?: string[]) => {
return useCallback(
event => {
event.preventDefault();
const files: File[] = Array.from((event.dataTransfer?.files as File[]) || []).filter(
(file: File) => file?.type && (!allowedTypes || allowedTypes.includes(file.type))
);
if (!files || !files.length) return;
onUpload(files);
},
[onUpload]
);
};

23
src/utils/hooks/keys.ts Normal file
View file

@ -0,0 +1,23 @@
import { useCallback, useEffect } from 'react';
export const useArrows = (onNext: () => void, onPrev: () => void, locked) => {
const onKeyDown = useCallback(
event => {
if ((event.target.tagName && ['TEXTAREA', 'INPUT'].includes(event.target.tagName)) || locked)
return;
switch (event.key) {
case 'ArrowLeft':
return onPrev();
case 'ArrowRight':
return onNext();
}
},
[onNext, onPrev, locked]
);
useEffect(() => {
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onKeyDown]);
};

View file

@ -1,8 +1,10 @@
import { USER_ROLES } from '~/redux/auth/constants';
import { ICommentGroup, INode } from '~/redux/types';
import { ICommentGroup, IFile, INode } from '~/redux/types';
import { IUser } from '~/redux/auth/types';
import { path } from 'ramda';
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 =>
path(['role'], user) === USER_ROLES.ADMIN ||
@ -19,3 +21,11 @@ export const canStarNode = (node: Partial<INode>, user: Partial<IUser>): boolean
node.type === NODE_TYPES.IMAGE &&
path(['role'], user) &&
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]
);
};

View file

@ -1,6 +1,7 @@
{
"allowSyntheticDefaultImports": true,
"compilerOptions": {
"incremental": true,
"outDir": "./dist/",
"sourceMap": true,
"noImplicitAny": false,

View file

@ -9582,6 +9582,23 @@ tiny-invariant@^1.0.2:
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73"
integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA==
tiny-slider-react@^0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/tiny-slider-react/-/tiny-slider-react-0.5.3.tgz#0e96f6b9a6cdafaac7b1bc29cfc9cb9a356760f5"
integrity sha512-miTPlaWgwfg2U7WBDxdR40LFhAncIS2fF03tuNE5nqVIF5tuvjVFHGz1V0LSJWoNeOtXgoWs94JB2/hdxrCWqA==
dependencies:
tiny-slider "^2.9.2"
tiny-slider@2.9.2:
version "2.9.2"
resolved "https://registry.yarnpkg.com/tiny-slider/-/tiny-slider-2.9.2.tgz#dcd70ac79054a4d170bc2cfde3efbdaa2cc0c75f"
integrity sha512-2sgEJpVbpIbbgiYM/xGa0HMvvtUZSJvXeZJmLWBux6VgFqh/MQG8LXBR59ZLYpa/1OtwM0E6/ic55oLOJN9Mnw==
tiny-slider@^2.9.2:
version "2.9.3"
resolved "https://registry.yarnpkg.com/tiny-slider/-/tiny-slider-2.9.3.tgz#94d8158f704f3192fef1634c0ae6779fb14ea04e"
integrity sha512-KZY45m+t3fb3Kwlqsic0PIos1lgTNXBEC5N/AhI3aNEcryrd0nXohZMbVPMkcNYdbLjY1IUJAXWYAO6/RGJnKw==
tiny-warning@^1.0.0, tiny-warning@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"