chore: components in

This commit is contained in:
cha0s 2020-12-08 06:45:52 -06:00
parent ddec5d2283
commit 9ffb5da0b5
24 changed files with 777 additions and 41 deletions

View File

@ -0,0 +1,57 @@
import './index.scss';
import PropTypes from 'prop-types';
import React from 'react';
export default function Actions(props) {
const {
actions,
} = props;
return (
<div
className="actions"
>
{actions.map(([icon, label, onClick]) => (
<button
className="action"
key={label}
onClick={onClick}
title={label}
type="button"
>
<span
className="action__icon"
role="img"
aria-label={label}
>
{icon}
</span>
</button>
))}
</div>
);
}
export const actionsPropValidator = (propValue, key, componentName, location, propFullName) => {
const prop = propValue[key];
const propError = (message) => (
new Error(`Invalid prop '${propFullName}' passed to '${componentName}'. ${message}.`)
);
if (!Array.isArray(prop)) {
return propError('Expected an array');
}
if ('string' !== typeof prop[0]) {
return propError('First element must be a string');
}
if ('string' !== typeof prop[1]) {
return propError('Second element must be a string');
}
if ('function' !== typeof prop[2]) {
return propError('Third element must be a function');
}
return undefined;
};
Actions.propTypes = {
actions: PropTypes.arrayOf(actionsPropValidator).isRequired,
};

View File

@ -0,0 +1,19 @@
@import 'scss/breakpoints.scss';
.actions {
align-items: center;
display: flex;
padding-right: 0.5em;
position: absolute;
right: 0;
top: 0;
transition: 0.2s right;
z-index: 10;
}
.action {
background-color: transparent;
border: 0;
color: inherit;
opacity: 0;
}

View File

@ -19,7 +19,7 @@ const App = () => {
const isAnonymous = useSelector(isAnonymousSelector);
return (
<div className="app">
{!isAnonymous && <Left />}
{<Left />}
<Router history={history}>
<Switch>
<Route

View File

@ -0,0 +1,69 @@
import './index.scss';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
export default function Bar(props) {
const {
active,
buttons,
className,
onActive,
} = props;
if (buttons.length < 2) {
return null;
}
return (
<div
className={classnames(['bar', className])}
>
<ul className="bar__buttons">
{
buttons.map(({
count,
icon,
label,
}, i) => (
<li
className="bar__buttonItem"
key={label}
>
<button
className={classnames('bar__button', {active: i === active})}
onClick={() => onActive(i, active)}
title={label}
type="button"
>
<span
className="bar__buttonIcon"
role="img"
aria-label={label}
>
{icon}
</span>
{count ? <span className="unread">{count}</span> : null}
</button>
</li>
))
}
</ul>
</div>
);
}
Bar.defaultProps = {
active: 0,
className: '',
onActive: () => {},
};
Bar.propTypes = {
active: PropTypes.number,
buttons: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
icon: PropTypes.string,
})).isRequired,
className: PropTypes.string,
onActive: PropTypes.func,
};

View File

@ -0,0 +1,62 @@
@import 'scss/colors.scss';
.bar {
height: 3em;
}
.bar__items {
display: flex;
.channels--closed & {
flex-direction: column;
}
}
.bar__buttons {
display: flex;
.closed & {
flex-direction: column;
}
}
.bar__buttonItem {
position: relative;
padding: 0.5em;
padding-right: 0;
}
.bar__button {
background-color: #303030;
border: none;
height: 2em;
position: relative;
transition: 0.1s background-color;
width: 2em;
&:hover {
background-color: #2a2a2a;
}
&.active {
background-color: $color-active;
}
.closed &.active {
background-color: #303030;
transition: none;
&:hover {
background-color: #2a2a2a;
}
}
.unread {
bottom: -0.2em;
position: absolute;
right: -0.8em;
z-index: 100;
}
}
.bar__buttonIcon {
display: flow-root;
font-size: 1em;
}
.bar__buttonText {
font-size: 0.7em;
}

View File

@ -0,0 +1,22 @@
import './index.scss';
import React from 'react';
import {Link} from 'react-router-dom';
export default function Branding() {
return (
<Link className="branding" to="/">
<div>
reddi
<span className="muted">?</span>
</div>
{' '}
<div>
chat
<span className="muted">!</span>
</div>
</Link>
);
}
Branding.propTypes = {};

View File

@ -0,0 +1,26 @@
@import 'scss/colors.scss';
.branding {
align-items: center;
color: $color-muted;
display: inline-block;
font-family: var(--thick-title-font-family);
font-size: 0.95em;
justify-content: space-around;
padding: 1em;
text-decoration: none;
user-select: none;
.closed & {
flex-direction: column;
font-size: 0.9em;
text-align: center;
padding: 1em 0 0.5em;
}
.muted {
opacity: 0.5;
}
div {
display: inline-block;
}
}

View File

@ -0,0 +1,64 @@
import './index.scss';
import classnames from 'classnames';
import {getLocation} from 'connected-react-router';
import PropTypes from 'prop-types';
import React from 'react';
import {Link} from 'react-router-dom';
import {useSelector} from 'react-redux';
import {unreadForChannelSelector} from '@reddichat/user/client';
import Actions, {actionsPropValidator} from 'components/actions';
export default function Channel(props) {
const {
actions,
href,
name,
type,
} = props;
const location = useSelector(getLocation);
const unread = useSelector((state) => unreadForChannelSelector(state, `${type}${name}`));
const content = (
<>
{
unread
? (
<span className="unread">
{unread}
{' '}
</span>
)
: null
}
<span className="muted">{type}</span>
<span className="channel__name">{name}</span>
</>
);
return (
<li
className={classnames('channel__item', {active: location.pathname === href})}
>
<div className="channel">
{React.cloneElement(
href ? <Link to={href} /> : <div />,
{className: 'channel__link', title: name},
[React.cloneElement(content, {key: name})],
)}
<Actions actions={actions} />
</div>
</li>
);
}
Channel.defaultProps = {
href: null,
};
Channel.propTypes = {
actions: PropTypes.arrayOf(actionsPropValidator).isRequired,
href: PropTypes.string,
name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
};

View File

@ -0,0 +1,66 @@
@import 'scss/breakpoints.scss';
@import 'scss/colors.scss';
.channel__item {
box-shadow: 0px 0px 2px rgba(0, 0, 0, 0.1) inset;
position: relative;
width: 100%;
&:nth-of-type(even) {
background-color: rgba(0, 0, 0, 0.1);
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
}
&:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.2);
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
&.active[class] {
background-color: $color-active;
&:hover {
background-color: lighten($color-active, 5%);
}
}
}
.channel {
position: relative;
user-select: none;
&:hover .action {
opacity: 0.5;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
opacity: 1;
}
}
.action {
padding: 0.5em;
@include breakpoint(desktop) {
opacity: 0.2;
}
}
.actions {
height: 3em;
}
}
.channel__link {
color: #ffffff;
display: block;
font-family: var(--title-font-family);
height: 3em;
max-width: calc(100% - 6em);
overflow: hidden;
padding: 1em;
padding-right: 0;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
.muted {
font-size: 0.8em;
padding-top: 0.15em;
margin-right: 0.125em;
}
}

View File

@ -0,0 +1,48 @@
import './index.scss';
import PropTypes from 'prop-types';
import React from 'react';
import {actionsPropValidator} from 'components/actions';
import Channel from 'components/channel';
export default function Channels(props) {
const {channels, type, title} = props;
return (
<div
className="channels"
>
{channels.length > 0 && (
<>
<h2 className="channels__title">{title}</h2>
<ul className="channels__list">
{(
channels
.map(({actions, name}) => (
<Channel
key={name}
actions={actions}
href={`/chat${type}${name}`}
name={name}
type={type}
/>
))
)}
</ul>
</>
)}
</div>
);
}
Channels.propTypes = {
channels: PropTypes.arrayOf(PropTypes.shape({
actions: PropTypes.arrayOf(actionsPropValidator),
name: PropTypes.string,
})).isRequired,
type: PropTypes.string.isRequired,
title: PropTypes.oneOfType([
PropTypes.node,
PropTypes.string,
]).isRequired,
};

View File

@ -0,0 +1,12 @@
@import 'scss/colors.scss';
.channels__title {
color: $color-muted;
font-family: var(--thick-title-font-family);
font-size: 0.7em;
font-weight: bold;
padding: 2.5em 1em 0.625em 1.375em;
text-transform: uppercase;
white-space: nowrap;
}

View File

@ -0,0 +1,51 @@
import './index.scss';
import React from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {
activeFriendshipSelector,
favoriteUsersSelector,
idSelector,
submitAddFavorite,
submitBlock,
submitRemoveFriend,
usernamesSelector,
} from '@reddichat/user/client';
import Channels from 'components/channels';
export default function ChatLeftFriendsActive() {
const dispatch = useDispatch();
const id = useSelector(idSelector);
const favorites = useSelector(favoriteUsersSelector);
const usernames = useSelector(usernamesSelector);
const activeFriendship = useSelector(activeFriendshipSelector)
.filter(({adderId, addeeId}) => (
[adderId, addeeId].every((id) => -1 === favorites.indexOf(usernames[id]))
));
const channels = activeFriendship
.map(({addeeId, adderId}) => {
const otherId = id === adderId ? addeeId : adderId;
const name = usernames[otherId] || '?';
const actions = [
['☢️', 'Block', () => dispatch(submitBlock(otherId))],
['🚫', 'Unfriend', () => dispatch(submitRemoveFriend(otherId))],
['❤️', 'Favorite', () => dispatch(submitAddFavorite(`/u/${name}`))],
];
return {actions, name};
});
return (
<div
className="chat--leftFriendsActive"
>
<Channels
channels={channels}
type="/u/"
title="Friends"
/>
</div>
);
}
ChatLeftFriendsActive.propTypes = {};

View File

@ -0,0 +1,54 @@
import './index.scss';
import React, {useRef, useState} from 'react';
import {useDispatch} from 'react-redux';
import {validateUsername} from '@reddichat/core/client';
import {submitAddFriend} from '@reddichat/user/client';
export default function ChatLeftFriendsAdd() {
const dispatch = useDispatch();
const [text, setText] = useState('');
const $form = useRef(null);
return (
<div
className="chat--leftFriendsAdd"
>
<form
onSubmit={(event) => {
event.preventDefault();
if (validateUsername(text)) {
dispatch(submitAddFriend(text));
}
setText('');
}}
ref={$form}
>
<label className="channels__join">
Make friends with
<span className="friends__addTextWrapper">
<input
className="friends__addText"
onChange={(event) => {
setText(event.target.value);
}}
onKeyDown={(event) => {
if ('Enter' === event.key && !event.shiftKey) {
if ($form.current) {
$form.current.dispatchEvent(new Event('submit', {cancelable: true}));
}
event.preventDefault();
}
}}
placeholder="cha0s"
type="text"
value={text}
/>
</span>
</label>
</form>
</div>
);
}
ChatLeftFriendsAdd.propTypes = {};

View File

@ -0,0 +1,29 @@
@import 'scss/colors.scss';
.friends__add[class] {
align-items: center;
background-color: #272727;
flex-direction: row;
flex-wrap: nowrap;
white-space: nowrap;
}
.friends__addTextWrapper {
align-items: center;
display: flex;
flex-grow: 1;
}
.friends__addTextWrapper::before {
content: '/u/';
font-size: 0.8em;
padding: 0 0.25em 0 0.5em;
position: relative;
top: 1px;
}
.friends__addText {
flex-grow: 1;
padding-left: 0.25em;
width: 0%;
}

View File

@ -0,0 +1,36 @@
import './index.scss';
import React from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {
favoriteUsersSelector,
submitRemoveFavorite,
} from '@reddichat/user/client';
import Channels from 'components/channels';
export default function ChatLeftFriendsFavorites() {
const dispatch = useDispatch();
const favorites = useSelector(favoriteUsersSelector);
const channels = favorites
.map((channel) => ({
actions: [
['💔', 'Unfavorite', () => dispatch(submitRemoveFavorite(`/u/${channel}`))],
],
name: channel,
}));
return (
<div
className="chat--leftFriendsFavorites"
>
<Channels
channels={channels}
type="/u/"
title="Favorites"
/>
</div>
);
}
ChatLeftFriendsFavorites.propTypes = {};

View File

@ -0,0 +1,25 @@
import './index.scss';
import React from 'react';
import FriendsActive from './active';
import FriendsAdd from './add';
import FriendsFavorites from './favorites';
import FriendsPendingIncoming from './pending/incoming';
import FriendsPendingOutgoing from './pending/outgoing';
export default function ChatLeftFriends() {
return (
<div
className="friends"
>
<FriendsAdd />
<FriendsActive />
<FriendsFavorites />
<FriendsPendingIncoming />
<FriendsPendingOutgoing />
</div>
);
}
ChatLeftFriends.propTypes = {};

View File

@ -0,0 +1,49 @@
import './index.scss';
import React from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {
idSelector,
pendingFriendshipSelector,
submitConfirmFriend,
submitRemoveFriend,
usernamesSelector,
} from '@reddichat/user/client';
import Channels from 'components/channels';
export default function ChatLeftFriendsPendingIncoming() {
const dispatch = useDispatch();
const id = useSelector(idSelector);
const pendingIncomingFriendship = useSelector(pendingFriendshipSelector)
.filter(({adderId}) => adderId !== id);
const usernames = useSelector(usernamesSelector);
const channels = pendingIncomingFriendship
.map(({adderId}) => ({
name: usernames[adderId] || '?',
actions: [
['👎', 'Deny friend request', () => dispatch(submitRemoveFriend(adderId))],
['👍', 'Confirm friend request', () => dispatch(submitConfirmFriend(adderId))],
],
}));
return (
<div
className="chat--leftFriendsPendingIncoming"
>
<Channels
channels={channels}
type="/u/"
title={(
<>
<span className="unread friends__requests">{pendingIncomingFriendship.length}</span>
{' '}
Incoming requests
</>
)}
/>
</div>
);
}
ChatLeftFriendsPendingIncoming.propTypes = {};

View File

@ -0,0 +1,5 @@
.friends__requests {
color: #ffffff;
font-size: 0.9em;
padding: 0.5em 0.4em;
}

View File

@ -0,0 +1,41 @@
import './index.scss';
import React from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {
idSelector,
pendingFriendshipSelector,
submitRemoveFriend,
usernamesSelector,
} from '@reddichat/user/client';
import Channels from 'components/channels';
export default function ChatLeftFriendsPendingOutgoing() {
const dispatch = useDispatch();
const id = useSelector(idSelector);
const pendingOutgoingFriendship = useSelector(pendingFriendshipSelector)
.filter(({adderId}) => adderId === id);
const usernames = useSelector(usernamesSelector);
const channels = pendingOutgoingFriendship
.map(({addeeId}) => ({
name: usernames[addeeId] || '?',
actions: [
['×', 'Cancel friend request', () => dispatch(submitRemoveFriend(addeeId))],
],
}));
return (
<div
className="chat--leftFriendsPendingOutgoing"
>
<Channels
channels={channels}
type="/u/"
title="Outgoing requests"
/>
</div>
);
}
ChatLeftFriendsPendingOutgoing.propTypes = {};

View File

@ -2,53 +2,54 @@ import './index.scss';
import classnames from 'classnames';
import React from 'react';
// import {useDispatch, useSelector} from 'react-redux';
import {useDispatch, useSelector} from 'react-redux';
// import {
// leftActiveIndexSelector,
// leftIsOpenSelector,
// setLeftActiveIndex,
// } from '~/common/state/app';
// import {
// idSelector,
// pendingFriendshipSelector,
// unreadChannelSelector,
// unreadUserSelector,
// } from '~/common/state/user';
import {
leftActiveIndexSelector,
leftIsOpenSelector,
setLeftActiveIndex,
} from '@reddichat/app/client';
// import Bar from './bar';
// import Branding from './branding';
// import ChatLeftFriends from './chat--leftFriends';
import {
idSelector,
pendingFriendshipSelector,
unreadChannelSelector,
unreadUserSelector,
} from '@reddichat/user/client';
import Bar from 'components/bar';
import Branding from 'components/branding';
import Friends from 'components/friends';
// import ChatLeftRooms from './chat--leftRooms';
export default function ChatLeft() {
// const dispatch = useDispatch();
// const leftActiveIndex = useSelector(leftActiveIndexSelector);
// const active = useSelector(leftActiveIndexSelector);
// const isOpen = useSelector(leftIsOpenSelector);
// const id = useSelector(idSelector);
// const pendingFriendship = useSelector(pendingFriendshipSelector)
// .filter(({addeeId}) => addeeId === id).length;
// const unreadChannel = useSelector(unreadChannelSelector);
// const unreadUser = useSelector(unreadUserSelector);
const showsAsOpen = true;
// const leftButtons = [
// {
// count: unreadChannel,
// icon: '💬',
// label: 'Chat',
// },
// {
// count: unreadUser + pendingFriendship,
// icon: '😁',
// label: 'Friends',
// },
// ];
const dispatch = useDispatch();
const leftActiveIndex = useSelector(leftActiveIndexSelector);
const active = useSelector(leftActiveIndexSelector);
const isOpen = useSelector(leftIsOpenSelector);
const id = useSelector(idSelector);
const pendingFriendship = useSelector(pendingFriendshipSelector)
.filter(({addeeId}) => addeeId === id).length;
const unreadChannel = useSelector(unreadChannelSelector);
const unreadUser = useSelector(unreadUserSelector);
const showsAsOpen = isOpen;
const leftButtons = [
{
count: unreadChannel,
icon: '💬',
label: 'Chat',
},
{
count: unreadUser + pendingFriendship,
icon: '😁',
label: 'Friends',
},
];
return (
<div
className={classnames('left', 'flexed', showsAsOpen ? 'open' : 'closed')}
>
{/* <Branding />
<Branding />
<Bar
active={leftActiveIndex}
buttons={leftButtons}
@ -56,8 +57,8 @@ export default function ChatLeft() {
isHorizontal
onActive={(active) => dispatch(setLeftActiveIndex(active))}
/>
{0 === active && <ChatLeftRooms />}
{1 === active && <ChatLeftFriends />} */}
{/* {0 === active && <ChatLeftRooms />} */}
{1 === active && <Friends />}
</div>
);
}